From 7a5d4a5a3d0637646b25cc21914582169b3d1f00 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:20:07 +0400 Subject: [PATCH 01/41] core: communicate connection chat version range (#2886) * core: communicate connection chat version range * encoding * type * implementation wip * contact requests * tests * more tests * refactor * remove comment * change encoding * remove Maybe --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 220 ++++++++++-------- src/Simplex/Chat/Controller.hs | 4 +- .../M20230829_connections_chat_vrange.hs | 26 +++ src/Simplex/Chat/Migrations/chat_schema.sql | 4 + src/Simplex/Chat/Protocol.hs | 40 +++- src/Simplex/Chat/Store/Connections.hs | 3 +- src/Simplex/Chat/Store/Direct.hs | 58 +++-- src/Simplex/Chat/Store/Files.hs | 2 +- src/Simplex/Chat/Store/Groups.hs | 27 ++- src/Simplex/Chat/Store/Messages.hs | 4 +- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 6 +- src/Simplex/Chat/Store/Shared.hs | 46 ++-- src/Simplex/Chat/Types.hs | 3 + src/Simplex/Chat/View.hs | 10 +- tests/ChatTests/Direct.hs | 156 +++++++++++-- tests/ChatTests/Groups.hs | 19 +- tests/ChatTests/Profiles.hs | 2 + tests/ChatTests/Utils.hs | 11 +- tests/ProtocolTests.hs | 121 +++++----- 21 files changed, 524 insertions(+), 243 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f26e3432c..c510e7333 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -108,6 +108,7 @@ library Simplex.Chat.Migrations.M20230705_delivery_receipts Simplex.Chat.Migrations.M20230721_group_snd_item_statuses Simplex.Chat.Migrations.M20230814_indexes + Simplex.Chat.Migrations.M20230829_connections_chat_vrange Simplex.Chat.Mobile Simplex.Chat.Mobile.WebRTC Simplex.Chat.Options diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 1fabed45b..1c353f7e8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -104,6 +104,7 @@ import UnliftIO.Concurrent (forkFinally, forkIO, mkWeakThreadId, threadDelay) import UnliftIO.Directory import UnliftIO.IO (hClose, hSeek, hTell, openFile) import UnliftIO.STM +import Simplex.Messaging.Version defaultChatConfig :: ChatConfig defaultChatConfig = @@ -113,6 +114,7 @@ defaultChatConfig = { tcpPort = undefined, -- agent does not listen to TCP tbqSize = 1024 }, + chatVRange = supportedChatVRange, confirmMigrations = MCConsole, defaultServers = DefaultAgentServers @@ -1290,7 +1292,8 @@ processChatCommand = \case -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq . directMessage $ XInfo profileToSend + dm <- directMessage $ XInfo profileToSend + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined $ incognitoProfile $> profileToSend toView $ CRNewContactConnection user conn pure $ CRSentConfirmation user @@ -1430,7 +1433,8 @@ processChatCommand = \case APIJoinGroup groupId -> withUser $ \user@User {userId} -> do ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId withChatLock "joinGroup" . procCmd $ do - agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest . directMessage $ XGrpAcpt (memberId (membership :: GroupMember)) + dm <- directMessage $ XGrpAcpt (memberId (membership :: GroupMember)) + agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm withStore' $ \db -> do createMemberConnection db userId fromMember agentConnId updateGroupMemberStatus db userId fromMember GSMemAccepted @@ -1820,7 +1824,8 @@ processChatCommand = \case -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq $ directMessage (XContact profileToSend $ Just xContactId) + dm <- directMessage (XContact profileToSend $ Just xContactId) + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId toView $ CRNewContactConnection user conn @@ -2210,7 +2215,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI case (xftpRcvFile, fileConnReq) of -- direct file protocol (Nothing, Just connReq) -> do - connIds <- joinAgentConnectionAsync user True connReq . directMessage $ XFileAcpt fName + connIds <- joinAgentConnectionAsync user True connReq =<< directMessage (XFileAcpt fName) filePath <- getRcvFilePath fileId filePath_ fName True withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnJoined filePath -- XFTP @@ -2325,17 +2330,18 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact -acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do +acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do let profileToSend = profileToSendOnAccept user incognitoProfile - acId <- withAgent $ \a -> acceptContact a True invId . directMessage $ XInfo profileToSend - withStore' $ \db -> createAcceptedContact db user acId cName profileId cp userContactLinkId xContactId incognitoProfile + dm <- directMessage $ XInfo profileToSend + acId <- withAgent $ \a -> acceptContact a True invId dm + withStore' $ \db -> createAcceptedContact db user acId cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact -acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile = do +acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile = do let profileToSend = profileToSendOnAccept user incognitoProfile (cmdId, acId) <- agentAcceptContactAsync user True invId $ XInfo profileToSend withStore' $ \db -> do - ct@Contact {activeConn = Connection {connId}} <- createAcceptedContact db user acId cName profileId p userContactLinkId xContactId incognitoProfile + ct@Contact {activeConn = Connection {connId}} <- createAcceptedContact db user acId cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile setCommandConnId db user cmdId connId pure ct @@ -2825,15 +2831,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing - saveConnInfo conn connInfo + conn' <- saveConnInfo conn connInfo -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn confId $ XInfo profileToSend - INFO connInfo -> - saveConnInfo conn connInfo + allowAgentConnectionAsync user conn' confId $ XInfo profileToSend + INFO connInfo -> do + _conn' <- saveConnInfo conn connInfo + pure () MSG meta _msgFlags msgBody -> do cmdId <- createAckCmd conn - withAckMessage agentConnId cmdId meta $ - saveRcvMSG conn (ConnectionId connId) meta msgBody cmdId $> False + withAckMessage agentConnId cmdId meta $ do + (_conn', _) <- saveRcvMSG conn (ConnectionId connId) meta msgBody cmdId + pure False SENT msgId -> sentMsgDeliveryEvent conn msgId OK -> @@ -2863,49 +2871,52 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do - msg@RcvMessage {chatMsgEvent = ACME _ event} <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody cmdId - assertDirectAllowed user MDRcv ct $ toCMEventTag event + (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody cmdId + let ct' = ct {activeConn = 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 + 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 -- TODO discontinue XFile - XFile fInv -> processFileInvitation' ct fInv msg msgMeta - XFileCancel sharedMsgId -> xFileCancel ct sharedMsgId msgMeta - XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct sharedMsgId fileConnReq_ fName msgMeta - XInfo p -> xInfo ct p - XGrpInv gInv -> processGroupInvitation ct gInv msg msgMeta - XInfoProbe probe -> xInfoProbe ct probe - XInfoProbeCheck probeHash -> xInfoProbeCheck ct probeHash - XInfoProbeOk probe -> xInfoProbeOk ct probe - XCallInv callId invitation -> xCallInv ct callId invitation msg msgMeta - XCallOffer callId offer -> xCallOffer ct callId offer msg msgMeta - XCallAnswer callId answer -> xCallAnswer ct callId answer msg msgMeta - XCallExtra callId extraInfo -> xCallExtra ct callId extraInfo msg msgMeta - XCallEnd callId -> xCallEnd ct callId msg msgMeta - BFileChunk sharedMsgId chunk -> bFileChunk ct sharedMsgId chunk msgMeta + XFile fInv -> processFileInvitation' ct' fInv msg msgMeta + XFileCancel sharedMsgId -> xFileCancel ct' sharedMsgId msgMeta + XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct' sharedMsgId fileConnReq_ fName msgMeta + XInfo p -> xInfo ct' p + XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta + XInfoProbe probe -> xInfoProbe ct' probe + XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash + XInfoProbeOk probe -> xInfoProbeOk ct' probe + XCallInv callId invitation -> xCallInv ct' callId invitation msg msgMeta + XCallOffer callId offer -> xCallOffer ct' callId offer msg msgMeta + XCallAnswer callId answer -> xCallAnswer ct' callId answer msg msgMeta + XCallExtra callId extraInfo -> xCallExtra ct' callId extraInfo msg msgMeta + XCallEnd callId -> xCallEnd ct' callId msg msgMeta + BFileChunk sharedMsgId chunk -> bFileChunk ct' sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) - let Contact {chatSettings = ChatSettings {sendRcpts}} = ct + let Contact {chatSettings = ChatSettings {sendRcpts}} = ct' pure $ fromMaybe (sendRcptsContacts user) sendRcpts && hasDeliveryReceipt (toCMEventTag event) RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ directMsgReceived ct conn msgMeta msgRcpt CONF confId _ connInfo -> do -- confirming direct connection with a member - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + conn' <- updateConnChatVRange conn chatVRange case chatMsgEvent of XGrpMemInfo _memId _memProfile -> do -- TODO check member ID -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn confId XOk + allowAgentConnectionAsync user conn' confId XOk _ -> messageError "CONF from member must have x.grp.mem.info" INFO connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + _conn' <- updateConnChatVRange conn chatVRange case chatMsgEvent of XGrpMemInfo _memId _memProfile -> do -- TODO check member ID @@ -3031,7 +3042,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" CONF confId _ connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + conn' <- updateConnChatVRange conn chatVRange case memberCategory m of GCInviteeMember -> case chatMsgEvent of @@ -3039,7 +3051,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | sameMemberId memId m -> do withStore $ \db -> liftIO $ updateGroupMemberStatus db userId m GSMemAccepted -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn confId XOk + allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" _ -> messageError "CONF from invited member must have x.grp.acpt" _ -> @@ -3048,11 +3060,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | sameMemberId memId m -> do -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn confId $ XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership) + allowAgentConnectionAsync user conn' confId $ XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership) | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" _ -> messageError "CONF from member must have x.grp.mem.info" INFO connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + _conn' <- updateConnChatVRange conn chatVRange case chatMsgEvent of XGrpMemInfo memId _memProfile | sameMemberId memId m -> do @@ -3110,28 +3123,29 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do - msg@RcvMessage {chatMsgEvent = ACME _ event} <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody cmdId + (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 $ newGroupContentMessage gInfo m mc msg msgMeta - XMsgFileDescr sharedMsgId fileDescr -> canSend $ groupMessageFileDescription gInfo m sharedMsgId fileDescr msgMeta - XMsgFileCancel sharedMsgId -> cancelGroupMessageFile gInfo m sharedMsgId msgMeta - XMsgUpdate sharedMsgId mContent ttl live -> canSend $ 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 + 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 - 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 + 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 + 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 BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo @@ -3141,8 +3155,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do && hasDeliveryReceipt (toCMEventTag event) && currentMemCount <= smallGroupsRcptsMemLimit where - canSend a - | memberRole (m :: GroupMember) <= GRObserver = messageError "member is not allowed to send messages" + canSend mem a + | memberRole (mem :: GroupMember) <= GRObserver = messageError "member is not allowed to send messages" | otherwise = a RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ @@ -3227,14 +3241,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- SMP CONF for SndFileConnection happens for direct file protocol -- when recipient of the file "joins" connection created by the sender CONF confId _ connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + conn' <- updateConnChatVRange conn chatVRange case chatMsgEvent of -- TODO save XFileAcpt message XFileAcpt name | name == fileName -> do withStore' $ \db -> updateSndFileStatus db ft FSAccepted -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn confId XOk + allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.file.acpt: fileName is different from expected" _ -> messageError "CONF from file connection must have x.file.acpt" CON -> do @@ -3295,9 +3310,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- when sender of the file "joins" connection created by the recipient -- (sender doesn't create connections for all group members) CONF confId _ connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + conn' <- updateConnChatVRange conn chatVRange case chatMsgEvent of - XOk -> allowAgentConnectionAsync user conn confId XOk -- [async agent commands] no continuation needed, but command should be asynchronous for stability + XOk -> allowAgentConnectionAsync user conn' confId XOk -- [async agent commands] no continuation needed, but command should be asynchronous for stability _ -> pure () CON -> startReceivingFile user fileId MSG meta _ msgBody -> do @@ -3356,10 +3372,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processUserContactRequest :: ACommand 'Agent e -> ConnectionEntity -> Connection -> UserContact -> m () processUserContactRequest agentMsg connEntity conn UserContact {userContactLinkId} = case agentMsg of REQ invId _ connInfo -> do - ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo case chatMsgEvent of - XContact p xContactId_ -> profileContactRequest invId p xContactId_ - XInfo p -> profileContactRequest invId p Nothing + XContact p xContactId_ -> profileContactRequest invId chatVRange p xContactId_ + XInfo p -> profileContactRequest invId chatVRange p Nothing -- TODO show/log error, other events in contact request _ -> pure () MERR _ err -> do @@ -3371,9 +3387,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- TODO add debugging output _ -> pure () where - profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m () - profileContactRequest invId p xContactId_ = do - withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId p xContactId_) >>= \case + profileContactRequest :: InvitationId -> VersionRange -> Profile -> Maybe XContactId -> m () + profileContactRequest invId chatVRange p xContactId_ = do + withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq@UserContactRequest {localDisplayName} -> do withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case @@ -3870,7 +3886,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do then unless cancelled $ case fileConnReq_ of -- receiving via a separate connection Just fileConnReq -> do - connIds <- joinAgentConnectionAsync user True fileConnReq $ directMessage XOk + connIds <- joinAgentConnectionAsync user True fileConnReq =<< directMessage XOk withStore' $ \db -> createSndDirectFTConnection db user fileId connIds -- receiving inline _ -> do @@ -3967,7 +3983,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (Just fileConnReq, _) -> do -- receiving via a separate connection -- [async agent commands] no continuation needed, but command should be asynchronous for stability - connIds <- joinAgentConnectionAsync user True fileConnReq $ directMessage XOk + connIds <- joinAgentConnectionAsync user True fileConnReq =<< directMessage XOk withStore' $ \db -> createSndGroupFileTransferConnection db user fileId connIds m (_, Just conn) -> do -- receiving inline @@ -3999,7 +4015,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId if sameGroupLinkId groupLinkId groupLinkId' then do - connIds <- joinAgentConnectionAsync user True connRequest . directMessage $ XGrpAcpt memberId + connIds <- joinAgentConnectionAsync user True connRequest =<< directMessage (XGrpAcpt memberId) withStore' $ \db -> do createMemberConnectionAsync db user hostId connIds updateGroupMemberStatusById db userId hostId GSMemAccepted @@ -4201,15 +4217,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> mergeContactRecords db userId c1 c2 toView $ CRContactsMerged user c1 c2 - saveConnInfo :: Connection -> ConnInfo -> m () + saveConnInfo :: Connection -> ConnInfo -> m Connection saveConnInfo activeConn connInfo = do - ChatMessage {chatMsgEvent} <- parseChatMessage activeConn connInfo + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage activeConn connInfo + conn' <- updateConnChatVRange activeConn chatVRange case chatMsgEvent of XInfo p -> do - ct <- withStore $ \db -> createDirectContact db user activeConn p + ct <- withStore $ \db -> createDirectContact db user conn' p toView $ CRContactConnecting user ct + pure conn' -- TODO show/log error, other events in SMP confirmation - _ -> pure () + _ -> pure conn' xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> MsgMeta -> m () xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole memberProfile) msg msgMeta = do @@ -4274,10 +4292,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Just m' -> pure m' withStore' $ \db -> saveMemberInvitation db toMember introInv -- [incognito] send membership incognito profile, create direct connection as incognito - let msg = XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership) + dm <- directMessage $ XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership) -- [async agent commands] no continuation needed, but commands should be asynchronous for stability - groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq $ directMessage msg - directConnIds <- joinAgentConnectionAsync user enableNtfs directConnReq $ directMessage msg + groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq dm + directConnIds <- joinAgentConnectionAsync user enableNtfs directConnReq dm let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing withStore' $ \db -> createIntroToMemberContact db user m toMember groupConnIds directConnIds customUserProfileId @@ -4419,6 +4437,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () +updateConnChatVRange :: ChatMonad m => Connection -> VersionRange -> m Connection +updateConnChatVRange conn@Connection {connId, connChatVRange} msgChatVRange + | msgChatVRange /= connChatVRange = do + withStore' $ \db -> setConnChatVRange db connId msgChatVRange + pure conn {connChatVRange = msgChatVRange} + | otherwise = pure conn + parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) @@ -4617,12 +4642,15 @@ sendDirectMessage conn chatMsgEvent connOrGroupId = do createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> m SndMessage createSndMessage chatMsgEvent connOrGroupId = do gVar <- asks idsDrg + ChatConfig {chatVRange} <- asks config withStore $ \db -> createNewSndMessage db gVar connOrGroupId $ \sharedMsgId -> - let msgBody = strEncode ChatMessage {msgId = Just sharedMsgId, chatMsgEvent} + let msgBody = strEncode ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent} in NewMessage {chatMsgEvent, msgBody} -directMessage :: MsgEncodingI e => ChatMsgEvent e -> ByteString -directMessage chatMsgEvent = strEncode ChatMessage {msgId = Nothing, chatMsgEvent} +directMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString +directMessage chatMsgEvent = do + ChatConfig {chatVRange} <- asks config + pure $ strEncode ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m Int64 deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do @@ -4677,15 +4705,17 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn _ -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName _ -> pure () -saveRcvMSG :: ChatMonad m => Connection -> ConnOrGroupId -> MsgMeta -> MsgBody -> CommandId -> m RcvMessage +saveRcvMSG :: ChatMonad m => Connection -> ConnOrGroupId -> MsgMeta -> MsgBody -> CommandId -> m (Connection, RcvMessage) saveRcvMSG conn@Connection {connId} connOrGroupId agentMsgMeta msgBody agentAckCmdId = do - ACMsg _ ChatMessage {msgId = sharedMsgId_, chatMsgEvent} <- parseAChatMessage conn agentMsgMeta msgBody + ACMsg _ ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} <- parseAChatMessage conn agentMsgMeta msgBody + conn' <- updateConnChatVRange conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta newMsg = NewMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} - withStoreCtx' + msg <- withStoreCtx' (Just $ "createNewMessageAndRcvMsgDelivery, rcvMsgDelivery: " <> show rcvMsgDelivery <> ", sharedMsgId_: " <> show sharedMsgId_ <> ", msgDeliveryStatus: MDSRcvAgent") $ \db -> createNewMessageAndRcvMsgDelivery db connOrGroupId newMsg sharedMsgId_ rcvMsgDelivery + pure (conn', msg) 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 @@ -4785,13 +4815,15 @@ joinAgentConnectionAsync user enableNtfs cReqUri cInfo = do allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m () allowAgentConnectionAsync user conn@Connection {connId} confId msg = do cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn - withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId $ directMessage msg + dm <- directMessage msg + withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> m (CommandId, ConnId) agentAcceptContactAsync user enableNtfs invId msg = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact - connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId $ directMessage msg + dm <- directMessage msg + connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm pure (cmdId, connId) deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m () diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 615e472f2..b942256c1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -64,6 +64,7 @@ import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors) +import Simplex.Messaging.Version import System.IO (Handle) import System.Mem.Weak (Weak) import UnliftIO.STM @@ -72,7 +73,7 @@ versionNumber :: String versionNumber = showVersion SC.version versionString :: String -> String -versionString version = "SimpleX Chat v" <> version +versionString ver = "SimpleX Chat v" <> ver updateStr :: String updateStr = "To update run: curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash" @@ -101,6 +102,7 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, + chatVRange :: VersionRange, confirmMigrations :: MigrationConfirmation, defaultServers :: DefaultAgentServers, tbqSize :: Natural, diff --git a/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs b/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs new file mode 100644 index 000000000..b657cc648 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs @@ -0,0 +1,26 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230829_connections_chat_vrange where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230829_connections_chat_vrange :: Query +m20230829_connections_chat_vrange = + [sql| +ALTER TABLE connections ADD COLUMN chat_vrange_min_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE connections ADD COLUMN chat_vrange_max_version INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE contact_requests ADD COLUMN chat_vrange_min_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE contact_requests ADD COLUMN chat_vrange_max_version INTEGER NOT NULL DEFAULT 1; +|] + +down_m20230829_connections_chat_vrange :: Query +down_m20230829_connections_chat_vrange = + [sql| +ALTER TABLE contact_requests DROP COLUMN chat_vrange_max_version; +ALTER TABLE contact_requests DROP COLUMN chat_vrange_min_version; + +ALTER TABLE connections DROP COLUMN chat_vrange_max_version; +ALTER TABLE connections DROP COLUMN chat_vrange_min_version; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 76b7ba4a1..b41d7efe6 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -283,6 +283,8 @@ CREATE TABLE connections( security_code TEXT NULL, security_code_verified_at TEXT NULL, auth_err_counter INTEGER DEFAULT 0 CHECK(auth_err_counter NOT NULL), + chat_vrange_min_version INTEGER NOT NULL DEFAULT 1, + chat_vrange_max_version INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -316,6 +318,8 @@ CREATE TABLE contact_requests( user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK(updated_at NOT NULL), xcontact_id BLOB, + chat_vrange_min_version INTEGER NOT NULL DEFAULT 1, + chat_vrange_max_version INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON UPDATE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 31d1eb573..5c33eb06c 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -46,6 +46,13 @@ import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) +import Simplex.Messaging.Version hiding (version) + +currentChatVersion :: Version +currentChatVersion = 2 + +supportedChatVRange :: VersionRange +supportedChatVRange = mkVersionRange 1 currentChatVersion data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} @@ -100,9 +107,22 @@ data AppMessage (e :: MsgEncoding) where AMJson :: AppMessageJson -> AppMessage 'Json AMBinary :: AppMessageBinary -> AppMessage 'Binary +newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRange} deriving (Eq, Show) + +chatInitialVRange :: VersionRange +chatInitialVRange = versionToRange 1 + +instance FromJSON ChatVersionRange where + parseJSON v = ChatVersionRange <$> strParseJSON "ChatVersionRange" v + +instance ToJSON ChatVersionRange where + toJSON (ChatVersionRange vr) = strToJSON vr + toEncoding (ChatVersionRange vr) = strToJEncoding vr + -- chat message is sent as JSON with these properties data AppMessageJson = AppMessageJson - { msgId :: Maybe SharedMsgId, + { v :: Maybe ChatVersionRange, + msgId :: Maybe SharedMsgId, event :: Text, params :: J.Object } @@ -161,7 +181,11 @@ instance ToJSON MsgRef where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} -data ChatMessage e = ChatMessage {msgId :: Maybe SharedMsgId, chatMsgEvent :: ChatMsgEvent e} +data ChatMessage e = ChatMessage + { chatVRange :: VersionRange, + msgId :: Maybe SharedMsgId, + chatMsgEvent :: ChatMsgEvent e + } deriving (Eq, Show) data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e) @@ -724,17 +748,17 @@ appBinaryToCM :: AppMessageBinary -> Either String (ChatMessage 'Binary) appBinaryToCM AppMessageBinary {msgId, tag, body} = do eventTag <- strDecode $ B.singleton tag chatMsgEvent <- parseAll (msg eventTag) body - pure ChatMessage {msgId, chatMsgEvent} + pure ChatMessage {chatVRange = chatInitialVRange, msgId, chatMsgEvent} where msg :: CMEventTag 'Binary -> A.Parser (ChatMsgEvent 'Binary) msg = \case BFileChunk_ -> BFileChunk <$> (SharedMsgId <$> smpP) <*> (unIFC <$> smpP) appJsonToCM :: AppMessageJson -> Either String (ChatMessage 'Json) -appJsonToCM AppMessageJson {msgId, event, params} = do +appJsonToCM AppMessageJson {v, msgId, event, params} = do eventTag <- strDecode $ encodeUtf8 event chatMsgEvent <- msg eventTag - pure ChatMessage {msgId, chatMsgEvent} + pure ChatMessage {chatVRange = maybe chatInitialVRange fromChatVRange v, msgId, chatMsgEvent} where p :: FromJSON a => J.Key -> Either String a p key = JT.parseEither (.: key) params @@ -784,11 +808,11 @@ appJsonToCM AppMessageJson {msgId, event, params} = do key .=? value = maybe id ((:) . (key .=)) value chatToAppMessage :: forall e. MsgEncodingI e => ChatMessage e -> AppMessage e -chatToAppMessage ChatMessage {msgId, chatMsgEvent} = case encoding @e of +chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @e of SBinary -> let (binaryMsgId, body) = toBody chatMsgEvent in AMBinary AppMessageBinary {msgId = binaryMsgId, tag = B.head $ strEncode tag, body} - SJson -> AMJson AppMessageJson {msgId, event = textEncode tag, params = params chatMsgEvent} + SJson -> AMJson AppMessageJson {v = Just $ ChatVersionRange chatVRange, msgId, event = textEncode tag, params = params chatMsgEvent} where tag = toCMEventTag chatMsgEvent o :: [(J.Key, J.Value)] -> J.Object @@ -804,7 +828,7 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = case encoding @e of 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 - XMsgReact msgId' memberId reaction add -> o $ ("memberId" .=? memberId) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] + XMsgReact msgId' memberId reaction add -> o $ ("memberId" .=? memberId) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] XFile fileInv -> o ["file" .= fileInv] XFileAcpt fileName -> o ["fileName" .= fileName] XFileAcptInv sharedMsgId fileConnReq fileName -> o $ ("fileConnReq" .=? fileConnReq) ["msgId" .= sharedMsgId, "fileName" .= fileName] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index e31598812..a7c8fd6c3 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -49,7 +49,8 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter + conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + chat_vrange_min_version, chat_vrange_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? |] diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index de1c5014b..00d3d55c9 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -75,6 +75,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Version getPendingContactConnection :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO PendingContactConnection getPendingContactConnection db userId connId = do @@ -143,7 +144,8 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.chat_vrange_min_version, c.chat_vrange_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id @@ -411,8 +413,8 @@ getUserContacts db user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId) rights <$> mapM (runExceptT . getContact db user) contactIds -createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest -createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profile {displayName, fullName, image, contactLink, preferences} xContactId_ = +createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> VersionRange -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest +createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ = liftIO (maybeM getContact' xContactId_) >>= \case Just contact -> pure $ CORContact contact Nothing -> CORRequest <$> createOrUpdate_ @@ -441,10 +443,10 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi db [sql| INSERT INTO contact_requests - (user_contact_link_id, agent_invitation_id, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id) - VALUES (?,?,?,?,?,?,?,?) + (user_contact_link_id, agent_invitation_id, chat_vrange_min_version, chat_vrange_max_version, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id) + VALUES (?,?,?,?,?,?,?,?,?,?) |] - (userContactLinkId, invId, profileId, ldn, userId, currentTs, currentTs, xContactId_) + (userContactLinkId, invId, minV, maxV, profileId, ldn, userId, currentTs, currentTs, xContactId_) insertedRowId db getContact' :: XContactId -> IO (Maybe Contact) getContact' xContactId = @@ -458,7 +460,8 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.chat_vrange_min_version, c.chat_vrange_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id @@ -475,7 +478,8 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, + cr.chat_vrange_min_version, cr.chat_vrange_max_version FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -489,10 +493,26 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi currentTs <- liftIO getCurrentTime updateProfile currentTs if displayName == oldDisplayName - then Right <$> DB.execute db "UPDATE contact_requests SET agent_invitation_id = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ?" (invId, currentTs, userId, cReqId) + then + Right + <$> DB.execute + db + [sql| + UPDATE contact_requests + SET agent_invitation_id = ?, chat_vrange_min_version = ?, chat_vrange_max_version = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + |] + (invId, minV, maxV, currentTs, userId, cReqId) else withLocalDisplayName db userId displayName $ \ldn -> Right <$> do - DB.execute db "UPDATE contact_requests SET agent_invitation_id = ?, local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ?" (invId, ldn, currentTs, userId, cReqId) + DB.execute + db + [sql| + UPDATE contact_requests + SET agent_invitation_id = ?, chat_vrange_min_version = ?, chat_vrange_max_version = ?, local_display_name = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + |] + (invId, minV, maxV, ldn, currentTs, userId, cReqId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (oldLdn, userId) where updateProfile currentTs = @@ -527,7 +547,8 @@ getContactRequest db User {userId} contactRequestId = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, + cr.chat_vrange_min_version, cr.chat_vrange_max_version FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -566,8 +587,8 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: DB.Connection -> User -> ConnId -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> IO Contact -createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do +createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> IO Contact +createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) createdAt <- getCurrentTime customUserProfileId <- forM incognitoProfile $ \case @@ -579,7 +600,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id) VALUES (?,?,?,?,?,?,?,?,?)" (userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId) contactId <- insertedRowId db - activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) customUserProfileId 0 createdAt + activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt} @@ -603,7 +624,8 @@ getContact_ db user@User {userId} contactId deleted = cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.chat_vrange_min_version, c.chat_vrange_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id @@ -651,7 +673,8 @@ getContactConnections db userId Contact {contactId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? @@ -667,7 +690,8 @@ getConnectionById db User {userId} connId = ExceptT $ do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter + conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + chat_vrange_min_version, chat_vrange_max_version FROM connections WHERE user_id = ? AND connection_id = ? |] diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 249dfedc3..4c370d15e 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -421,7 +421,7 @@ getChatRefByFileId db User {userId} fileId = createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection createSndFileConnection_ db userId fileId agentConnId = do currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId Nothing Nothing Nothing 0 currentTs + createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 32a3b9110..6c2f32f76 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -98,6 +98,7 @@ import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared +import Simplex.Chat.Protocol (chatInitialVRange) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId, UserId) @@ -142,7 +143,7 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing Nothing 0 currentTs + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs getGroupLinkConnection :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO Connection getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = @@ -151,7 +152,8 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.group_id = ? @@ -232,7 +234,8 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, 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, 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.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -524,7 +527,8 @@ groupMemberQuery = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, 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, 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.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.connection_id = ( @@ -682,7 +686,8 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} = cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.chat_vrange_min_version, c.chat_vrange_max_version FROM contacts ct JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id JOIN connections c ON c.connection_id = ( @@ -911,7 +916,7 @@ createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> Memb createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) (groupCmdId, groupAgentConnId) (directCmdId, directAgentConnId) customUserProfileId = do let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn currentTs <- liftIO getCurrentTime - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId memberContactId Nothing customUserProfileId cLevel currentTs + Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId chatInitialVRange memberContactId Nothing customUserProfileId cLevel currentTs liftIO $ setCommandConnId db user directCmdId directConnId (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing liftIO $ do @@ -936,7 +941,7 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = currentTs <- getCurrentTime Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId viaContactId cLevel currentTs setCommandConnId db user groupCmdId groupConnId - Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId viaContactId Nothing customUserProfileId cLevel currentTs + Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId chatInitialVRange viaContactId Nothing customUserProfileId cLevel currentTs setCommandConnId db user directCmdId directConnId contactId <- createMemberContact_ directConnId currentTs updateMember_ contactId currentTs @@ -967,7 +972,7 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createMemberConnection_ db userId groupMemberId agentConnId viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId viaContact Nothing Nothing +createMemberConnection_ db userId groupMemberId agentConnId viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId chatInitialVRange viaContact Nothing Nothing getViaGroupMember :: DB.Connection -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db User {userId, userContactId} Contact {contactId} = @@ -987,7 +992,8 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, 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, 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.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -1020,7 +1026,8 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, 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.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id JOIN connections c ON c.connection_id = ( diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 7bc2eaf4d..5f760add1 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -478,6 +478,7 @@ getDirectChatPreviews_ db user@User {userId} = do -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), ct.unread_chat, -- ChatItem @@ -608,7 +609,8 @@ getContactRequestChatPreviews_ db User {userId} = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, + cr.chat_vrange_min_version, cr.chat_vrange_max_version FROM contact_requests cr JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 6da0d1cdc..3bd9bf4f7 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -76,6 +76,7 @@ import Simplex.Chat.Migrations.M20230621_chat_item_moderations import Simplex.Chat.Migrations.M20230705_delivery_receipts import Simplex.Chat.Migrations.M20230721_group_snd_item_statuses import Simplex.Chat.Migrations.M20230814_indexes +import Simplex.Chat.Migrations.M20230829_connections_chat_vrange import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -151,7 +152,8 @@ schemaMigrations = ("20230621_chat_item_moderations", m20230621_chat_item_moderations, Just down_m20230621_chat_item_moderations), ("20230705_delivery_receipts", m20230705_delivery_receipts, Just down_m20230705_delivery_receipts), ("20230721_group_snd_item_statuses", m20230721_group_snd_item_statuses, Just down_m20230721_group_snd_item_statuses), - ("20230814_indexes", m20230814_indexes, Just down_m20230814_indexes) + ("20230814_indexes", m20230814_indexes, Just down_m20230814_indexes), + ("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 48f2dd144..1d305ffd6 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -302,7 +302,7 @@ createUserContactLink db User {userId} agentConnId cReq = "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing Nothing 0 currentTs + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs getUserAddressConnections :: DB.Connection -> User -> ExceptT StoreError IO [Connection] getUserAddressConnections db User {userId} = do @@ -316,7 +316,8 @@ getUserAddressConnections db User {userId} = do db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter + c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL @@ -331,6 +332,7 @@ getUserContactLinks db User {userId} = [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index c4e6b8d90..5bae79d80 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -17,8 +17,8 @@ import Control.Monad.Except import Crypto.Random (ChaChaDRG, randomBytesGenerate) import Data.Aeson (ToJSON) import qualified Data.Aeson as J -import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Base64 as B64 +import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text (Text) @@ -37,6 +37,7 @@ import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Util (allFinally) +import Simplex.Messaging.Version import UnliftIO.STM -- These error type constructors must be added to mobile apps @@ -132,15 +133,16 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int, Version, Version) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int, Maybe Version, Maybe Version) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter)) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = let entityId = entityId_ connType connectionCode = SecurityCode <$> code_ <*> verifiedAt_ - in Connection {connId, agentConnId = AgentConnId acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, connectionCode, authErrCounter, createdAt} + connChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + in Connection {connId, agentConnId = AgentConnId acId, connChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, connectionCode, authErrCounter, createdAt} where entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId @@ -150,12 +152,12 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter)) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter, Just minVer, Just maxVer)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) toMaybeConnection _ = Nothing -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> IO Connection -createConnection_ db userId connType entityId acId viaContact viaUserContactLink customUserProfileId connLevel currentTs = do +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> IO Connection +createConnection_ db userId connType entityId acId connChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs = do viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId -> maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId) let viaGroupLink = isJust viaLinkGroupId @@ -164,17 +166,30 @@ createConnection_ db userId connType entityId acId viaContact viaUserContactLink [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, - contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, + chat_vrange_min_version, chat_vrange_max_version + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, ConnNew, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) + :. (minV, maxV) ) connId <- insertedRowId db - pure Connection {connId, agentConnId = AgentConnId acId, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure Connection {connId, agentConnId = AgentConnId acId, connChatVRange, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} where ent ct = if connType == ct then entityId else Nothing +setConnChatVRange :: DB.Connection -> Int64 -> VersionRange -> IO () +setConnChatVRange db connId (VersionRange minVer maxVer) = + DB.execute + db + [sql| + UPDATE connections + SET chat_vrange_min_version = ?, chat_vrange_max_version = ? + WHERE connection_id = ? + |] + (minVer, maxVer, connId) + setCommandConnId :: DB.Connection -> User -> CommandId -> Int64 -> IO () setCommandConnId db User {userId} cmdId connId = do updatedAt <- getCurrentTime @@ -256,12 +271,13 @@ getProfileById db userId profileId = toProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) -> LocalProfile toProfile (displayName, fullName, image, contactLink, localAlias, preferences) = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, Maybe Preferences, UTCTime, UTCTime) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, Maybe Preferences, UTCTime, UTCTime, Version, Version) toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, preferences, createdAt, updatedAt)) = do +toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, preferences, createdAt, updatedAt, minVer, maxVer)) = do let profile = Profile {displayName, fullName, image, contactLink, preferences} - in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, xContactId, createdAt, updatedAt} + cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, cReqChatVRange, localDisplayName, profileId, profile, xContactId, createdAt, updatedAt} userQuery :: Query userQuery = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index ac71ce612..ac19cbc36 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -46,6 +46,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util ((<$?>)) +import Simplex.Messaging.Version class IsContact a where contactId' :: a -> ContactId @@ -231,6 +232,7 @@ data UserContactRequest = UserContactRequest agentInvitationId :: AgentInvId, userContactLinkId :: Int64, agentContactConnId :: AgentConnId, -- connection id of user contact + cReqChatVRange :: VersionRange, localDisplayName :: ContactName, profileId :: Int64, profile :: Profile, @@ -1154,6 +1156,7 @@ type ConnReqContact = ConnectionRequestUri 'CMContact data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, + connChatVRange :: VersionRange, connLevel :: Int, viaContact :: Maybe Int64, -- group member contact ID, if not direct connection viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 033b1c9f7..2f512a9b7 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -57,6 +57,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, Pro import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (bshow, tshow) +import Simplex.Messaging.Version hiding (version) import System.Console.ANSI.Types type CurrentTime = UTCTime @@ -949,7 +950,7 @@ viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} = ] viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString] -viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}} stats incognitoProfile = +viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn} stats incognitoProfile = ["contact ID: " <> sShow contactId] <> viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) l]) contactLink <> maybe @@ -958,6 +959,7 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta incognitoProfile <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (contactSecurityCode ct)] + <> [viewConnChatVRange (connChatVRange activeConn)] viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] viewGroupInfo GroupInfo {groupId} s = @@ -966,18 +968,22 @@ viewGroupInfo GroupInfo {groupId} s = ] viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString] -viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}} stats = +viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}, activeConn} stats = [ "group ID: " <> sShow groupId, "member ID: " <> sShow groupMemberId ] <> maybe ["member not connected"] viewConnectionStats stats <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (memberSecurityCode m) | isJust stats] + <> maybe [] (\ac -> [viewConnChatVRange (connChatVRange ac)]) activeConn viewConnectionVerified :: Maybe SecurityCode -> StyledString viewConnectionVerified (Just _) = "connection verified" -- TODO show verification time? viewConnectionVerified _ = "connection not verified, use " <> highlight' "/code" <> " command to see security code" +viewConnChatVRange :: VersionRange -> StyledString +viewConnChatVRange (VersionRange minVer maxVer) = "chat protocol version range: (" <> sShow minVer <> ", " <> sShow maxVer <> ")" + viewConnectionStats :: ConnectionStats -> [StyledString] viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} = ["receiving messages via: " <> viewRcvQueuesInfo rcvQueuesInfo | not $ null rcvQueuesInfo] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 2384daac3..e7442d90a 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -17,9 +17,11 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Options (ChatOpts (..)) +import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (authErrDisableCount, sameVerificationCode, verificationCode) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) import System.FilePath (()) import Test.Hspec @@ -94,6 +96,21 @@ chatDirectTests = do describe "delivery receipts" $ do it "should send delivery receipts" testSendDeliveryReceipts it "should send delivery receipts depending on configuration" testConfigureDeliveryReceipts + describe "negotiate connection chat protocol version range" $ do + describe "version range correctly set for new connection via invitation" $ do + testInvVRange supportedChatVRange supportedChatVRange + testInvVRange supportedChatVRange vr11 + testInvVRange vr11 supportedChatVRange + testInvVRange vr11 vr11 + describe "version range correctly set for new connection via contact request" $ do + testReqVRange supportedChatVRange supportedChatVRange + testReqVRange supportedChatVRange vr11 + testReqVRange vr11 supportedChatVRange + testReqVRange vr11 vr11 + it "update connection version range on received messages" testUpdateConnChatVRange + where + testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 + testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 testAddContact :: HasCallStack => SpecWith FilePath testAddContact = versionTestMatrix2 runTestAddContact @@ -1939,8 +1956,7 @@ testMarkContactVerified = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob alice ##> "/i bob" - bobInfo alice - alice <## "connection not verified, use /code command to see security code" + bobInfo alice False alice ##> "/code bob" bCode <- getTermLine alice bob ##> "/code alice" @@ -1951,28 +1967,31 @@ testMarkContactVerified = alice ##> ("/verify bob " <> aCode) alice <## "connection verified" alice ##> "/i bob" - bobInfo alice - alice <## "connection verified" + bobInfo alice True alice ##> "/verify bob" alice <##. "connection not verified, current code is " alice ##> "/i bob" - bobInfo alice - alice <## "connection not verified, use /code command to see security code" + bobInfo alice False where - bobInfo :: HasCallStack => TestCC -> IO () - bobInfo alice = do + bobInfo :: HasCallStack => TestCC -> Bool -> IO () + bobInfo alice verified = do alice <## "contact ID: 2" alice <## "receiving messages via: localhost" alice <## "sending messages via: localhost" alice <## "you've shared main profile with this contact" + alice <## connVerified + alice <## currentChatVRangeInfo + where + connVerified + | verified = "connection verified" + | otherwise = "connection not verified, use /code command to see security code" testMarkGroupMemberVerified :: HasCallStack => FilePath -> IO () testMarkGroupMemberVerified = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob alice ##> "/i #team bob" - bobInfo alice - alice <## "connection not verified, use /code command to see security code" + bobInfo alice False alice ##> "/code #team bob" bCode <- getTermLine alice bob ##> "/code #team alice" @@ -1983,20 +2002,24 @@ testMarkGroupMemberVerified = alice ##> ("/verify #team bob " <> aCode) alice <## "connection verified" alice ##> "/i #team bob" - bobInfo alice - alice <## "connection verified" + bobInfo alice True alice ##> "/verify #team bob" alice <##. "connection not verified, current code is " alice ##> "/i #team bob" - bobInfo alice - alice <## "connection not verified, use /code command to see security code" + bobInfo alice False where - bobInfo :: HasCallStack => TestCC -> IO () - bobInfo alice = do + bobInfo :: HasCallStack => TestCC -> Bool -> IO () + bobInfo alice verified = do alice <## "group ID: 1" alice <## "member ID: 2" alice <## "receiving messages via: localhost" alice <## "sending messages via: localhost" + alice <## connVerified + alice <## currentChatVRangeInfo + where + connVerified + | verified = "connection verified" + | otherwise = "connection not verified, use /code command to see security code" testMsgDecryptError :: HasCallStack => FilePath -> IO () testMsgDecryptError tmp = @@ -2088,8 +2111,7 @@ testSyncRatchetCodeReset tmp = alice <# "bob> hey" -- connection not verified bob ##> "/i alice" - aliceInfo bob - bob <## "connection not verified, use /code command to see security code" + aliceInfo bob False -- verify connection alice ##> "/code bob" bCode <- getTermLine alice @@ -2097,8 +2119,7 @@ testSyncRatchetCodeReset tmp = bob <## "connection verified" -- connection verified bob ##> "/i alice" - aliceInfo bob - bob <## "connection verified" + aliceInfo bob True setupDesynchronizedRatchet tmp alice withTestChat tmp "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" @@ -2115,20 +2136,25 @@ testSyncRatchetCodeReset tmp = -- connection not verified bob ##> "/i alice" - aliceInfo bob - bob <## "connection not verified, use /code command to see security code" + aliceInfo bob False alice #> "@bob hello again" bob <# "alice> hello again" bob #> "@alice received!" alice <# "bob> received!" where - aliceInfo :: HasCallStack => TestCC -> IO () - aliceInfo bob = do + aliceInfo :: HasCallStack => TestCC -> Bool -> IO () + aliceInfo bob verified = do bob <## "contact ID: 2" bob <## "receiving messages via: localhost" bob <## "sending messages via: localhost" bob <## "you've shared main profile with this contact" + bob <## connVerified + bob <## currentChatVRangeInfo + where + connVerified + | verified = "connection verified" + | otherwise = "connection not verified, use /code command to see security code" testSetMessageReactions :: HasCallStack => FilePath -> IO () testSetMessageReactions = @@ -2271,3 +2297,85 @@ testConfigureDeliveryReceipts tmp = cc1 #> ("@" <> name2 <> " " <> msg) cc2 <# (name1 <> "> " <> msg) cc1 VersionRange -> VersionRange -> FilePath -> IO () +testConnInvChatVRange ct1VRange ct2VRange tmp = + withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do + connectUsers alice bob + + alice ##> "/i bob" + contactInfoChatVRange alice ct2VRange + + bob ##> "/i alice" + contactInfoChatVRange bob ct1VRange + +testConnReqChatVRange :: HasCallStack => VersionRange -> VersionRange -> FilePath -> IO () +testConnReqChatVRange ct1VRange ct2VRange tmp = + withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + + alice ##> "/i bob" + contactInfoChatVRange alice ct2VRange + + bob ##> "/i alice" + contactInfoChatVRange bob ct1VRange + +testUpdateConnChatVRange :: HasCallStack => FilePath -> IO () +testUpdateConnChatVRange tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg11 "bob" bobProfile $ \bob -> do + connectUsers alice bob + + alice ##> "/i bob" + contactInfoChatVRange alice vr11 + + bob ##> "/i alice" + contactInfoChatVRange bob supportedChatVRange + + withTestChat tmp "bob" $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + + bob #> "@alice hello 1" + alice <# "bob> hello 1" + + alice ##> "/i bob" + contactInfoChatVRange alice supportedChatVRange + + bob ##> "/i alice" + contactInfoChatVRange bob supportedChatVRange + + withTestChatCfg tmp cfg11 "bob" $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + + bob #> "@alice hello 2" + alice <# "bob> hello 2" + + alice ##> "/i bob" + contactInfoChatVRange alice vr11 + + bob ##> "/i alice" + contactInfoChatVRange bob supportedChatVRange + where + cfg11 = testCfg {chatVRange = vr11} :: ChatConfig + +vr11 :: VersionRange +vr11 = mkVersionRange 1 1 + +contactInfoChatVRange :: TestCC -> VersionRange -> IO () +contactInfoChatVRange cc (VersionRange minVer maxVer) = do + cc <## "contact ID: 2" + cc <## "receiving messages via: localhost" + cc <## "sending messages via: localhost" + cc <## "you've shared main profile with this contact" + cc <## "connection not verified, use /code command to see security code" + cc <## ("chat protocol version range: (" <> show minVer <> ", " <> show maxVer <> ")") diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 2f5b2689b..7a4bb2061 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2289,8 +2289,7 @@ testGroupSyncRatchetCodeReset tmp = alice <# "#team bob> hey" -- connection not verified bob ##> "/i #team alice" - aliceInfo bob - bob <## "connection not verified, use /code command to see security code" + aliceInfo bob False -- verify connection alice ##> "/code #team bob" bCode <- getTermLine alice @@ -2298,8 +2297,7 @@ testGroupSyncRatchetCodeReset tmp = bob <## "connection verified" -- connection verified bob ##> "/i #team alice" - aliceInfo bob - bob <## "connection verified" + aliceInfo bob True setupDesynchronizedRatchet tmp alice withTestChat tmp "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" @@ -2317,20 +2315,25 @@ testGroupSyncRatchetCodeReset tmp = -- connection not verified bob ##> "/i #team alice" - aliceInfo bob - bob <## "connection not verified, use /code command to see security code" + aliceInfo bob False alice #> "#team hello again" bob <# "#team alice> hello again" bob #> "#team received!" alice <# "#team bob> received!" where - aliceInfo :: HasCallStack => TestCC -> IO () - aliceInfo bob = do + aliceInfo :: HasCallStack => TestCC -> Bool -> IO () + aliceInfo bob verified = do bob <## "group ID: 1" bob <## "member ID: 1" bob <## "receiving messages via: localhost" bob <## "sending messages via: localhost" + bob <## connVerified + bob <## currentChatVRangeInfo + where + connVerified + | verified = "connection verified" + | otherwise = "connection not verified, use /code command to see security code" testSetGroupMessageReactions :: HasCallStack => FilePath -> IO () testSetGroupMessageReactions = diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 98c840388..c51202340 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -214,6 +214,7 @@ testProfileLink = cc <## ("contact address: " <> cLink) cc <## "you've shared main profile with this contact" cc <## "connection not verified, use /code command to see security code" + cc <## currentChatVRangeInfo checkAliceNoProfileLink cc = do cc ##> "/info alice" cc <## "contact ID: 2" @@ -221,6 +222,7 @@ testProfileLink = cc <##. "sending messages via" cc <## "you've shared main profile with this contact" cc <## "connection not verified, use /code command to see security code" + cc <## currentChatVRangeInfo testUserContactLinkAutoAccept :: HasCallStack => FilePath -> IO () testUserContactLinkAutoAccept = diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 4c7ca8d0a..b525bf333 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -18,11 +18,13 @@ import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), InlineFilesConfig (..), defaultInlineFilesConfig) +import Simplex.Chat.Protocol import Simplex.Chat.Store.Profiles (getUserContactProfiles) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Store.SQLite (withTransaction) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Version import System.Directory (doesFileExist) import System.Environment (lookupEnv) import System.FilePath (()) @@ -356,7 +358,7 @@ dropTime_ msg = case splitAt 6 msg of _ -> Nothing dropStrPrefix :: HasCallStack => String -> String -> String -dropStrPrefix pfx s = +dropStrPrefix pfx s = let (p, rest) = splitAt (length pfx) s in if p == pfx then rest else error $ "no prefix " <> pfx <> " in string : " <> s @@ -523,3 +525,10 @@ startFileTransferWithDest' cc1 cc2 fileName fileSize fileDest_ = do concurrently_ (cc2 <## ("started receiving file 1 (" <> fileName <> ") from " <> name1)) (cc1 <## ("started sending file 1 (" <> fileName <> ") to " <> name2)) + +currentChatVRangeInfo :: String +currentChatVRangeInfo = + "chat protocol version range: " <> vRangeStr supportedChatVRange + +vRangeStr :: VersionRange -> String +vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxVer <> ")" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 6f7e0b8cf..98c592fa7 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -76,10 +76,10 @@ s ##==## msg = do s ==## msg (==#) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation -s ==# msg = s ==## ChatMessage Nothing msg +s ==# msg = s ==## ChatMessage chatInitialVRange Nothing msg (#==) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation -s #== msg = s ##== ChatMessage Nothing msg +s #== msg = s ##== ChatMessage chatInitialVRange Nothing msg (#==#) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation s #==# msg = do @@ -101,59 +101,66 @@ testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", descri decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.msg.new simple text" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)) it "x.msg.new simple text - timed message TTL" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"ttl\":3600}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"ttl\":3600}}" #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) it "x.msg.new simple text - live message" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"live\":true}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"live\":true}}" #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) it "x.msg.new simple link" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" #==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing)) it "x.msg.new simple image" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCImage "" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) it "x.msg.new simple image with text" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"here's an image\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"here's an image\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCImage "here's an image" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) - it "x.msg.new chat message " $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) + it "x.msg.new chat message" $ + "{\"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-2\",\"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" $ - "{\"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\"}}}}" + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" ##==## ChatMessage + chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCQuote quotedMsg (extMsgContent (MCText "hello to you too") Nothing))) it "x.msg.new quote - timed message TTL" $ - "{\"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\"}},\"ttl\":3600}}" + "{\"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\"}},\"ttl\":3600}}" ##==## ChatMessage + chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing (Just 3600) Nothing))) it "x.msg.new quote - live message" $ - "{\"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\"}},\"live\":true}}" + "{\"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\"}},\"live\":true}}" ##==## ChatMessage + chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing Nothing (Just True)))) it "x.msg.new forward" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing)) + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing)) it "x.msg.new forward - timed message TTL" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) it "x.msg.new forward - live message" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) it "x.msg.new simple text with file" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.new simple file with file" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"file\"},\"file\":{\"fileSize\":12345,\"fileName\":\"file.txt\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"file\"},\"file\":{\"fileSize\":12345,\"fileName\":\"file.txt\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCFile "") (Just FileInvitation {fileName = "file.txt", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.new quote with file" $ - "{\"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\"}},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"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\"}},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" ##==## ChatMessage + chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") ( XMsgNew ( MCQuote @@ -165,101 +172,101 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ) ) it "x.msg.new forward with file" $ - "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.update" $ - "{\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") Nothing Nothing it "x.msg.del" $ - "{\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing it "x.msg.deleted" $ - "{\"event\":\"x.msg.deleted\",\"params\":{}}" + "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/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\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/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\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ - "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing} it "x.file.acpt" $ - "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/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\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/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\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ - "{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") Nothing "photo.jpg" it "x.file.cancel" $ - "{\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XFileCancel (SharedMsgId "\1\2\3\4") it "x.info" $ - "{\"event\":\"x.info\",\"params\":{\"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.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XInfo testProfile it "x.info with empty full name" $ - "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing, contactLink = Nothing, preferences = testChatPreferences} it "x.contact with xContactId" $ - "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") it "x.contact without XContactId" $ - "{\"event\":\"x.contact\",\"params\":{\"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.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile Nothing it "x.contact with content null" $ - "{\"event\":\"x.contact\",\"params\":{\"content\":null,\"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.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.contact with content (ignored)" $ - "{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/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\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/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\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing} it "x.grp.inv with group link id" $ - "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/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\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/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\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"} it "x.grp.acpt without incognito profile" $ - "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") it "x.grp.mem.new" $ - "{\"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\"}}}}}}" + "{\"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, profile = testProfile} it "x.grp.mem.intro" $ - "{\"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\"}}}}}}" + "{\"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, profile = testProfile} it "x.grp.mem.inv" $ - "{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/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\":\"https://simplex.chat/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\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/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\":\"https://simplex.chat/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\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq} it "x.grp.mem.fwd" $ - "{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/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\":\"https://simplex.chat/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\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/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\":\"https://simplex.chat/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, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq} it "x.grp.mem.info" $ - "{\"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\"}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile it "x.grp.mem.con" $ - "{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemCon (MemberId "\1\2\3\4") it "x.grp.mem.con.all" $ - "{\"event\":\"x.grp.mem.con.all\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.con.all\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemConAll (MemberId "\1\2\3\4") it "x.grp.mem.del" $ - "{\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemDel (MemberId "\1\2\3\4") it "x.grp.leave" $ - "{\"event\":\"x.grp.leave\",\"params\":{}}" + "{\"v\":\"1\",\"event\":\"x.grp.leave\",\"params\":{}}" ==# XGrpLeave it "x.grp.del" $ - "{\"event\":\"x.grp.del\",\"params\":{}}" + "{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.info.probe" $ - "{\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbe (Probe "\1\2\3\4") it "x.info.probe.check" $ - "{\"event\":\"x.info.probe.check\",\"params\":{\"probeHash\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.info.probe.check\",\"params\":{\"probeHash\":\"AQIDBA==\"}}" #==# XInfoProbeCheck (ProbeHash "\1\2\3\4") it "x.info.probe.ok" $ - "{\"event\":\"x.info.probe.ok\",\"params\":{\"probe\":\"AQIDBA==\"}}" + "{\"v\":\"1\",\"event\":\"x.info.probe.ok\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbeOk (Probe "\1\2\3\4") it "x.ok" $ - "{\"event\":\"x.ok\",\"params\":{}}" + "{\"v\":\"1\",\"event\":\"x.ok\",\"params\":{}}" ==# XOk From 1c90eb0a2ead5b47d7bb4494233ee863131ab19b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:43:07 +0400 Subject: [PATCH 02/41] docs: groups improvements rfc (#2988) --- docs/rfcs/2023-08-28-groups-improvements.md | 112 ++++++++++++++++++ .../2023-08-28-groups-improvements.mmd | 40 +++++++ .../2023-08-28-groups-improvements.svg | 1 + 3 files changed, 153 insertions(+) create mode 100644 docs/rfcs/2023-08-28-groups-improvements.md create mode 100644 docs/rfcs/diagrams/2023-08-28-groups-improvements.mmd create mode 100644 docs/rfcs/diagrams/2023-08-28-groups-improvements.svg diff --git a/docs/rfcs/2023-08-28-groups-improvements.md b/docs/rfcs/2023-08-28-groups-improvements.md new file mode 100644 index 000000000..7a653fcb2 --- /dev/null +++ b/docs/rfcs/2023-08-28-groups-improvements.md @@ -0,0 +1,112 @@ +# Groups improvements + +See also: +- [Group contacts management](./2022-10-19-group-contacts-management.md). +- [Create groups without establishing direct connections](./2023-08-10-groups-wt-contacts.md). + +## Problem + +Establishing connections in groups is unstable and uses a lot of traffic. There are several areas for improvement that that could help optimize it: + +- Joining group member prematurely creates direct and group connections for each member. + + Some members may never come online, and that traffic would be completely wasted. + + Instead of creating direct connections, we could allow to send direct messages inside group, and optionally have a separate protocol for automating establishing direct connection with member via them. + +- Host sends N introduction messages (XGrpMemIntro) to joining member. Instead they could be batched. + +## Possible solutions + +### Improved group handshake protocol + +Below are proposed changes to group handshake protocol to reduce traffic and improve stability. + +Each joining member creates a new temporary per group address for introduced members to connect via. Joining member sends it to host when accepting group invitation. + +``` haskell +XGrpAcptAddress :: MemberId -> ConnReqContact -> ChatMsgEvent 'Json +``` + +Host sends group introductions in batches, batching smaller messages first (introductions of members without profile picture). + +For each received batch of N introductions joining member creates N transient per member identifiers (MemberCodes) and replies to host with batched XGrpMemInv messages including these identifiers. Joining member would then use them to verify contact requests from introduced members. + +How is MemberCode different from MemberId? - MemberId is known to all group members and is constant per member per group. MemberCode would be known only to host and to introduced member (of existing members), so other members wouldn't be able to impersonate one another when requesting connection with joining member. An introduced member can still pass their identifier + joining member address to another member or outside of group, but it is no different to passing currently shared invitation links. + +```haskell +newtype MemberCode = MemberCode {unMemberCode :: ByteString} + +XGrpMemInvCode :: MemberId -> MemberCode -> ChatMsgEvent 'Json + +-- instead of / in addition to batching message could be + +type MemberCodes = Map MemberId MemberCode + +XGrpMemInvCodes :: MemberCodes -> ChatMsgEvent 'Json +``` + +Host includes joining member address and code (unique for each introduced member) into XGrpMemFwd messages instead of invitation links: + +```haskell +XGrpMemFwdCode :: MemberInfo -> ConnReqContact -> MemberCode -> ChatMsgEvent 'Json +``` + +Introduced members send contact requests with a new message XGroupMember / XIntroduced (similar to XInfo or XContact, see `processUserContactRequest`): + +```haskell +XIntroduced :: MemberInfo -> MemberCode -> ChatMsgEvent 'Json +``` + +Joinee verifies profile and code and automatically accepts contact request. They both assign resulting connection to respective group member record, without creating contact. + +After (if) all introduced members have connected, joining member deletes per group address. Possibly it can also be deleted after expiration interval. + +#### Group links + +We can reduce number of steps taken to join group via group link: +- Do not create direct connection and contact with group link host, instead use the connection resulting from contact request as a group connection, and assign it to a group member record. +- Host to not send XGrpInv message, joining member to not wait for it, instead joining member would initiate with XGrpAcptAddress after establishing connection via group link. + +In addition to their profile, host includes MemberId of joining member into confirmation when accepting group link join request, using new message: + +```haskell +XGroupLinkInfo :: Profile -> MemberId -> ChatMsgEvent 'Json +``` + +Joining member initially doesn't know group profile, they create a placeholder group with a new dummy profile (alternatively, we could include at least group display name into group link). After connection is established, host sends XGrpInfo containing group profile to joining member. This can happen in parallel with group handshake started by XGrpAcptAddress. + +Group profile could also be included into XGroupLinkInfo if not for the limitation on size if both host's profile and group profile contain pictures. + +![Adding member to the group](./diagrams/2023-08-28-groups-improvements.svg) + +#### Clients compatibility + +We have a [proposed mechanism](https://github.com/simplex-chat/simplex-chat/pull/2886) for communicating "chat protocol version" between clients. + +Sending and processing new protocol messages would only be supported by updated clients. + +Trying to support both protocols across different members in the same group would require complex logic: + +Host would have to send introduced members versions, joining member would provide both address or invitation links depending on each members' versions, host would forward accordingly. + +Instead we could assign "chat protocol version" per group and share it with members as part of group profile, and make a two-stage release when members would first be able to update and get new processing logic, but have it disabled until next release. + +After group switching to new processing logic old clients wouldn't be able to connect in groups. + +How should existing groups be switched? +- Owner user action? +- Owner client deciding automatically? +- In case group has multiple owners - which owner(s) can / should decide? +- Prohibited until all / part of existing members don't update? How to request members to update? +- Old clients will not be able to process and save group chat version from group profile update. + +### Sending direct messages inside group + +Group messages are sent by broadcasting them to all group member connections. As a replacement for creating additional direct connections in group we can allow to send message directly to members via group member connections. The UX would be to choose whether to send to group or to a specific member via compose view. + +Possible approach is to extend ExtMsgContent with `direct :: Maybe Bool` field, which would only be considered for group messages. + +Chat items should store information of receiving member database ID (for sending member) and of message being direct (for receiving member). Perhaps it could be a single field `direct_member_id`, which would be the same as `group_member_id` for received messages. + +TODO - consider whether `connection_id` or `group_id` or both should be assigned in `messages` table. diff --git a/docs/rfcs/diagrams/2023-08-28-groups-improvements.mmd b/docs/rfcs/diagrams/2023-08-28-groups-improvements.mmd new file mode 100644 index 000000000..591c30445 --- /dev/null +++ b/docs/rfcs/diagrams/2023-08-28-groups-improvements.mmd @@ -0,0 +1,40 @@ +sequenceDiagram + participant M as N existing
members + participant A as Alice + participant B as Bob + + note over A, B: 1. send and accept group invitation /
join via group link + alt host invites contact + A ->> B: x.grp.inv
invite Bob to group
(via contact connection) + else user joins via group link + B ->> A: request to join group via link + A ->> B: auto-accept
x.group.link.info with host's profile
and joining member MemberId
establish group member connection + A ->> B: x.grp.info
group profile + end + + note right of B: when joining via group link
Bob doesn't wait for x.grp.info
and initiates group handshake
with x.grp.acpt.address
after establishing connection + + note over B: create per group address + B ->> A: x.grp.acpt.address
accept invitation
and send address to connect
(via member connection) + B ->> A: establish group member connection + + note over M, B: 2. introduce new member Bob to all existing members + A ->> M: x.grp.mem.new
"announce" Bob
to existing members
(via member connections) + + loop batched + A ->> B: x.grp.mem.intro * N
"introduce" members
(via member connection) + note over B: create N MemberCodes + B ->> A: x.grp.mem.inv.code
unique MemberCodes
for all members
(via member connection) + end + + A ->> M: x.grp.mem.fwd.code
forward address
and unique MemberCodes
to all members
(via member connections) + + note over M, B: 3. establish group member connection + M ->> B: request group member connection
x.introduced with MemberCode + B ->> M: verify MemberCode, auto-accept + + note over M, B: no contact deduplication + + opt all introduced members connected / expiration + note over B: delete per group address + end diff --git a/docs/rfcs/diagrams/2023-08-28-groups-improvements.svg b/docs/rfcs/diagrams/2023-08-28-groups-improvements.svg new file mode 100644 index 000000000..34529d532 --- /dev/null +++ b/docs/rfcs/diagrams/2023-08-28-groups-improvements.svg @@ -0,0 +1 @@ +BobAliceN existingmembersBobAliceN existingmembers1. send and accept group invitation /join via group linkalt[host invites contact][user joins via group link]when joining via group linkBob doesn't wait for x.grp.infoand initiates group handshakewith x.grp.acpt.addressafter establishing connectioncreate per group address2. introduce new member Bob to all existing memberscreate N MemberCodesloop[batched]3. establish group member connectionno contact deduplicationdelete per group addressopt[all introducedmembersconnected /expiration]x.grp.invinvite Bob to group(via contact connection)request to join group via linkauto-acceptx.group.link.info with host's profileand joining member MemberIdestablish group member connectionx.grp.infogroup profilex.grp.acpt.addressaccept invitationand send address to connect(via member connection)establish group member connectionx.grp.mem.new"announce" Bobto existing members(via member connections)x.grp.mem.intro * N"introduce" members(via member connection)x.grp.mem.inv.codeunique MemberCodesfor all members(via member connection)x.grp.mem.fwd.codeforward addressand unique MemberCodesto all members(via member connections)request group member connectionx.introduced with MemberCodeverify MemberCode, auto-accept \ No newline at end of file From 0b214acf97931994d85f1efbe442ad984330a9da Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:43:27 +0100 Subject: [PATCH 03/41] core: support encrypted local files (#2989) * core: support encrypted local files * add migration * update agent api, chat api * fix query, exported functions to read/write files * update simplexmq * remove formatting changes * test, fix file size * reduce diff Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * fail when receiving SMP files with local encryption * update simplexmq * remove style changes --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 7 +- src/Simplex/Chat.hs | 125 ++++++++++-------- src/Simplex/Chat/Bot.hs | 2 +- src/Simplex/Chat/Controller.hs | 30 ++++- src/Simplex/Chat/Messages.hs | 12 +- src/Simplex/Chat/Messages/CIContent.hs | 2 +- .../Migrations/M20230827_file_encryption.hs | 20 +++ src/Simplex/Chat/Migrations/chat_schema.sql | 4 +- src/Simplex/Chat/Mobile.hs | 8 +- src/Simplex/Chat/Mobile/File.hs | 83 ++++++++++++ src/Simplex/Chat/Mobile/Shared.hs | 19 +++ src/Simplex/Chat/Mobile/WebRTC.hs | 17 +-- src/Simplex/Chat/Store/Files.hs | 60 ++++++--- src/Simplex/Chat/Store/Messages.hs | 37 +++--- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Types.hs | 18 ++- src/Simplex/Chat/View.hs | 42 +++--- stack.yaml | 2 +- tests/ChatClient.hs | 2 +- tests/ChatTests/Files.hs | 39 +++++- 22 files changed, 390 insertions(+), 147 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20230827_file_encryption.hs create mode 100644 src/Simplex/Chat/Mobile/File.hs create mode 100644 src/Simplex/Chat/Mobile/Shared.hs diff --git a/cabal.project b/cabal.project index 1be6c7365..5338f229a 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: 4c0b8a31d20870a23e120e243359901d8240f922 + tag: 5dc3d739b206edc2b4706ba0eef64ad4492e68e6 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index f3ad3061a..4598c9c04 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."4c0b8a31d20870a23e120e243359901d8240f922" = "0lrgfm8di0x4rmidqp7k2fw29yaal6467nmb85lwk95yz602906z"; + "https://github.com/simplex-chat/simplexmq.git"."5dc3d739b206edc2b4706ba0eef64ad4492e68e6" = "0nzp0ijmw7ppmzjj72hf0b8jkyg8lwwy92hc1649xk3hnrj48wfz"; "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"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f26e3432c..dbff34350 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.0. +-- This file has been generated from package.yaml by hpack version 0.35.2. -- -- see: https://github.com/sol/hpack @@ -10,7 +10,7 @@ category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat maintainer: chat@simplex.chat -copyright: 2020-23 simplex.chat +copyright: 2020-22 simplex.chat license: AGPL-3 license-file: LICENSE build-type: Simple @@ -108,7 +108,10 @@ library Simplex.Chat.Migrations.M20230705_delivery_receipts Simplex.Chat.Migrations.M20230721_group_snd_item_statuses Simplex.Chat.Migrations.M20230814_indexes + Simplex.Chat.Migrations.M20230827_file_encryption Simplex.Chat.Mobile + Simplex.Chat.Mobile.File + Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.WebRTC Simplex.Chat.Options Simplex.Chat.ProfileGenerator diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 1fabed45b..dd7e90425 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE BangPatterns #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -86,6 +85,8 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Client (defaultNetworkConfig) import 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 import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) @@ -562,8 +563,9 @@ processChatCommand = \case SendFileSMP fileInline -> smpSndFileTransfer file fileSize fileInline SendFileXFTP -> xftpSndFileTransfer user file fileSize 1 $ CGContact ct where - smpSndFileTransfer :: FilePath -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - smpSndFileTransfer file fileSize fileInline = do + smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) + smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled + smpSndFileTransfer (CryptoFile file Nothing) fileSize fileInline = do (agentConnId_, fileConnReq) <- if isJust fileInline then pure (Nothing, Nothing) @@ -576,7 +578,8 @@ processChatCommand = \case fileStatus <- case fileInline of Just IFMSent -> createSndDirectInlineFT db ct ft $> CIFSSndTransfer 0 1 _ -> pure CIFSSndStored - let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus, fileProtocol = FPSMP} + let fileSource = Just $ CF.plain file + ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol = FPSMP} pure (fileInvitation, ciFile, ft) prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> m (MsgContainer, Maybe (CIQuote 'CTDirect)) prepareMsg fInv_ timed_ = case quotedItemId_ of @@ -625,15 +628,17 @@ processChatCommand = \case SendFileSMP fileInline -> smpSndFileTransfer file fileSize fileInline SendFileXFTP -> xftpSndFileTransfer user file fileSize n $ CGGroup g where - smpSndFileTransfer :: FilePath -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - smpSndFileTransfer file fileSize fileInline = do + smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) + smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled + smpSndFileTransfer (CryptoFile file Nothing) fileSize fileInline = do let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline, fileDescr = Nothing} fileStatus = if fileInline == Just IFMSent then CIFSSndTransfer 0 1 else CIFSSndStored chSize <- asks $ fileChunkSize . config withStore' $ \db -> do ft@FileTransferMeta {fileId} <- createSndGroupFileTransfer db userId gInfo file fileInvitation chSize - let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus, fileProtocol = FPSMP} + let fileSource = Just $ CF.plain file + ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol = FPSMP} pure (fileInvitation, ciFile, ft) sendGroupFileInline :: [GroupMember] -> SharedMsgId -> FileTransferMeta -> m () sendGroupFileInline ms sharedMsgId ft@FileTransferMeta {fileInline} = @@ -688,17 +693,19 @@ processChatCommand = \case qText = msgContentText qmc qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_ qTextOrFile = if T.null qText then qFileName else qText - xftpSndFileTransfer :: User -> FilePath -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - xftpSndFileTransfer user file fileSize n contactOrGroup = do - let fileName = takeFileName file + xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) + xftpSndFileTransfer user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup = do + let fileName = takeFileName filePath fileDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} fInv = xftpFileInvitation fileName fileSize fileDescr - fsFilePath <- toFSFilePath file - aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) fsFilePath (roundedFDCount n) + fsFilePath <- toFSFilePath filePath + let srcFile = CryptoFile fsFilePath cfArgs + aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n) -- TODO CRSndFileStart event for XFTP chSize <- asks $ fileChunkSize . config ft@FileTransferMeta {fileId} <- withStore' $ \db -> createSndFileTransferXFTP db user contactOrGroup file fInv (AgentSndFileId aFileId) chSize - let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored, fileProtocol = FPXFTP} + let fileSource = Just $ CryptoFile filePath cfArgs + ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP} case contactOrGroup of CGContact Contact {activeConn} -> withStore' $ \db -> createSndFTDescrXFTP db user Nothing activeConn ft fileDescr CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) @@ -1613,26 +1620,40 @@ 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 f) Nothing (MCFile "") + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCFile "") SendImage chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName filePath <- toFSFilePath f - unless (any ((`isSuffixOf` map toLower f)) imageExtensions) $ throwChatError CEFileImageType {filePath} + unless (any (`isSuffixOf` map toLower f) 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 f) Nothing (MCImage "" fixedImagePreview) + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain 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" - ReceiveFile fileId rcvInline_ filePath_ -> withUser $ \_ -> + ReceiveFile fileId encrypted rcvInline_ filePath_ -> withUser $ \_ -> withChatLock "receiveFile" . procCmd $ do - (user, ft) <- withStore $ \db -> getRcvFileTransferById db fileId - receiveFile' user ft rcvInline_ filePath_ - SetFileToReceive fileId -> withUser $ \_ -> do + (user, ft) <- withStore (`getRcvFileTransferById` fileId) + ft' <- if encrypted then encryptLocalFile ft else pure ft + receiveFile' user ft' rcvInline_ filePath_ + where + encryptLocalFile ft@RcvFileTransfer {xftpRcvFile} = case xftpRcvFile of + Nothing -> throwChatError $ CEFileInternal "locally encrypted files can't be received via SMP" + Just f -> do + cfArgs <- liftIO $ CF.randomArgs + withStore' $ \db -> setFileCryptoArgs db fileId cfArgs + pure ft {xftpRcvFile = Just ((f :: XFTPRcvFile) {cryptoArgs = Just cfArgs})} + SetFileToReceive fileId encrypted -> withUser $ \_ -> do withChatLock "setFileToReceive" . procCmd $ do - withStore' (`setRcvFileToReceive` fileId) + cfArgs <- if encrypted then fileCryptoArgs else pure Nothing + withStore' $ \db -> setRcvFileToReceive db fileId cfArgs ok_ + where + fileCryptoArgs = do + (_, RcvFileTransfer {xftpRcvFile = f}) <- withStore (`getRcvFileTransferById` fileId) + unless (isJust f) $ throwChatError $ CEFileInternal "locally encrypted files can't be received via SMP" + liftIO $ Just <$> CF.randomArgs CancelFile fileId -> withUser $ \user@User {userId} -> withChatLock "cancelFile" . procCmd $ withStore (\db -> getFileTransfer db user fileId) >>= \case @@ -1829,18 +1850,19 @@ processChatCommand = \case contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft - checkSndFile :: MsgContent -> FilePath -> Integer -> m (Integer, SendFileMode) - checkSndFile mc f n = do + checkSndFile :: MsgContent -> CryptoFile -> Integer -> m (Integer, SendFileMode) + checkSndFile mc (CryptoFile f cfArgs) n = do fsFilePath <- toFSFilePath f unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f ChatConfig {fileChunkSize, inlineFiles} <- asks config xftpCfg <- readTVarIO =<< asks userXFTPFileConfig - fileSize <- getFileSize fsFilePath + fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs when (fromInteger fileSize > maxFileSize) $ throwChatError $ CEFileSize f - let chunks = - ((- fileSize) `div` fileChunkSize) + let chunks = -((-fileSize) `div` fileChunkSize) fileInline = inlineFileMode mc inlineFiles chunks n fileMode = case xftpCfg of Just cfg + | isJust cfArgs -> SendFileXFTP | fileInline == Just IFMSent || fileSize < minFileSize cfg || n <= 0 -> SendFileSMP fileInline | otherwise -> SendFileXFTP _ -> SendFileSMP fileInline @@ -1867,17 +1889,17 @@ processChatCommand = \case summary <- foldM (processAndCount user' logLevel) (UserProfileUpdateSummary 0 0 0 []) contacts pure $ CRUserProfileUpdated user' (fromLocalProfile p) p' summary where - processAndCount user' ll (!s@UserProfileUpdateSummary {notChanged, updateSuccesses, updateFailures, changedContacts = cts}) ct = do + processAndCount user' ll s@UserProfileUpdateSummary {notChanged, updateSuccesses, updateFailures, changedContacts = cts} ct = do let mergedProfile = userProfileToSend user Nothing $ Just ct ct' = updateMergedPreferences user' ct mergedProfile' = userProfileToSend user' Nothing $ Just ct' if mergedProfile' == mergedProfile then pure s {notChanged = notChanged + 1} - else - let cts' = if mergedPreferences ct == mergedPreferences ct' then cts else ct' : cts + else + let cts' = if mergedPreferences ct == mergedPreferences ct' then cts else ct' : cts in (notifyContact mergedProfile' ct' $> s {updateSuccesses = updateSuccesses + 1, changedContacts = cts'}) `catchChatError` \e -> when (ll <= CLLInfo) (toView $ CRChatError (Just user) e) $> s {updateFailures = updateFailures + 1, changedContacts = cts'} - where + where notifyContact mergedProfile' ct' = do void $ sendDirectContactMessage ct' (XInfo mergedProfile') when (directOrUsed ct') $ createSndFeatureItems user' ct ct' @@ -2214,7 +2236,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI filePath <- getRcvFilePath fileId filePath_ fName True withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnJoined filePath -- XFTP - (Just _xftpRcvFile, _) -> do + (Just XFTPRcvFile {cryptoArgs}, _) -> do filePath <- getRcvFilePath fileId filePath_ fName False (ci, rfd) <- withStoreCtx (Just "acceptFileReceive, xftpAcceptRcvFT ...") $ \db -> do -- marking file as accepted and reading description in the same transaction @@ -2222,7 +2244,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI ci <- xftpAcceptRcvFT db user fileId filePath rfd <- getRcvFileDescrByFileId db fileId pure (ci, rfd) - receiveViaCompleteFD user fileId rfd + receiveViaCompleteFD user fileId rfd cryptoArgs pure ci -- group & direct file protocol _ -> do @@ -2265,11 +2287,11 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI || (rcvInline_ == Just True && fileSize <= fileChunkSize * offerChunks) ) -receiveViaCompleteFD :: ChatMonad m => User -> FileTransferId -> RcvFileDescr -> m () -receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} = +receiveViaCompleteFD :: ChatMonad m => User -> FileTransferId -> RcvFileDescr -> Maybe CryptoFileArgs -> m () +receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} cfArgs = when fileDescrComplete $ do rd <- parseFileDescription fileDescrText - aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd + aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd cfArgs startReceivingFile user fileId withStoreCtx' (Just "receiveViaCompleteFD, updateRcvFileAgentId") $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) @@ -2535,7 +2557,7 @@ cleanupManager = do `catchChatError` (toView . CRChatError (Just user)) cleanupMessages = do ts <- liftIO getCurrentTime - let cutoffTs = addUTCTime (- (30 * nominalDay)) ts + let cutoffTs = addUTCTime (-(30 * nominalDay)) ts withStoreCtx' (Just "cleanupManager, deleteOldMessages") (`deleteOldMessages` cutoffTs) startProximateTimedItemThread :: ChatMonad m => User -> (ChatRef, ChatItemId) -> UTCTime -> m () @@ -3567,14 +3589,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processFDMessage fileId fileDescr = do ft <- withStore $ \db -> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do - (rfd, RcvFileTransfer {fileStatus}) <- withStore $ \db -> do + (rfd, RcvFileTransfer {fileStatus, xftpRcvFile}) <- withStore $ \db -> do rfd <- appendRcvFD db userId fileId fileDescr -- reading second time in the same transaction as appending description -- to prevent race condition with accept ft' <- getRcvFileTransfer db user fileId pure (rfd, ft') - case fileStatus of - RFSAccepted _ -> receiveViaCompleteFD user fileId rfd + case (fileStatus, xftpRcvFile) of + (RFSAccepted _, Just XFTPRcvFile {cryptoArgs}) -> receiveViaCompleteFD user fileId rfd cryptoArgs _ -> pure () cancelMessageFile :: Contact -> SharedMsgId -> MsgMeta -> m () @@ -3600,7 +3622,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> startRcvInlineFT db user ft fPath inline pure (Just fPath, CIFSRcvAccepted) _ -> pure (Nothing, CIFSRcvInvitation) - pure (ft, CIFile {fileId, fileName, fileSize, filePath, fileStatus, fileProtocol}) + let fileSource = CF.plain <$> filePath + pure (ft, CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do @@ -3817,7 +3840,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do inline <- receiveInlineMode fInv Nothing fileChunkSize 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, filePath = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} + ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) whenContactNtfs user ct $ do @@ -3831,7 +3854,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do 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, filePath = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} + 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 groupMsgToView gInfo m ci msgMeta let g = groupName' gInfo @@ -4737,10 +4760,9 @@ deleteGroupCI user gInfo ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUse pure $ CRChatItemDeleted user (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi byUser timed deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () -deleteCIFile user file = - forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do - let fileInfo = CIFileInfo {fileId, fileStatus = Just $ AFS msgDirection fileStatus, filePath} - fileAgentConnIds <- deleteFile' user fileInfo True +deleteCIFile user file_ = + forM_ file_ $ \file -> do + fileAgentConnIds <- deleteFile' user (mkCIFileInfo file) True deleteAgentConnectionsAsync user fileAgentConnIds markDirectCIDeleted :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> MessageId -> Bool -> UTCTime -> m ChatResponse @@ -4764,10 +4786,9 @@ markGroupCIDeleted user gInfo@GroupInfo {groupId} ci@(CChatItem _ ChatItem {file gItem (CChatItem msgDir ci') = AChatItem SCTGroup msgDir (GroupChat gInfo) ci' cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () -cancelCIFile user file = - forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do - let fileInfo = CIFileInfo {fileId, fileStatus = Just $ AFS msgDirection fileStatus, filePath} - fileAgentConnIds <- cancelFile' user fileInfo True +cancelCIFile user file_ = + forM_ file_ $ \file -> do + fileAgentConnIds <- cancelFile' user (mkCIFileInfo file) True deleteAgentConnectionsAsync user fileAgentConnIds createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> m (CommandId, ConnId) @@ -5000,7 +5021,7 @@ withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a withAgent action = asks smpAgent >>= runExceptT . action - >>= liftEither . first (\e -> ChatErrorAgent e Nothing) + >>= liftEither . first (`ChatErrorAgent` Nothing) withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a withStore' action = withStore $ liftIO . action @@ -5235,8 +5256,8 @@ chatCommandP = ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal), ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal), ("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath), - ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)), - "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal), + ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)), + "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False)), ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal), ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), "/simplex" *> (ConnectSimplex <$> incognitoP), diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 234963b44..df9c66cee 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -66,7 +66,7 @@ sendComposedMessage cc = sendComposedMessage' cc . contactId' sendComposedMessage' :: ChatController -> ContactId -> Maybe ChatItemId -> MsgContent -> IO () sendComposedMessage' cc ctId quotedItemId msgContent = do - let cm = ComposedMessage {filePath = Nothing, quotedItemId, msgContent} + let cm = ComposedMessage {fileSource = Nothing, quotedItemId, msgContent} sendChatCmd cc (APISendMessage (ChatRef CTDirect ctId) False Nothing cm) >>= \case CRNewChatItem {} -> printLog cc CLLInfo $ "sent message to contact ID " <> show ctId r -> putStrLn $ "unexpected send message response: " <> show r diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 615e472f2..f766edf0c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -22,8 +22,9 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import Crypto.Random (ChaChaDRG) -import Data.Aeson (FromJSON (..), ToJSON (..)) +import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?)) import qualified Data.Aeson as J +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 @@ -54,16 +55,18 @@ import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) 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 qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..)) +import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import 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, UserProtocol, XFTPServerWithAuth) import Simplex.Messaging.TMap (TMap) -import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors) +import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors, (<$$>)) import System.IO (Handle) import System.Mem.Weak (Weak) import UnliftIO.STM @@ -387,8 +390,8 @@ data ChatCommand | ForwardFile ChatName FileTransferId | ForwardImage ChatName FileTransferId | SendFileDescription ChatName FilePath - | ReceiveFile {fileId :: FileTransferId, fileInline :: Maybe Bool, filePath :: Maybe FilePath} - | SetFileToReceive FileTransferId + | ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath} + | SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Bool} | CancelFile FileTransferId | FileStatus FileTransferId | ShowProfile -- UserId (not used in UI) @@ -723,11 +726,24 @@ data UserProfileUpdateSummary = UserProfileUpdateSummary instance ToJSON UserProfileUpdateSummary where toEncoding = J.genericToEncoding J.defaultOptions data ComposedMessage = ComposedMessage - { filePath :: Maybe FilePath, + { fileSource :: Maybe CryptoFile, quotedItemId :: Maybe ChatItemId, msgContent :: MsgContent } - deriving (Show, Generic, FromJSON) + deriving (Show, Generic) + +-- This instance is needed for backward compatibility, can be removed in v6.0 +instance FromJSON ComposedMessage where + parseJSON (J.Object v) = do + fileSource <- + (v .:? "fileSource") >>= \case + Nothing -> CF.plain <$$> (v .:? "filePath") + f -> pure f + quotedItemId <- v .:? "quotedItemId" + msgContent <- v .: "msgContent" + pure ComposedMessage {fileSource, quotedItemId, msgContent} + parseJSON invalid = + JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) instance ToJSON ComposedMessage where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 33b604184..45e5f9ff7 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -37,6 +37,8 @@ import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptStatus (..)) +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.Protocol (MsgBody) @@ -459,7 +461,7 @@ data CIFile (d :: MsgDirection) = CIFile { fileId :: Int64, fileName :: String, fileSize :: Integer, - filePath :: Maybe FilePath, -- local file path + fileSource :: Maybe CryptoFile, -- local file path with optional key and nonce fileStatus :: CIFileStatus d, fileProtocol :: FileProtocol } @@ -631,6 +633,14 @@ data CIFileInfo = CIFileInfo } deriving (Show) +mkCIFileInfo :: MsgDirectionI d => CIFile d -> CIFileInfo +mkCIFileInfo CIFile {fileId, fileStatus, fileSource} = + CIFileInfo + { fileId, + fileStatus = Just $ AFS msgDirection fileStatus, + filePath = CF.filePath <$> fileSource + } + data CIStatus (d :: MsgDirection) where CISSndNew :: CIStatus 'MDSnd CISSndSent :: SndCIStatusProgress -> CIStatus 'MDSnd diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 725cf74cf..95c490a90 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -50,7 +50,7 @@ instance FromField AMsgDirection where fromField = fromIntField_ $ fmap fromMsgD instance ToField MsgDirection where toField = toField . msgDirectionInt -fromIntField_ :: (Typeable a) => (Int64 -> Maybe a) -> Field -> Ok a +fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> Field -> Ok a fromIntField_ fromInt = \case f@(Field (SQLInteger i) _) -> case fromInt i of diff --git a/src/Simplex/Chat/Migrations/M20230827_file_encryption.hs b/src/Simplex/Chat/Migrations/M20230827_file_encryption.hs new file mode 100644 index 000000000..2e659cac8 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230827_file_encryption.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230827_file_encryption where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230827_file_encryption :: Query +m20230827_file_encryption = + [sql| +ALTER TABLE files ADD COLUMN file_crypto_key BLOB; +ALTER TABLE files ADD COLUMN file_crypto_nonce BLOB; +|] + +down_m20230827_file_encryption :: Query +down_m20230827_file_encryption = + [sql| +ALTER TABLE files DROP COLUMN file_crypto_key; +ALTER TABLE files DROP COLUMN file_crypto_nonce; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 76b7ba4a1..1badae2e1 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -204,7 +204,9 @@ CREATE TABLE files( agent_snd_file_id BLOB NULL, private_snd_file_descr TEXT NULL, agent_snd_file_deleted INTEGER DEFAULT 0 CHECK(agent_snd_file_deleted NOT NULL), - protocol TEXT NOT NULL DEFAULT 'smp' + protocol TEXT NOT NULL DEFAULT 'smp', + file_crypto_key BLOB, + file_crypto_nonce BLOB ); CREATE TABLE snd_files( file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 6e62fbce0..0f4b262b7 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -35,6 +35,8 @@ import GHC.Generics (Generic) import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList) +import Simplex.Chat.Mobile.File +import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Options import Simplex.Chat.Store @@ -69,6 +71,10 @@ foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: CString -> Ptr Wo foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString +foreign export ccall "chat_write_file" cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString + +foreign export ccall "chat_read_file" cChatReadFile :: CString -> CString -> CString -> IO (Ptr Word8) + -- | check / migrate database and initialize chat controller on success cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInit fp key conf ctrl = do @@ -151,8 +157,6 @@ defaultMobileConfig = logLevel = CLLError } -type CJSONString = CString - getActiveUser_ :: SQLiteStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs new file mode 100644 index 000000000..4f73e191a --- /dev/null +++ b/src/Simplex/Chat/Mobile/File.hs @@ -0,0 +1,83 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TupleSections #-} + +module Simplex.Chat.Mobile.File + ( cChatWriteFile, + cChatReadFile, + WriteFileResult (..), + ReadFileResult (..), + chatWriteFile, + chatReadFile, + ) +where + +import Control.Monad.Except +import Data.Aeson (ToJSON) +import qualified Data.Aeson as J +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import qualified Data.ByteString.Lazy as LB +import qualified Data.ByteString.Lazy.Char8 as LB' +import Data.Int (Int64) +import Data.Word (Word8) +import Foreign.C +import Foreign.Marshal.Alloc (mallocBytes) +import Foreign.Ptr +import GHC.Generics (Generic) +import Simplex.Chat.Mobile.Shared +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) + +data WriteFileResult + = WFResult {cryptoArgs :: CryptoFileArgs} + | WFError {writeError :: String} + deriving (Generic) + +instance ToJSON WriteFileResult where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "WF" + +cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString +cChatWriteFile cPath ptr len = do + path <- peekCAString cPath + s <- getByteString ptr len + r <- chatWriteFile path s + newCAString $ LB'.unpack $ J.encode r + +chatWriteFile :: FilePath -> ByteString -> IO WriteFileResult +chatWriteFile path s = do + cfArgs <- CF.randomArgs + let file = CryptoFile path $ Just cfArgs + either (WFError . show) (\_ -> WFResult cfArgs) + <$> runExceptT (CF.writeFile file $ LB.fromStrict s) + +data ReadFileResult + = RFResult {fileSize :: Int64} + | 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 + path <- peekCAString cPath + key <- B.packCString cKey + nonce <- B.packCString cNonce + (r, s) <- chatReadFile path key nonce + let r' = LB.toStrict (J.encode r) <> "\NUL" + ptr <- mallocBytes $ B.length r' + B.length s + putByteString ptr r' + unless (B.null s) $ putByteString (ptr `plusPtr` B.length r') s + pure ptr + +chatReadFile :: FilePath -> ByteString -> ByteString -> IO (ReadFileResult, ByteString) +chatReadFile path keyStr nonceStr = do + either ((,"") . RFError) (\s -> (RFResult $ LB.length s, LB.toStrict s)) <$> runExceptT readFile_ + where + readFile_ :: ExceptT String IO LB.ByteString + readFile_ = do + key <- liftEither $ strDecode keyStr + nonce <- liftEither $ strDecode nonceStr + let file = CryptoFile path $ Just $ CFArgs key nonce + withExceptT show $ CF.readFile file diff --git a/src/Simplex/Chat/Mobile/Shared.hs b/src/Simplex/Chat/Mobile/Shared.hs new file mode 100644 index 000000000..a73a25fb6 --- /dev/null +++ b/src/Simplex/Chat/Mobile/Shared.hs @@ -0,0 +1,19 @@ +module Simplex.Chat.Mobile.Shared where + +import qualified Data.ByteString as B +import Data.ByteString.Internal (ByteString (PS), memcpy) +import Foreign.C (CInt, CString) +import Foreign (Ptr, Word8, newForeignPtr_, plusPtr) +import Foreign.ForeignPtr.Unsafe + +type CJSONString = CString + +getByteString :: Ptr Word8 -> CInt -> IO ByteString +getByteString ptr len = do + fp <- newForeignPtr_ ptr + pure $ PS fp 0 $ fromIntegral len + +putByteString :: Ptr Word8 -> ByteString -> IO () +putByteString ptr bs@(PS fp offset _) = do + let p = unsafeForeignPtrToPtr fp `plusPtr` offset + memcpy ptr p $ B.length bs diff --git a/src/Simplex/Chat/Mobile/WebRTC.hs b/src/Simplex/Chat/Mobile/WebRTC.hs index e05c9d609..3fd5f018e 100644 --- a/src/Simplex/Chat/Mobile/WebRTC.hs +++ b/src/Simplex/Chat/Mobile/WebRTC.hs @@ -12,16 +12,15 @@ import Control.Monad.Except import qualified Crypto.Cipher.Types as AES import Data.Bifunctor (bimap) import qualified Data.ByteArray as BA +import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Base64.URL as U -import Data.ByteString.Internal (ByteString (PS), memcpy) import Data.Either (fromLeft) import Data.Word (Word8) import Foreign.C (CInt, CString, newCAString) -import Foreign.ForeignPtr (newForeignPtr_) -import Foreign.ForeignPtr.Unsafe (unsafeForeignPtrToPtr) -import Foreign.Ptr (Ptr, plusPtr) +import Foreign.Ptr (Ptr) import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Mobile.Shared cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString cChatEncryptMedia = cTransformMedia chatEncryptMedia @@ -32,16 +31,10 @@ cChatDecryptMedia = cTransformMedia chatDecryptMedia cTransformMedia :: (ByteString -> ByteString -> ExceptT String IO ByteString) -> CString -> Ptr Word8 -> CInt -> IO CString cTransformMedia f cKey cFrame cFrameLen = do key <- B.packCString cKey - frame <- getFrame + frame <- getByteString cFrame cFrameLen runExceptT (f key frame >>= liftIO . putFrame) >>= newCAString . fromLeft "" where - getFrame = do - fp <- newForeignPtr_ cFrame - pure $ PS fp 0 $ fromIntegral cFrameLen - putFrame bs@(PS fp offset _) = do - let len = B.length bs - p = unsafeForeignPtrToPtr fp `plusPtr` offset - when (len <= fromIntegral cFrameLen) $ memcpy cFrame p len + putFrame s = when (B.length s < fromIntegral cFrameLen) $ putByteString cFrame s {-# INLINE cTransformMedia #-} chatEncryptMedia :: ByteString -> ByteString -> ExceptT String IO ByteString diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 249dfedc3..998035292 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -56,6 +56,7 @@ module Simplex.Chat.Store.Files startRcvInlineFT, xftpAcceptRcvFT, setRcvFileToReceive, + setFileCryptoArgs, getRcvFilesToReceive, setRcvFTAgentDeleted, updateRcvFileStatus, @@ -84,18 +85,21 @@ import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) import Data.Type.Equality import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared -import Simplex.Chat.Messages -import Simplex.Chat.Messages.CIContent -import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (week) import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import qualified Simplex.Messaging.Crypto.File as CF getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] getLiveSndFileTransfers db User {userId} = do @@ -257,14 +261,14 @@ getSndFTViaMsgDelivery db User {userId} Connection {connId, agentConnId} agentMs (\n -> SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, groupMemberId, recipientDisplayName = n, connId, agentConnId}) <$> (contactName_ <|> memberName_) -createSndFileTransferXFTP :: DB.Connection -> User -> ContactOrGroup -> FilePath -> FileInvitation -> AgentSndFileId -> Integer -> IO FileTransferMeta -createSndFileTransferXFTP db User {userId} contactOrGroup filePath FileInvitation {fileName, fileSize} agentSndFileId chunkSize = do +createSndFileTransferXFTP :: DB.Connection -> User -> ContactOrGroup -> CryptoFile -> FileInvitation -> AgentSndFileId -> Integer -> IO FileTransferMeta +createSndFileTransferXFTP db User {userId} contactOrGroup (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId chunkSize = do currentTs <- getCurrentTime - let xftpSndFile = Just XFTPSndFile {agentSndFileId, privateSndFileDescr = Nothing, agentSndFileDeleted = False} + let xftpSndFile = Just XFTPSndFile {agentSndFileId, privateSndFileDescr = Nothing, agentSndFileDeleted = False, cryptoArgs} DB.execute db - "INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_size, chunk_size, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" - (contactAndGroupIds contactOrGroup :. (userId, fileName, filePath, fileSize, chunkSize, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs)) + "INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + (contactAndGroupIds contactOrGroup :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs)) fileId <- insertedRowId db pure FileTransferMeta {fileId, xftpSndFile, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False} @@ -479,7 +483,8 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr let rfdId = (fileDescrId :: RcvFileDescr -> Int64) <$> rfd_ - xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId = Nothing, agentRcvFileDeleted = False}) <$> rfd_ + -- cryptoArgs = Nothing here, the decision to encrypt is made when receiving it + xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId = Nothing, agentRcvFileDeleted = False, cryptoArgs = Nothing}) <$> rfd_ fileProtocol = if isJust rfd_ then FPXFTP else FPSMP fileId <- liftIO $ do DB.execute @@ -499,7 +504,8 @@ createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localD currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr let rfdId = (fileDescrId :: RcvFileDescr -> Int64) <$> rfd_ - xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId = Nothing, agentRcvFileDeleted = False}) <$> rfd_ + -- cryptoArgs = Nothing here, the decision to encrypt is made when receiving it + xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId = Nothing, agentRcvFileDeleted = False, cryptoArgs = Nothing}) <$> rfd_ fileProtocol = if isJust rfd_ then FPXFTP else FPSMP fileId <- liftIO $ do DB.execute @@ -600,7 +606,7 @@ getRcvFileTransfer db User {userId} fileId = do [sql| SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, - f.file_path, r.file_inline, r.rcv_file_inline, r.agent_rcv_file_id, r.agent_rcv_file_deleted, c.connection_id, c.agent_conn_id + f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, r.agent_rcv_file_id, r.agent_rcv_file_deleted, c.connection_id, c.agent_conn_id FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN connections c ON r.file_id = c.rcv_file_id @@ -614,9 +620,9 @@ getRcvFileTransfer db User {userId} fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, Bool) :. (Maybe Int64, Maybe AgentConnId) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, Bool) :. (Maybe Int64, Maybe AgentConnId) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted) :. (connId_, agentConnId_)) = + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted) :. (connId_, agentConnId_)) = case contactName_ <|> memberName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> do @@ -629,7 +635,8 @@ getRcvFileTransfer db User {userId} fileId = do where ft senderDisplayName fileStatus = let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} - xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted}) <$> rfd_ + cryptoArgs = CFArgs <$> fileKey <*> fileNonce + xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, cryptoArgs}) <$> rfd_ in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId} rfi = maybe (throwError $ SERcvFileInvalid fileId) pure =<< rfi_ rfi_ = case (filePath_, connId_, agentConnId_) of @@ -683,13 +690,21 @@ acceptRcvFT_ db User {userId} fileId filePath rcvFileInline currentTs = do "UPDATE rcv_files SET rcv_file_inline = ?, file_status = ?, updated_at = ? WHERE file_id = ?" (rcvFileInline, FSAccepted, currentTs, fileId) -setRcvFileToReceive :: DB.Connection -> FileTransferId -> IO () -setRcvFileToReceive db fileId = do +setRcvFileToReceive :: DB.Connection -> FileTransferId -> Maybe CryptoFileArgs -> IO () +setRcvFileToReceive db fileId cfArgs_ = do currentTs <- getCurrentTime + DB.execute db "UPDATE rcv_files SET to_receive = 1, updated_at = ? WHERE file_id = ?" (currentTs, fileId) + forM_ cfArgs_ $ \cfArgs -> setFileCryptoArgs_ db fileId cfArgs currentTs + +setFileCryptoArgs :: DB.Connection -> FileTransferId -> CryptoFileArgs -> IO () +setFileCryptoArgs db fileId cfArgs = setFileCryptoArgs_ db fileId cfArgs =<< getCurrentTime + +setFileCryptoArgs_ :: DB.Connection -> FileTransferId -> CryptoFileArgs -> UTCTime -> IO () +setFileCryptoArgs_ db fileId (CFArgs key nonce) currentTs = DB.execute db - "UPDATE rcv_files SET to_receive = 1, updated_at = ? WHERE file_id = ?" - (currentTs, fileId) + "UPDATE files SET file_crypto_key = ?, file_crypto_nonce = ?, updated_at = ? WHERE file_id = ?" + (key, nonce, currentTs, fileId) getRcvFilesToReceive :: DB.Connection -> User -> IO [RcvFileTransfer] getRcvFilesToReceive db user@User {userId} = do @@ -842,15 +857,16 @@ getFileTransferMeta db User {userId} fileId = DB.query db [sql| - SELECT file_name, file_size, chunk_size, file_path, file_inline, agent_snd_file_id, agent_snd_file_deleted, private_snd_file_descr, cancelled + SELECT file_name, file_size, chunk_size, file_path, file_crypto_key, file_crypto_nonce, file_inline, agent_snd_file_id, agent_snd_file_deleted, private_snd_file_descr, cancelled FROM files WHERE user_id = ? AND file_id = ? |] (userId, fileId) where - fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool) -> FileTransferMeta - fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_) = - let xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted}) <$> aSndFileId_ + fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool) -> FileTransferMeta + fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_) = + let cryptoArgs = CFArgs <$> fileKey <*> fileNonce + xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted, cryptoArgs}) <$> aSndFileId_ in FileTransferMeta {fileId, xftpSndFile, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_} getContactFileInfo :: DB.Connection -> User -> Contact -> IO [CIFileInfo] diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 7bc2eaf4d..fb4e84c21 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -13,7 +13,6 @@ module Simplex.Chat.Store.Messages ( getContactConnIds_, getDirectChatReactions_, - toDirectChatItem, -- * Message and chat item functions deleteContactCIs, @@ -122,6 +121,8 @@ import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM @@ -483,7 +484,7 @@ getDirectChatPreviews_ db user@User {userId} = do -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM contacts ct @@ -548,7 +549,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + 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, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, @@ -669,7 +670,7 @@ getDirectChatItemsLast db User {userId} contactId count search = ExceptT $ do -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -698,7 +699,7 @@ getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId coun -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -728,7 +729,7 @@ getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId co -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -950,7 +951,7 @@ type ChatStatsRow = (Int, ChatItemId, Bool) toChatStats :: ChatStatsRow -> ChatStats toChatStats (unreadCount, minUnreadItemId, unreadChat) = ChatStats {unreadCount, minUnreadItemId, unreadChat} -type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe ACIFileStatus, Maybe FileProtocol) +type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol) type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe Bool) @@ -971,7 +972,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_, fileProtocol_)) :. quoteRow) = +toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -988,7 +989,10 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) maybeCIFile fileStatus = case (fileId_, fileName_, fileSize_, fileProtocol_) of - (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> Just CIFile {fileId, fileName, fileSize, filePath, fileStatus, fileProtocol} + (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> + let cfArgs = CFArgs <$> fileKey <*> fileNonce + fileSource = (`CryptoFile` cfArgs) <$> filePath + in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol} _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = @@ -1021,7 +1025,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 :. 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, fileStatus_, fileProtocol_)) :. 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_)) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where member_ = toMaybeGroupMember userContactId memberRow_ @@ -1041,7 +1045,10 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) maybeCIFile fileStatus = case (fileId_, fileName_, fileSize_, fileProtocol_) of - (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> Just CIFile {fileId, fileName, fileSize, filePath, fileStatus, fileProtocol} + (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> + let cfArgs = CFArgs <$> fileKey <*> fileNonce + fileSource = (`CryptoFile` cfArgs) <$> filePath + in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol} _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup cItem d chatDir ciStatus content file = @@ -1141,7 +1148,7 @@ updateDirectChatItemStatus db user@User {userId} contactId itemId itemStatus = d correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateDirectChatItem :: forall d. (MsgDirectionI d) => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> Bool -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> Bool -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) updateDirectChatItem db user contactId itemId newContent live msgId_ = do ci <- liftEither . correctDir =<< getDirectChatItem db user contactId itemId liftIO $ updateDirectChatItem' db user contactId ci newContent live msgId_ @@ -1149,7 +1156,7 @@ updateDirectChatItem db user contactId itemId newContent live msgId_ = do correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateDirectChatItem' :: forall d. (MsgDirectionI d) => DB.Connection -> User -> Int64 -> ChatItem 'CTDirect d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTDirect d) +updateDirectChatItem' :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTDirect d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTDirect d) updateDirectChatItem' db User {userId} contactId ci newContent live msgId_ = do currentTs <- liftIO getCurrentTime let ci' = updatedChatItem ci newContent live currentTs @@ -1294,7 +1301,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -1469,7 +1476,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- ChatItem i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, f.protocol, + 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, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 6da0d1cdc..e8ac86c1e 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -76,6 +76,7 @@ import Simplex.Chat.Migrations.M20230621_chat_item_moderations import Simplex.Chat.Migrations.M20230705_delivery_receipts import Simplex.Chat.Migrations.M20230721_group_snd_item_statuses import Simplex.Chat.Migrations.M20230814_indexes +import Simplex.Chat.Migrations.M20230827_file_encryption import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -151,7 +152,8 @@ schemaMigrations = ("20230621_chat_item_moderations", m20230621_chat_item_moderations, Just down_m20230621_chat_item_moderations), ("20230705_delivery_receipts", m20230705_delivery_receipts, Just down_m20230705_delivery_receipts), ("20230721_group_snd_item_statuses", m20230721_group_snd_item_statuses, Just down_m20230721_group_snd_item_statuses), - ("20230814_indexes", m20230814_indexes, Just down_m20230814_indexes) + ("20230814_indexes", m20230814_indexes, Just down_m20230814_indexes), + ("20230827_file_encryption", m20230827_file_encryption, Just down_m20230827_file_encryption) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index ac71ce612..d427db6c6 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -42,6 +42,7 @@ 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) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) @@ -345,11 +346,12 @@ data ChatSettings = ChatSettings instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOptions defaultChatSettings :: ChatSettings -defaultChatSettings = ChatSettings - { enableNtfs = True, - sendRcpts = Nothing, - favorite = False - } +defaultChatSettings = + ChatSettings + { enableNtfs = True, + sendRcpts = Nothing, + favorite = False + } pattern DisableNtfs :: ChatSettings pattern DisableNtfs <- ChatSettings {enableNtfs = False} @@ -953,7 +955,8 @@ instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.default data XFTPRcvFile = XFTPRcvFile { rcvFileDescription :: RcvFileDescr, agentRcvFileId :: Maybe AgentRcvFileId, - agentRcvFileDeleted :: Bool + agentRcvFileDeleted :: Bool, + cryptoArgs :: Maybe CryptoFileArgs } deriving (Eq, Show, Generic) @@ -1108,7 +1111,8 @@ instance ToJSON FileTransferMeta where toEncoding = J.genericToEncoding J.defaul data XFTPSndFile = XFTPSndFile { agentSndFileId :: AgentSndFileId, privateSndFileDescr :: Maybe Text, - agentSndFileDeleted :: Bool + agentSndFileDeleted :: Bool, + cryptoArgs :: Maybe CryptoFileArgs } deriving (Eq, Show, Generic) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 033b1c9f7..172155747 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -50,6 +50,7 @@ import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..)) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) @@ -160,7 +161,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRRcvFileDescrReady _ _ -> [] CRRcvFileDescrNotReady _ _ -> [] CRRcvFileProgressXFTP {} -> [] - CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci + CRRcvFileAccepted u ci -> ttyUser u $ savingFile' testView ci CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts CRRcvFileCancelled u _ ft -> ttyUser u $ receivingFile_ "cancelled" ft @@ -251,7 +252,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = - "count: " <> sShow count + ("count: " <> sShow count) <> (" :: max: " <> sShow timeMax <> " ms") <> (" :: avg: " <> sShow timeAvg <> " ms") <> (" :: " <> plain (T.unwords $ T.lines query)) @@ -274,7 +275,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView <> ("pending subscriptions: " : map sShow pendingSubscriptions) CRConnectionDisabled entity -> viewConnectionEntityDisabled entity CRAgentRcvQueueDeleted acId srv aqId err_ -> - [ "completed deleting rcv queue, agent connection id: " <> sShow acId + [ ("completed deleting rcv queue, agent connection id: " <> sShow acId) <> (", server: " <> sShow srv) <> (", agent queue id: " <> sShow aqId) <> maybe "" (\e -> ", error: " <> sShow e) err_ @@ -327,7 +328,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView Just CIQuote {chatDir = quoteDir, content} -> Just (msgDirectionInt $ quoteMsgDirection quoteDir, msgContentText content) fPath = case file of - Just CIFile {filePath = Just fp} -> Just fp + Just CIFile {fileSource = Just (CryptoFile fp _)} -> Just fp _ -> Nothing testViewItem :: CChatItem c -> Maybe GroupMember -> Text testViewItem (CChatItem _ ci@ChatItem {meta = CIMeta {itemText}}) membership_ = @@ -950,7 +951,8 @@ viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} = viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}} stats incognitoProfile = - ["contact ID: " <> sShow contactId] <> viewConnectionStats stats + ["contact ID: " <> sShow contactId] + <> viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) l]) contactLink <> maybe ["you've shared main profile with this contact"] @@ -1269,8 +1271,8 @@ viewSentBroadcast mc s f ts tz time = prependFirst (highlight' "/feed" <> " (" < | otherwise = "" viewSentFileInvitation :: StyledString -> CIFile d -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] -viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} ts tz = case filePath of - Just fPath -> sentWithTime_ ts tz $ ttySentFile fPath +viewSentFileInvitation to CIFile {fileId, fileSource, fileStatus} ts tz = case fileSource of + Just (CryptoFile fPath _) -> sentWithTime_ ts tz $ ttySentFile fPath _ -> const [] where ttySentFile fPath = ["/f " <> to <> ttyFilePath fPath] <> cancelSending @@ -1338,14 +1340,20 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 -savingFile' :: AChatItem -> [StyledString] -savingFile' (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, filePath = Just filePath}, chatDir = CIDirectRcv}) = - ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] -savingFile' (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, filePath = Just filePath}, chatDir = CIGroupRcv GroupMember {localDisplayName = m}}) = - ["saving file " <> sShow fileId <> " from " <> ttyContact m <> " to " <> plain filePath] -savingFile' (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, filePath = Just filePath}}) = - ["saving file " <> sShow fileId <> " to " <> plain filePath] -savingFile' _ = ["saving file"] -- shouldn't happen +savingFile' :: Bool -> AChatItem -> [StyledString] +savingFile' testView (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath cfArgs_)}, chatDir}) = + let from = case (chat, chatDir) of + (DirectChat Contact {localDisplayName = c}, CIDirectRcv) -> " from " <> ttyContact c + (_, CIGroupRcv GroupMember {localDisplayName = m}) -> " from " <> ttyContact m + _ -> "" + in ["saving file " <> sShow fileId <> from <> " to " <> plain filePath] <> cfArgsStr + where + cfArgsStr = case cfArgs_ of + Just cfArgs@(CFArgs key nonce) + | testView -> [plain $ LB.unpack $ J.encode cfArgs] + | otherwise -> [plain $ "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce] + _ -> [] +savingFile' _ _ = ["saving file"] -- shouldn't happen receivingFile_' :: StyledString -> AChatItem -> [StyledString] receivingFile_' status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = @@ -1397,7 +1405,7 @@ viewFileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileI RFSCancelled Nothing -> "cancelled" viewFileTransferStatusXFTP :: AChatItem -> [StyledString] -viewFileTransferStatusXFTP (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName, fileSize, fileStatus, filePath}}) = +viewFileTransferStatusXFTP (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName, fileSize, fileStatus, fileSource}}) = case fileStatus of CIFSSndStored -> ["sending " <> fstr <> " just started"] CIFSSndTransfer progress total -> ["sending " <> fstr <> " in progress " <> fileProgressXFTP progress total fileSize] @@ -1407,7 +1415,7 @@ viewFileTransferStatusXFTP (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId CIFSRcvInvitation -> ["receiving " <> fstr <> " not accepted yet, use " <> highlight ("/fr " <> show fileId) <> " to receive file"] CIFSRcvAccepted -> ["receiving " <> fstr <> " just started"] CIFSRcvTransfer progress total -> ["receiving " <> fstr <> " progress " <> fileProgressXFTP progress total fileSize] - CIFSRcvComplete -> ["receiving " <> fstr <> " complete" <> maybe "" (\fp -> ", path: " <> plain fp) filePath] + CIFSRcvComplete -> ["receiving " <> fstr <> " complete" <> maybe "" (\(CryptoFile fp _) -> ", path: " <> plain fp) fileSource] CIFSRcvCancelled -> ["receiving " <> fstr <> " cancelled"] CIFSRcvError -> ["receiving " <> fstr <> " error"] CIFSInvalid text -> [fstr <> " invalid status: " <> plain text] diff --git a/stack.yaml b/stack.yaml index d86d8fe57..b4a7fcf06 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: 4c0b8a31d20870a23e120e243359901d8240f922 + commit: 5dc3d739b206edc2b4706ba0eef64ad4492e68e6 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index e612f3d09..a0622556a 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -249,7 +249,7 @@ getTermLine cc = Just s -> do -- remove condition to always echo virtual terminal when (printOutput cc) $ do - -- when True $ do + -- when True $ do name <- userName cc putStrLn $ name <> ": " <> s pure s diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 4343b547c..a0b0779ff 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -8,14 +8,19 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) +import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Simplex.Chat (roundedFDCount) import Simplex.Chat.Controller (ChatConfig (..), InlineFilesConfig (..), XFTPFileConfig (..), defaultInlineFilesConfig) +import Simplex.Chat.Mobile.File import Simplex.Chat.Options (ChatOpts (..)) import Simplex.FileTransfer.Client.Main (xftpClientCLI) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..)) +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util (unlessM) -import System.Directory (copyFile, doesFileExist) +import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize) import System.Environment (withArgs) import System.IO.Silently (capture_) import Test.Hspec @@ -59,6 +64,7 @@ chatFileTests = do describe "file transfer over XFTP" $ do it "round file description count" $ const testXFTPRoundFDCount it "send and receive file" testXFTPFileTransfer + it "send and receive locally encrypted files" testXFTPFileTransferEncrypted it "send and receive file, accepting after upload" testXFTPAcceptAfterUpload it "send and receive file in group" testXFTPGroupFileTransfer it "delete uploaded file" testXFTPDeleteUploadedFile @@ -1013,6 +1019,35 @@ testXFTPFileTransfer = where cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} +testXFTPFileTransferEncrypted :: HasCallStack => FilePath -> IO () +testXFTPFileTransferEncrypted = + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + src <- B.readFile "./tests/fixtures/test.pdf" + srcLen <- getFileSize "./tests/fixtures/test.pdf" + let srcPath = "./tests/tmp/alice/test.pdf" + createDirectoryIfMissing True "./tests/tmp/alice/" + createDirectoryIfMissing True "./tests/tmp/bob/" + WFResult cfArgs <- chatWriteFile srcPath src + let fileJSON = LB.unpack $ J.encode $ CryptoFile srcPath $ Just cfArgs + withXFTPServer $ do + connectUsers alice bob + alice ##> ("/_send @2 json {\"msgContent\":{\"type\":\"file\", \"text\":\"\"}, \"fileSource\": " <> fileJSON <> "}") + alice <# "/f @bob ./tests/tmp/alice/test.pdf" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 encrypt=on ./tests/tmp/bob/" + bob <## "saving file 1 from alice to ./tests/tmp/bob/test.pdf" + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob + alice <## "completed uploading file 1 (test.pdf) for bob" + bob <## "started receiving file 1 (test.pdf) from alice" + bob <## "completed receiving file 1 (test.pdf) from alice" + (RFResult destLen, dest) <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce) + fromIntegral destLen `shouldBe` srcLen + dest `shouldBe` src + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + testXFTPAcceptAfterUpload :: HasCallStack => FilePath -> IO () testXFTPAcceptAfterUpload = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -1447,7 +1482,7 @@ startFileTransfer alice bob = startFileTransfer' alice bob "test.jpg" "136.5 KiB / 139737 bytes" startFileTransfer' :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () -startFileTransfer' cc1 cc2 fileName fileSize = startFileTransferWithDest' cc1 cc2 fileName fileSize $ Just "./tests/tmp" +startFileTransfer' cc1 cc2 fName fSize = startFileTransferWithDest' cc1 cc2 fName fSize $ Just "./tests/tmp" checkPartialTransfer :: HasCallStack => String -> IO () checkPartialTransfer fileName = do From 461142b875b5e2efe64cdb04ab4541fba86061b6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 1 Sep 2023 22:27:03 +0100 Subject: [PATCH 04/41] core: update simplexmq (import stateTVar) --- 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 5338f229a..c465ffa26 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: 5dc3d739b206edc2b4706ba0eef64ad4492e68e6 + tag: 17a1a911d885eae8b939fd6deaa797f3dc72289c source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 4598c9c04..7d90d9e01 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."5dc3d739b206edc2b4706ba0eef64ad4492e68e6" = "0nzp0ijmw7ppmzjj72hf0b8jkyg8lwwy92hc1649xk3hnrj48wfz"; + "https://github.com/simplex-chat/simplexmq.git"."17a1a911d885eae8b939fd6deaa797f3dc72289c" = "03530jwrdn3skmyzhvaml01j41lynl0m2ym0wvppj19sckg7a6mh"; "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"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/stack.yaml b/stack.yaml index b4a7fcf06..c3f99b6d9 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: 5dc3d739b206edc2b4706ba0eef64ad4492e68e6 + commit: 17a1a911d885eae8b939fd6deaa797f3dc72289c - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher From af02a9244241efb588b2d262c8b258c3046ac948 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 2 Sep 2023 23:34:00 +0100 Subject: [PATCH 05/41] core: fix WebRTC encryption, test (#3005) --- src/Simplex/Chat/Mobile/WebRTC.hs | 2 +- tests/MobileTests.hs | 50 ++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Mobile/WebRTC.hs b/src/Simplex/Chat/Mobile/WebRTC.hs index 3fd5f018e..19ba2b751 100644 --- a/src/Simplex/Chat/Mobile/WebRTC.hs +++ b/src/Simplex/Chat/Mobile/WebRTC.hs @@ -34,7 +34,7 @@ cTransformMedia f cKey cFrame cFrameLen = do frame <- getByteString cFrame cFrameLen runExceptT (f key frame >>= liftIO . putFrame) >>= newCAString . fromLeft "" where - putFrame s = when (B.length s < fromIntegral cFrameLen) $ putByteString cFrame s + putFrame s = when (B.length s <= fromIntegral cFrameLen) $ putByteString cFrame s {-# INLINE cTransformMedia #-} chatEncryptMedia :: ByteString -> ByteString -> ExceptT String IO ByteString diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 31c080354..e11496ef4 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -1,22 +1,37 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE ScopedTypeVariables #-} module MobileTests where import ChatTests.Utils import Control.Monad.Except +import Crypto.Random (getRandomBytes) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import qualified Data.ByteString.Char8 as BS +import Data.Word (Word8) +import Foreign.C +import Foreign.Marshal.Alloc (mallocBytes) +import Foreign.Ptr import Simplex.Chat.Mobile +import Simplex.Chat.Mobile.Shared +import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String import System.FilePath (()) import Test.Hspec -mobileTests :: SpecWith FilePath +mobileTests :: HasCallStack => SpecWith FilePath mobileTests = do describe "mobile API" $ do it "start new chat without user" testChatApiNoUser it "start new chat with existing user" testChatApi + fit "should encrypt/decrypt WebRTC frames" testMediaApi + fit "should encrypt/decrypt WebRTC frames via C API" testMediaCApi noActiveUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -113,3 +128,36 @@ testChatApi tmp = do chatRecvMsgWait cc 10000 `shouldReturn` "" chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown + +testMediaApi :: HasCallStack => FilePath -> IO () +testMediaApi _ = do + key :: ByteString <- getRandomBytes 32 + frame <- getRandomBytes 100 + let keyStr = strEncode key + reserved = B.replicate (C.authTagSize + C.gcmIVSize) 0 + frame' = frame <> reserved + Right encrypted <- runExceptT $ chatEncryptMedia keyStr frame' + encrypted `shouldNotBe` frame' + B.length encrypted `shouldBe` B.length frame' + runExceptT (chatDecryptMedia keyStr encrypted) `shouldReturn` Right frame' + +testMediaCApi :: HasCallStack => FilePath -> IO () +testMediaCApi _ = do + key :: ByteString <- getRandomBytes 32 + frame <- getRandomBytes 100 + let keyStr = strEncode key + reserved = B.replicate (C.authTagSize + C.gcmIVSize) 0 + frame' = frame <> reserved + encrypted <- test cChatEncryptMedia keyStr frame' + encrypted `shouldNotBe` frame' + test cChatDecryptMedia keyStr encrypted `shouldReturn` frame' + where + test :: HasCallStack => (CString -> Ptr Word8 -> CInt -> IO CString) -> ByteString -> ByteString -> IO ByteString + test f keyStr frame = do + let len = B.length frame + cLen = fromIntegral len + ptr <- mallocBytes len + putByteString ptr frame + cKeyStr <- newCString $ BS.unpack keyStr + (f cKeyStr ptr cLen >>= peekCString) `shouldReturn` "" + getByteString ptr cLen From aa676924659a93869f7140ae525fe15507fbdf2e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 3 Sep 2023 08:32:21 +0100 Subject: [PATCH 06/41] rfc: local file encryption (#2342) --- docs/rfcs/2023-04-28-files-encryption.md | 64 ++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/rfcs/2023-04-28-files-encryption.md diff --git a/docs/rfcs/2023-04-28-files-encryption.md b/docs/rfcs/2023-04-28-files-encryption.md new file mode 100644 index 000000000..30c6a4d2d --- /dev/null +++ b/docs/rfcs/2023-04-28-files-encryption.md @@ -0,0 +1,64 @@ +# Encrpting local app files + +## Problem + +Currently, the files are stored in the file storage unencrypted, unlike the database. + +There are multiple operations in the app that access files: + +1. Sending files via SMP - chat core reads the files chunk by chunk and sends them. The file can be encrypted once sent and the "encrypted" flag added. + +2. Sending files via XFTP - simplexmq encrypts the file first and then sends it. Currently, we are deleting the file from chat, once its uploaded, there is no reason to keep unencrypted file (from XFTP point of view) once its encrypted. + +3. Viewing images in the mobile apps. + +4. Playing voice files in the mobile apps. + +5. Playing videos and showing video previews in mobile apps. + +6. Saving files from the app storage to the device. + +## Possible solutions + +### System encryption + +A possible approach is to use platform-specific encryption mechanism. The problem with that approach is inconsistency between platforms, and that the files in chat archive will probably be unencrypted in this case. + +### App encryption + +Files will be encrypted once received, using storage key, and the core would expose C apis to mobile apps: + +1. Read the file with decryption - this can be used for image previews, for example, as a replacement for OS file reading. + +2. Copy the file with decryption to some permanent destination - this can be used for saving files to the device. + +3. Copy the file into a temporary location with decryption - this can be used for playing voice/video files. The app would remove the files once no longer used, and this temporary location can be cleaned on each app start, to clean up the files that the app failed to remove. Alternative to that would be to have both encrypted and decrypted copies available for the file, with paths stored in the database, and clean up process removed decrypted copies once no longer used - there should be some flags to indicate when decrypted copy can be deleted. + +For specific use cases: + +1. Viewing images in the mobile apps. + - iOS: we use `UIImage(contentsOfFile path: String)`. We could use `init?(data: Data)` instead, and decrypt the file in memory before passing it to the image view. Images are small enough for this approach to be ok, and in any case the image is read to memory as a whole. + - Android: we use `BitmapFactory.decodeFileDescriptor` (?). We could use ... + +2. Playing voice files in the mobile apps. + - iOS: we use `AVAudioPlayer.init(contentsOf: URL)` to play the file. We could either decrypt the file before playing it, or, given that voice files are small (even if we increase allowed duration, they are still likely to be under 1mb), we could use `init(data: Data)` to avoid creating decrypted file. + - Android: we use `MediaPlayer.setDataSource(filePath)`. We could use ... + +3. Showing video previews. + - iOS: ... + - Android: ... + + Possibly, we will need to store preview as a separate file, to avoid decrypting the whole video just to show preview. + +4. Playing video files. + - iOS: we use `AVPlayer(url: URL)`, the file will have to be decrypted for playback. + - Android: ... + +5. Saving files from the app storage to the device. + The file will have to be decrypted, passed to the system, and then decrypted copy deleted once no longer needed. + +### Which key to use for encryption + +1. Derive file encryption key from database storage key. The downside for this approach is managing key changes - they will be slow. Also, if file encryption is made optional, and in any case, for the existing users all files are not encrypted yet, we will need somehow to track which files are encrypted. + +2. Random per-file encryption key stored in the database. Given that the database is already encrypted, it can be a better approach, and it makes it easier to manage file encryption/decryption. File keys will not be sent to the client application, but they will be accessible via the database queries of course. From 4793173465c69878a327ab76300c2f76c9346ebf Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 3 Sep 2023 22:25:19 +0100 Subject: [PATCH 07/41] core: update return type of read/write file C api, tests (#3010) --- src/Simplex/Chat/Mobile/File.hs | 8 +++--- tests/MobileTests.hs | 46 ++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 4f73e191a..25e694365 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -19,7 +19,6 @@ import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Lazy as LB import qualified Data.ByteString.Lazy.Char8 as LB' -import Data.Int (Int64) import Data.Word (Word8) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) @@ -53,7 +52,7 @@ chatWriteFile path s = do <$> runExceptT (CF.writeFile file $ LB.fromStrict s) data ReadFileResult - = RFResult {fileSize :: Int64} + = RFResult {fileSize :: Int} | RFError {readError :: String} deriving (Generic) @@ -65,7 +64,7 @@ cChatReadFile cPath cKey cNonce = do key <- B.packCString cKey nonce <- B.packCString cNonce (r, s) <- chatReadFile path key nonce - let r' = LB.toStrict (J.encode r) <> "\NUL" + let r' = LB.toStrict $ J.encode r <> "\NUL" ptr <- mallocBytes $ B.length r' + B.length s putByteString ptr r' unless (B.null s) $ putByteString (ptr `plusPtr` B.length r') s @@ -73,8 +72,9 @@ cChatReadFile cPath cKey cNonce = do chatReadFile :: FilePath -> ByteString -> ByteString -> IO (ReadFileResult, ByteString) chatReadFile path keyStr nonceStr = do - either ((,"") . RFError) (\s -> (RFResult $ LB.length s, LB.toStrict s)) <$> runExceptT readFile_ + either ((,"") . RFError) result <$> runExceptT readFile_ where + result s = let s' = LB.toStrict s in (RFResult $ B.length s', s') readFile_ :: ExceptT String IO LB.ByteString readFile_ = do key <- liftEither $ strDecode keyStr diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index e11496ef4..604a1640e 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -1,19 +1,25 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# OPTIONS_GHC -fno-warn-orphans #-} + module MobileTests where import ChatTests.Utils import Control.Monad.Except import Crypto.Random (getRandomBytes) +import Data.Aeson (FromJSON (..)) +import qualified Data.Aeson as J import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Char8 as BS +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Word (Word8) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr import Simplex.Chat.Mobile +import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Store @@ -21,7 +27,9 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import System.FilePath (()) import Test.Hspec @@ -30,8 +38,9 @@ mobileTests = do describe "mobile API" $ do it "start new chat without user" testChatApiNoUser it "start new chat with existing user" testChatApi - fit "should encrypt/decrypt WebRTC frames" testMediaApi - fit "should encrypt/decrypt WebRTC frames via C API" testMediaCApi + it "should encrypt/decrypt WebRTC frames" testMediaApi + it "should encrypt/decrypt WebRTC frames via C API" testMediaCApi + it "should read/write encrypted files via C API" testFileCApi noActiveUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -158,6 +167,35 @@ testMediaCApi _ = do cLen = fromIntegral len ptr <- mallocBytes len putByteString ptr frame - cKeyStr <- newCString $ BS.unpack keyStr - (f cKeyStr ptr cLen >>= peekCString) `shouldReturn` "" + cKeyStr <- newCAString $ BS.unpack keyStr + (f cKeyStr ptr cLen >>= peekCAString) `shouldReturn` "" getByteString ptr cLen + +instance FromJSON WriteFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "WF" + +instance FromJSON ReadFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RF" + +testFileCApi :: FilePath -> IO () +testFileCApi tmp = do + src <- B.readFile "./tests/fixtures/test.pdf" + cPath <- newCAString $ tmp "test.pdf" + let len = B.length src + cLen = fromIntegral len + ptr <- mallocBytes $ B.length src + putByteString ptr src + r <- peekCAString =<< cChatWriteFile cPath ptr cLen + Just (WFResult (CFArgs key nonce)) <- jDecode r + cKey <- encodedCString key + cNonce <- encodedCString nonce + ptr' <- cChatReadFile cPath cKey cNonce + -- the returned pointer contains NUL-terminated JSON string of ReadFileResult followed by the file contents + r' <- peekCAString $ castPtr ptr' + Just (RFResult sz) <- jDecode r' + contents <- getByteString (ptr' `plusPtr` (length r' + 1)) $ fromIntegral sz + contents `shouldBe` src + sz `shouldBe` len + where + jDecode :: FromJSON a => String -> IO (Maybe a) + jDecode = pure . J.decode . LB.pack + encodedCString :: StrEncoding a => a -> IO CString + encodedCString = newCAString . BS.unpack . strEncode From c7f1af8742e3f5214d651563417fbd4fd6b5cd02 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 4 Sep 2023 20:37:53 +0300 Subject: [PATCH 08/41] android: sharing of files with plain text (#3011) --- .../android/src/main/java/chat/simplex/app/MainActivity.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 55d8202f8..06def4ce1 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 @@ -141,7 +141,12 @@ fun processExternalIntent(intent: Intent?) { when { intent.type == "text/plain" -> { val text = intent.getStringExtra(Intent.EXTRA_TEXT) - if (text != null) { + val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri + if (uri != null) { + // Shared file that contains plain text, like `*.log` file + chatModel.sharedContent.value = SharedContent.File(text ?: "", uri.toURI()) + } else if (text != null) { + // Shared just a text chatModel.sharedContent.value = SharedContent.Text(text) } } From 0ec3e0c18db0a6ddd4471c2023db11c7ae182cd4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 4 Sep 2023 23:19:24 +0100 Subject: [PATCH 09/41] core: add debug info for subscriptions (#3014) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 23 +++++++++++------------ src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/View.hs | 14 ++++++++------ stack.yaml | 2 +- 6 files changed, 23 insertions(+), 22 deletions(-) diff --git a/cabal.project b/cabal.project index c465ffa26..b40b009b3 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: 17a1a911d885eae8b939fd6deaa797f3dc72289c + tag: 980e5c4d1ec15f44290542fd2a5d1c08456f00d1 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7d90d9e01..09ac41e49 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."17a1a911d885eae8b939fd6deaa797f3dc72289c" = "03530jwrdn3skmyzhvaml01j41lynl0m2ym0wvppj19sckg7a6mh"; + "https://github.com/simplex-chat/simplexmq.git"."980e5c4d1ec15f44290542fd2a5d1c08456f00d1" = "1lqciyy215dvmbhykyp80bwipqmxybv39p6jff6vjgd5r34958nh"; "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"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index dd7e90425..79a39780a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -25,7 +25,7 @@ import Crypto.Random (drgNew) import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A -import Data.Bifunctor (bimap, first, second) +import Data.Bifunctor (bimap, first) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -41,7 +41,6 @@ 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 qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -1757,17 +1756,17 @@ processChatCommand = \case ResetAgentStats -> withAgent resetAgentStats >> ok_ GetAgentSubs -> summary <$> withAgent getAgentSubscriptions where - summary SubscriptionsInfo {activeSubscriptions, pendingSubscriptions} = - CRAgentSubs {activeSubs, distinctActiveSubs, pendingSubs, distinctPendingSubs} + summary SubscriptionsInfo {activeSubscriptions, pendingSubscriptions, removedSubscriptions} = + CRAgentSubs + { activeSubs = foldl' countSubs M.empty activeSubscriptions, + pendingSubs = foldl' countSubs M.empty pendingSubscriptions, + removedSubs = foldl' accSubErrors M.empty removedSubscriptions + } where - (activeSubs, distinctActiveSubs) = foldSubs activeSubscriptions - (pendingSubs, distinctPendingSubs) = foldSubs pendingSubscriptions - foldSubs :: [SubInfo] -> (Map Text Int, Map Text Int) - foldSubs = second (M.map S.size) . foldl' acc (M.empty, M.empty) - acc (m, m') SubInfo {server, rcvId} = - ( M.alter (Just . maybe 1 (+ 1)) server m, - M.alter (Just . maybe (S.singleton rcvId) (S.insert rcvId)) server m' - ) + countSubs m SubInfo {server} = M.alter (Just . maybe 1 (+ 1)) server m + accSubErrors m = \case + SubInfo {server, subError = Just e} -> M.alter (Just . maybe [e] (e :)) server m + _ -> m GetAgentSubsDetails -> CRAgentSubsDetails <$> withAgent getAgentSubscriptions where withChatLock name action = asks chatLock >>= \l -> withLock l name action diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index f766edf0c..3a286cf98 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -573,7 +573,7 @@ data ChatResponse | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} | CRAgentStats {agentStats :: [[String]]} - | CRAgentSubs {activeSubs :: Map Text Int, distinctActiveSubs :: Map Text Int, pendingSubs :: Map Text Int, distinctPendingSubs :: Map Text Int} + | CRAgentSubs {activeSubs :: Map Text Int, pendingSubs :: Map Text Int, removedSubs :: Map Text [String]} | CRAgentSubsDetails {agentSubs :: SubscriptionsInfo} | CRConnectionDisabled {connectionEntity :: ConnectionEntity} | CRAgentRcvQueueDeleted {agentConnId :: AgentConnId, server :: SMPServer, agentQueueId :: AgentQueueId, agentError_ :: Maybe AgentErrorType} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 172155747..5a92c8ab4 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -20,6 +20,7 @@ import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L +import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) import Data.Text (Text) @@ -262,17 +263,18 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView plain $ "agent locks: " <> LB.unpack (J.encode agentLocks) ] CRAgentStats stats -> map (plain . intercalate ",") stats - CRAgentSubs {activeSubs, distinctActiveSubs, pendingSubs, distinctPendingSubs} -> - [plain $ "Subscriptions: active = " <> show (sum activeSubs) <> ", distinct active = " <> show (sum distinctActiveSubs) <> ", pending = " <> show (sum pendingSubs) <> ", distinct pending = " <> show (sum distinctPendingSubs)] + CRAgentSubs {activeSubs, pendingSubs, removedSubs} -> + [plain $ "Subscriptions: active = " <> show (sum activeSubs) <> ", pending = " <> show (sum pendingSubs) <> ", removed = " <> show (sum $ M.map length removedSubs)] <> ("active subscriptions:" : listSubs activeSubs) - <> ("distinct active subscriptions:" : listSubs distinctActiveSubs) <> ("pending subscriptions:" : listSubs pendingSubs) - <> ("distinct pending subscriptions:" : listSubs distinctPendingSubs) + <> ("removed subscriptions:" : listSubs removedSubs) where - listSubs = map (\(srv, count) -> plain $ srv <> ": " <> tshow count) . M.assocs - CRAgentSubsDetails SubscriptionsInfo {activeSubscriptions, pendingSubscriptions} -> + listSubs :: Show a => Map Text a -> [StyledString] + listSubs = map (\(srv, info) -> plain $ srv <> ": " <> tshow info) . M.assocs + CRAgentSubsDetails SubscriptionsInfo {activeSubscriptions, pendingSubscriptions, removedSubscriptions} -> ("active subscriptions:" : map sShow activeSubscriptions) <> ("pending subscriptions: " : map sShow pendingSubscriptions) + <> ("removed subscriptions: " : map sShow removedSubscriptions) CRConnectionDisabled entity -> viewConnectionEntityDisabled entity CRAgentRcvQueueDeleted acId srv aqId err_ -> [ ("completed deleting rcv queue, agent connection id: " <> sShow acId) diff --git a/stack.yaml b/stack.yaml index c3f99b6d9..c949cbb16 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: 17a1a911d885eae8b939fd6deaa797f3dc72289c + commit: 980e5c4d1ec15f44290542fd2a5d1c08456f00d1 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher From 8aed56819945ac976a780bcb006eaddade69437d Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 5 Sep 2023 01:21:29 +0300 Subject: [PATCH 10/41] multiplatform: layout fix on link creation page and self destruct option (#3012) --- .../simplex/common/views/usersettings/SettingsView.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 baffc02f6..c7d57353c 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 @@ -393,9 +393,13 @@ fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: ( val padding = with(LocalDensity.current) { 6.sp.toDp() } Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground) Spacer(Modifier.width(DEFAULT_PADDING)) - } - Row(Modifier.widthIn(max = (windowWidth() - DEFAULT_PADDING * 2) / 2)) { - content() + Row(Modifier.widthIn(max = (windowWidth() - DEFAULT_PADDING * 2) / 2)) { + content() + } + } else { + Row { + content() + } } } } From aff71c58d7c0436158a3547309ba36cc990712e2 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 5 Sep 2023 13:45:09 +0300 Subject: [PATCH 11/41] desktop: setup passphrase during onboarding (#2987) * desktop: setup passphrase during onboarding * updated logic * removed unused code * button and starting chat action * better * removed debug code * fallback * focusing and moving focus on desktop text fields * different logic * removed unused variable * divided logic in two functions * enabled keyboard enter * rollback when db deleted by hand on desktop * update texts, font size * stopping chat before other actions --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../main/java/chat/simplex/app/SimplexApp.kt | 6 +- .../DatabaseEncryptionView.android.kt | 106 ++++++++ .../kotlin/chat/simplex/common/App.kt | 13 +- .../chat/simplex/common/model/ChatModel.kt | 1 - .../chat/simplex/common/platform/Core.kt | 7 +- .../simplex/common/platform/NtfManager.kt | 2 +- .../chat/simplex/common/views/WelcomeView.kt | 35 ++- .../views/database/DatabaseEncryptionView.kt | 187 ++++++-------- .../views/database/DatabaseErrorView.kt | 18 +- .../common/views/database/DatabaseView.kt | 5 +- .../common/views/helpers/AlertManager.kt | 6 +- .../common/views/helpers/DatabaseUtils.kt | 6 + .../common/views/helpers/SimpleButton.kt | 7 +- .../common/views/localauth/LocalAuthView.kt | 1 - .../views/onboarding/CreateSimpleXAddress.kt | 24 +- .../common/views/onboarding/HowItWorks.kt | 5 +- .../common/views/onboarding/OnboardingView.kt | 1 + .../views/onboarding/SetNotificationsMode.kt | 2 +- .../onboarding/SetupDatabasePassphrase.kt | 233 ++++++++++++++++++ .../common/views/onboarding/SimpleXInfo.kt | 13 +- .../common/views/usersettings/SettingsView.kt | 9 +- .../commonMain/resources/MR/base/strings.xml | 12 + .../DatabaseEncryptionView.desktop.kt | 106 ++++++++ 23 files changed, 652 insertions(+), 153 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index c94194a35..f70032788 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 @@ -71,7 +71,7 @@ class SimplexApp: Application(), LifecycleEventObserver { } Lifecycle.Event.ON_RESUME -> { isAppOnForeground = true - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { SimplexService.showBackgroundServiceNoticeIfNeeded() } /** @@ -80,7 +80,7 @@ class SimplexApp: Application(), LifecycleEventObserver { * It can happen when app was started and a user enables battery optimization while app in background * */ if (chatModel.chatRunning.value != false && - chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && + chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && appPrefs.notificationsMode.get() == NotificationsMode.SERVICE ) { SimplexService.start() @@ -191,7 +191,7 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidChatInitializedAndStarted() { // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { SimplexService.showBackgroundServiceNoticeIfNeeded() if (appPrefs.notificationsMode.get() == NotificationsMode.SERVICE) withBGApi { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt new file mode 100644 index 000000000..df2499926 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -0,0 +1,106 @@ +package chat.simplex.common.views.database + +import SectionItemView +import SectionTextFooter +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.theme.SimplexGreen +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun SavePassphraseSetting( + useKeychain: Boolean, + initialRandomDBPassphrase: Boolean, + storedKey: Boolean, + progressIndicator: Boolean, + minHeight: Dp, + onCheckedChange: (Boolean) -> Unit, +) { + SectionItemView(minHeight = minHeight) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled), + stringResource(MR.strings.save_passphrase_in_keychain), + tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + stringResource(MR.strings.save_passphrase_in_keychain), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = useKeychain, + onCheckedChange = onCheckedChange, + enabled = !initialRandomDBPassphrase && !progressIndicator + ) + } + } +} + +@Composable +actual fun DatabaseEncryptionFooter( + useKeychain: MutableState, + chatDbEncrypted: Boolean?, + storedKey: MutableState, + initialRandomDBPassphrase: MutableState, +) { + if (chatDbEncrypted == false) { + SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) + } else if (useKeychain.value) { + if (storedKey.value) { + SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely)) + if (initialRandomDBPassphrase.value) { + SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) + } else { + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } +} + +actual fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.encrypt_database_question), + text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.encrypt_database), + onConfirm = onConfirm, + destructive = true, + ) +} + +actual fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.change_database_passphrase_question), + text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.update_database), + onConfirm = onConfirm, + destructive = false, + ) +} + +actual fun removePassphraseAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.remove_passphrase_from_keychain), + text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(), + confirmText = generalGetString(MR.strings.remove_passphrase), + onConfirm = onConfirm, + destructive = true, + ) +} 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 cb386be7a..6b9770c09 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 @@ -32,8 +32,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.* data class SettingsViewState( val userPickerState: MutableStateFlow, @@ -64,7 +63,7 @@ fun MainScreen() { if ( !chatModel.controller.appPrefs.laNoticeShown.get() && showAdvertiseLAAlert - && chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete + && chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.chats.isNotEmpty() && chatModel.activeCallInvitation.value == null ) { @@ -102,7 +101,10 @@ fun MainScreen() { } Box { - val onboarding = chatModel.onboardingStage.value + var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) } + LaunchedEffect(Unit) { + snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it } + } val userCreated = chatModel.userCreated.value var showInitializationView by remember { mutableStateOf(false) } when { @@ -112,7 +114,7 @@ fun MainScreen() { DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) } } - onboarding == null || userCreated == null -> SplashView() + remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView() onboarding == OnboardingStage.OnboardingComplete && userCreated -> { Box { showAdvertiseLAAlert = true @@ -134,6 +136,7 @@ fun MainScreen() { } } onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {} + onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) } 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 629d4b869..0eb35fccd 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 @@ -38,7 +38,6 @@ import kotlin.time.* @Stable object ChatModel { val controller: ChatController = ChatController - val onboardingStage = mutableStateOf(null) val setDeliveryReceipts = mutableStateOf(false) val currentUser = mutableStateOf(null) val users = mutableStateListOf() 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 c39c00080..341f4e954 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 @@ -50,17 +50,16 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat if (user == null) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) - chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo chatModel.currentUser.value = null chatModel.users.clear() } else { val savedOnboardingStage = appPreferences.onboardingStage.get() - chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { + appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { OnboardingStage.Step3_CreateSimpleXAddress } else { savedOnboardingStage - } - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) { + }) + if (appPreferences.onboardingStage.get() == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) { chatModel.setDeliveryReceipts.value = true } chatController.startChat(user) 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 6adadaffa..a03df5add 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 @@ -100,7 +100,7 @@ abstract class NtfManager { if (chatModel.chatRunning.value == null) { val step = 50L for (i in 0..(timeout / step)) { - if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) { + if (chatModel.chatRunning.value == true || chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.Step1_SimpleXInfo) { break } delay(step) 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 9539a0790..13ce16d0a 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 @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel import chat.simplex.common.model.Profile +import chat.simplex.common.platform.appPlatform import chat.simplex.common.platform.navigationBarsWithImePadding import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -88,14 +89,20 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) { icon = painterResource(MR.images.ic_arrow_back_ios_new), textDecoration = TextDecoration.None, fontWeight = FontWeight.Medium - ) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo } + ) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) } } Spacer(Modifier.fillMaxWidth().weight(1f)) val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) val createModifier: Modifier val createColor: Color if (enabled) { - createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp) + createModifier = Modifier.clickable { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { + createProfileInProfiles(chatModel, displayName.value, fullName.value, close) + } else { + createProfileOnboarding(chatModel, displayName.value, fullName.value, close) + } + }.padding(8.dp) createColor = MaterialTheme.colors.primary } else { createModifier = Modifier.padding(8.dp) @@ -116,7 +123,7 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) { } } -fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { +fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { withApi { val user = chatModel.controller.apiCreateActiveUser( Profile(displayName, fullName, null) @@ -125,16 +132,32 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, c if (chatModel.users.isEmpty()) { chatModel.controller.startChat(user) chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) - chatModel.onboardingStage.value = OnboardingStage.Step3_CreateSimpleXAddress } else { val users = chatModel.controller.listUsers() chatModel.users.clear() chatModel.users.addAll(users) chatModel.controller.getUserChatData() + close() + } + } +} + +fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { + withApi { + chatModel.controller.apiCreateActiveUser( + Profile(displayName, fullName, null) + ) ?: return@withApi + val onboardingStage = chatModel.controller.appPrefs.onboardingStage + if (chatModel.users.isEmpty()) { + onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get()) { + OnboardingStage.Step2_5_SetupDatabasePassphrase + } else { + OnboardingStage.Step3_CreateSimpleXAddress + }) + } else { // the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen, // this will get it unstuck. - chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) - chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete + onboardingStage.set(OnboardingStage.OnboardingComplete) close() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 37080ebd8..e34f80a7e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -30,6 +30,7 @@ import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* +import chat.simplex.common.platform.appPlatform import chat.simplex.res.MR import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.datetime.Clock @@ -61,46 +62,8 @@ fun DatabaseEncryptionView(m: ChatModel) { initialRandomDBPassphrase, progressIndicator, onConfirmEncrypt = { - progressIndicator.value = true withApi { - try { - prefs.encryptionStartedAt.set(Clock.System.now()) - val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) - prefs.encryptionStartedAt.set(null) - val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError - when { - sqliteError is SQLiteError.ErrorNotADatabase -> { - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.wrong_passphrase_title), - generalGetString(MR.strings.enter_correct_current_passphrase) - ) - } - } - error != null -> { - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), - "failed to set storage encryption: ${error.responseType} ${error.details}" - ) - } - } - else -> { - prefs.initialRandomDBPassphrase.set(false) - initialRandomDBPassphrase.value = false - if (useKeychain.value) { - DatabaseUtils.ksDatabasePassword.set(newKey.value) - } - resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted)) - } - } - } - } catch (e: Exception) { - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString()) - } - } + encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator) } } ) @@ -143,17 +106,11 @@ fun DatabaseEncryptionLayout( if (checked) { setUseKeychain(true, useKeychain, prefs) } else if (storedKey.value) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.remove_passphrase_from_keychain), - text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(), - confirmText = generalGetString(MR.strings.remove_passphrase), - onConfirm = { - DatabaseUtils.ksDatabasePassword.remove() - setUseKeychain(false, useKeychain, prefs) - storedKey.value = false - }, - destructive = true, - ) + removePassphraseAlert { + DatabaseUtils.ksDatabasePassword.remove() + setUseKeychain(false, useKeychain, prefs) + storedKey.value = false + } } else { setUseKeychain(false, useKeychain, prefs) } @@ -217,37 +174,13 @@ fun DatabaseEncryptionLayout( } Column { - if (chatDbEncrypted == false) { - SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) - } else if (useKeychain.value) { - if (storedKey.value) { - SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely)) - if (initialRandomDBPassphrase.value) { - SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) - } else { - SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) - } - } else { - SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs)) - } - } else { - SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) - SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) - } + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase) } SectionBottomSpacer() } } -fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.encrypt_database_question), - text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(), - confirmText = generalGetString(MR.strings.encrypt_database), - onConfirm = onConfirm, - destructive = true, - ) -} +expect fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) fun encryptDatabaseAlert(onConfirm: () -> Unit) { AlertManager.shared.showAlertDialog( @@ -259,15 +192,7 @@ fun encryptDatabaseAlert(onConfirm: () -> Unit) { ) } -fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.change_database_passphrase_question), - text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(), - confirmText = generalGetString(MR.strings.update_database), - onConfirm = onConfirm, - destructive = false, - ) -} +expect fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) fun changeDatabaseKeyAlert(onConfirm: () -> Unit) { AlertManager.shared.showAlertDialog( @@ -279,37 +204,25 @@ fun changeDatabaseKeyAlert(onConfirm: () -> Unit) { ) } +expect fun removePassphraseAlert(onConfirm: () -> Unit) + @Composable -fun SavePassphraseSetting( +expect fun SavePassphraseSetting( useKeychain: Boolean, initialRandomDBPassphrase: Boolean, storedKey: Boolean, progressIndicator: Boolean, minHeight: Dp = TextFieldDefaults.MinHeight, onCheckedChange: (Boolean) -> Unit, -) { - SectionItemView(minHeight = minHeight) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled), - stringResource(MR.strings.save_passphrase_in_keychain), - tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - stringResource(MR.strings.save_passphrase_in_keychain), - Modifier.padding(end = 24.dp), - color = Color.Unspecified - ) - Spacer(Modifier.fillMaxWidth().weight(1f)) - DefaultSwitch( - checked = useKeychain, - onCheckedChange = onCheckedChange, - enabled = !initialRandomDBPassphrase && !progressIndicator - ) - } - } -} +) + +@Composable +expect fun DatabaseEncryptionFooter( + useKeychain: MutableState, + chatDbEncrypted: Boolean?, + storedKey: MutableState, + initialRandomDBPassphrase: MutableState, +) fun resetFormAfterEncryption( m: ChatModel, @@ -443,6 +356,62 @@ fun PassphraseField( } } +suspend fun encryptDatabase( + currentKey: MutableState, + newKey: MutableState, + confirmNewKey: MutableState, + initialRandomDBPassphrase: MutableState, + useKeychain: MutableState, + storedKey: MutableState, + progressIndicator: MutableState +): Boolean { + val m = ChatModel + val prefs = ChatController.appPrefs + progressIndicator.value = true + return try { + prefs.encryptionStartedAt.set(Clock.System.now()) + val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) + prefs.encryptionStartedAt.set(null) + val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError + when { + sqliteError is SQLiteError.ErrorNotADatabase -> { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.wrong_passphrase_title), + generalGetString(MR.strings.enter_correct_current_passphrase) + ) + } + false + } + error != null -> { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), + "failed to set storage encryption: ${error.responseType} ${error.details}" + ) + } + false + } + else -> { + prefs.initialRandomDBPassphrase.set(false) + initialRandomDBPassphrase.value = false + if (useKeychain.value) { + DatabaseUtils.ksDatabasePassword.set(newKey.value) + } + resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted)) + } + true + } + } + } catch (e: Exception) { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString()) + } + false + } +} + // based on https://generatepasswords.org/how-to-calculate-entropy/ private fun passphraseEntropy(s: String): Double { var hasDigits = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 710148168..bce8fdf4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -12,6 +12,9 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.common.model.AppPreferences @@ -252,6 +255,11 @@ private fun mtrErrorDescription(err: MTRError): String = @Composable private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onClick: (() -> Unit)? = null) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(100L) + focusRequester.requestFocus() + } PassphraseField( text, generalGetString(MR.strings.enter_passphrase), @@ -259,7 +267,15 @@ private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onCli keyboardActions = KeyboardActions(onDone = if (enabled) { { onClick?.invoke() } } else null - ) + ), + modifier = Modifier.focusRequester(focusRequester).onPreviewKeyEvent { + if (onClick != null && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + onClick() + true + } else { + 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 05f38b74d..bd29cb7ae 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 @@ -73,6 +73,7 @@ fun DatabaseView( m.chatDbChanged.value, useKeychain.value, m.chatDbEncrypted.value, + m.controller.appPrefs.storeDBPassphrase.state.value, m.controller.appPrefs.initialRandomDBPassphrase, importArchiveLauncher, chatArchiveName, @@ -122,6 +123,7 @@ fun DatabaseLayout( chatDbChanged: Boolean, useKeyChain: Boolean, chatDbEncrypted: Boolean?, + passphraseSaved: Boolean, initialRandomDBPassphrase: SharedPreference, importArchiveLauncher: FileChooserLauncher, chatArchiveName: MutableState, @@ -182,7 +184,7 @@ fun DatabaseLayout( else painterResource(MR.images.ic_lock), stringResource(MR.strings.database_passphrase), click = showSettingsModal() { DatabaseEncryptionView(it) }, - iconColor = if (unencrypted) WarningOrange else MaterialTheme.colors.secondary, + iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, disabled = operationsDisabled ) SettingsActionItem( @@ -657,6 +659,7 @@ fun PreviewDatabaseLayout() { chatDbChanged = false, useKeyChain = false, chatDbEncrypted = false, + passphraseSaved = false, initialRandomDBPassphrase = SharedPreference({ true }, {}), importArchiveLauncher = rememberFileChooserLauncher(true) {}, chatArchiveName = remember { mutableStateOf("dummy_archive") }, 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 d96b9d8a1..d8466e9d9 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 @@ -101,6 +101,10 @@ class AlertManager { Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.SpaceBetween ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } TextButton(onClick = { onDismiss?.invoke() hideAlert() @@ -108,7 +112,7 @@ class AlertManager { TextButton(onClick = { onConfirm?.invoke() hideAlert() - }) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } + }, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index 10641b6d8..e7da47f8f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -54,6 +54,12 @@ object DatabaseUtils { } else { dbKey = ksDatabasePassword.get() ?: "" } + } else if (appPlatform.isDesktop && !hasDatabase(dataDir.absolutePath)) { + // In case of database was deleted by hand + dbKey = randomDatabasePassword() + ksDatabasePassword.set(dbKey) + appPreferences.initialRandomDBPassphrase.set(true) + appPreferences.storeDBPassphrase.set(true) } return dbKey } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt index 5ab0e68c6..7db001a4b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp @@ -66,11 +67,13 @@ fun SimpleButton( fun SimpleButtonIconEnded( text: String, icon: Painter, + style: TextStyle = MaterialTheme.typography.caption, color: Color = MaterialTheme.colors.primary, + disabled: Boolean = false, click: () -> Unit ) { - SimpleButtonFrame(click) { - Text(text, style = MaterialTheme.typography.caption, color = color) + SimpleButtonFrame(click, disabled = disabled) { + Text(text, style = style, color = color) Icon( icon, text, tint = color, modifier = Modifier.padding(start = 8.dp) 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 756e605dc..8b5c2a833 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 @@ -66,7 +66,6 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( val createdUser = m.controller.apiCreateActiveUser(profile, pastTimestamp = true) m.currentUser.value = createdUser m.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) - m.onboardingStage.value = OnboardingStage.OnboardingComplete if (createdUser != null) { m.controller.startChat(createdUser) } 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 84d1ae639..72cbc3a62 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 @@ -14,8 +14,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.UserContactLinkRec +import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -29,6 +28,10 @@ fun CreateSimpleXAddress(m: ChatModel) { val clipboard = LocalClipboardManager.current val uriHandler = LocalUriHandler.current + LaunchedEffect(Unit) { + prepareChatBeforeAddressCreation() + } + CreateSimpleXAddressLayout( userAddress.value, share = { address: String -> clipboard.shareText(address) }, @@ -63,7 +66,6 @@ fun CreateSimpleXAddress(m: ChatModel) { OnboardingStage.OnboardingComplete } m.controller.appPrefs.onboardingStage.set(next) - m.onboardingStage.value = next }, ) @@ -172,3 +174,19 @@ private fun ProgressIndicator() { ) } } + +private fun prepareChatBeforeAddressCreation() { + if (chatModel.users.isNotEmpty()) return + withApi { + val user = chatModel.controller.apiGetActiveUser() ?: return@withApi + chatModel.currentUser.value = user + if (chatModel.users.isEmpty()) { + chatModel.controller.startChat(user) + } else { + val users = chatModel.controller.listUsers() + chatModel.users.clear() + chatModel.users.addAll(users) + chatModel.controller.getUserChatData() + } + } +} 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 3b2e0b408..e3dfb2b73 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 @@ -13,8 +13,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatController -import chat.simplex.common.model.User +import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* @@ -22,7 +21,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @Composable -fun HowItWorks(user: User?, onboardingStage: MutableState? = null) { +fun HowItWorks(user: User?, onboardingStage: SharedPreference? = null) { Column(Modifier .fillMaxWidth() .padding(horizontal = DEFAULT_PADDING), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index e3190f875..119ed8cd4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.launch enum class OnboardingStage { Step1_SimpleXInfo, Step2_CreateProfile, + Step2_5_SetupDatabasePassphrase, Step3_CreateSimpleXAddress, Step4_SetNotificationsMode, OnboardingComplete diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index af640d5b4..aa413016d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -41,7 +41,7 @@ fun SetNotificationsMode(m: ChatModel) { } Spacer(Modifier.fillMaxHeight().weight(1f)) Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), contentAlignment = Alignment.Center) { - OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage, false) { + OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, false) { changeNotificationsMode(currentMode.value, m) } } 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 new file mode 100644 index 000000000..9bc5ae846 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -0,0 +1,233 @@ +package chat.simplex.common.views.onboarding + +import SectionBottomSpacer +import SectionItemView +import SectionItemViewSpaceBetween +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.database.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.delay + +@Composable +fun SetupDatabasePassphrase(m: ChatModel) { + val progressIndicator = remember { mutableStateOf(false) } + val prefs = m.controller.appPrefs + val saveInPreferences = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } + val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) } + // Do not do rememberSaveable on current key to prevent saving it on disk in clear text + val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") } + val newKey = rememberSaveable { mutableStateOf("") } + val confirmNewKey = rememberSaveable { mutableStateOf("") } + fun nextStep() { + m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) + } + SetupDatabasePassphraseLayout( + currentKey, + newKey, + confirmNewKey, + progressIndicator, + onConfirmEncrypt = { + withApi { + if (m.chatRunning.value == true) { + // Stop chat if it's started before doing anything + stopChatAsync(m) + } + prefs.storeDBPassphrase.set(false) + + val newKeyValue = newKey.value + val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator) + if (success) { + startChat(newKeyValue) + nextStep() + } else { + // Rollback in case of it is finished with error in order to allow to repeat the process again + prefs.storeDBPassphrase.set(true) + } + } + }, + nextStep = ::nextStep, + ) + + if (progressIndicator.value) { + ProgressIndicator() + } + + DisposableEffect(Unit) { + onDispose { + if (m.chatRunning.value != true) { + withBGApi { + val user = chatController.apiGetActiveUser() + if (user != null) { + m.controller.startChat(user) + } + } + } + } + } +} + +@Composable +private fun SetupDatabasePassphraseLayout( + currentKey: MutableState, + newKey: MutableState, + confirmNewKey: MutableState, + progressIndicator: MutableState, + onConfirmEncrypt: () -> Unit, + nextStep: () -> Unit, +) { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarTitle(stringResource(MR.strings.setup_database_passphrase)) + + Spacer(Modifier.weight(1f)) + + Column(Modifier.width(600.dp)) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + LaunchedEffect(Unit) { + delay(100L) + focusRequester.requestFocus() + } + PassphraseField( + newKey, + generalGetString(MR.strings.new_passphrase), + modifier = Modifier + .padding(horizontal = DEFAULT_PADDING) + .focusRequester(focusRequester) + .onPreviewKeyEvent { + if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + focusManager.moveFocus(FocusDirection.Down) + true + } else { + false + } + }, + showStrength = true, + isValid = ::validKey, + keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + ) + val onClickUpdate = { + // Don't do things concurrently. Shouldn't be here concurrently, just in case + if (!progressIndicator.value) { + encryptDatabaseAlert(onConfirmEncrypt) + } + } + val disabled = currentKey.value == newKey.value || + newKey.value != confirmNewKey.value || + newKey.value.isEmpty() || + !validKey(currentKey.value) || + !validKey(newKey.value) || + progressIndicator.value + + PassphraseField( + confirmNewKey, + generalGetString(MR.strings.confirm_new_passphrase), + modifier = Modifier + .padding(horizontal = DEFAULT_PADDING) + .onPreviewKeyEvent { + if (!disabled && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + onClickUpdate() + true + } else { + false + } + }, + isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, + keyboardActions = KeyboardActions(onDone = { + if (!disabled) onClickUpdate() + defaultKeyboardAction(ImeAction.Done) + }), + ) + + Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) { + SetPassphraseButton(disabled, onClickUpdate) + } + + Column { + SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } + } + + Spacer(Modifier.weight(1f)) + SkipButton(progressIndicator.value, nextStep) + + SectionBottomSpacer() + } +} + +@Composable +private fun SetPassphraseButton(disabled: Boolean, onClick: () -> Unit) { + SimpleButtonIconEnded( + stringResource(MR.strings.set_database_passphrase), + painterResource(MR.images.ic_check), + style = MaterialTheme.typography.h2, + color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + disabled = disabled, + click = onClick + ) +} + +@Composable +private fun SkipButton(disabled: Boolean, onClick: () -> Unit) { + SimpleButtonIconEnded(stringResource(MR.strings.use_random_passphrase), painterResource(MR.images.ic_chevron_right), color = + if (disabled) MaterialTheme.colors.secondary else WarningOrange, disabled = disabled, click = onClick) + Text( + stringResource(MR.strings.you_can_change_it_later), + Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING * 3), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun ProgressIndicator() { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + } +} + +private suspend fun startChat(key: String?) { + val m = ChatModel + initChatController(key) + m.chatDbChanged.value = false + m.chatRunning.value = true +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 8248194eb..f20c4508b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -25,7 +24,7 @@ import dev.icerock.moko.resources.StringResource fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { SimpleXInfoLayout( user = chatModel.currentUser.value, - onboardingStage = if (onboarding) chatModel.onboardingStage else null, + onboardingStage = if (onboarding) chatModel.controller.appPrefs.onboardingStage else null, showModal = { modalView -> { if (onboarding) ModalManager.fullscreen.showModal { modalView(chatModel) } else ModalManager.start.showModal { modalView(chatModel) } } }, ) } @@ -33,7 +32,7 @@ fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { @Composable fun SimpleXInfoLayout( user: User?, - onboardingStage: MutableState?, + onboardingStage: SharedPreference?, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), ) { Column( @@ -100,11 +99,11 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour } @Composable -fun OnboardingActionButton(user: User?, onboardingStage: MutableState, onclick: (() -> Unit)? = null) { +fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)? = null) { if (user == null) { - OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, true, onclick) + OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick) } else { - OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, true, onclick) + OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick) } } @@ -112,7 +111,6 @@ fun OnboardingActionButton(user: User?, onboardingStage: MutableState, border: Boolean, onclick: (() -> Unit)? ) { @@ -129,7 +127,6 @@ fun OnboardingActionButton( SimpleButtonFrame(click = { onclick?.invoke() - onboardingStage.value = onboarding if (onboarding != null) { ChatController.appPrefs.onboardingStage.set(onboarding) } 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 c7d57353c..8969e48b2 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 @@ -43,6 +43,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt profile = user.profile, stopped, chatModel.chatDbEncrypted.value == true, + remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value, remember { chatModel.controller.appPrefs.notificationsMode.state }, user.displayName, setPerformLA = setPerformLA, @@ -115,6 +116,7 @@ fun SettingsLayout( profile: LocalProfile, stopped: Boolean, encrypted: Boolean, + passphraseSaved: Boolean, notificationsMode: State, userDisplayName: String, setPerformLA: (Boolean) -> Unit, @@ -162,7 +164,7 @@ fun SettingsLayout( SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true) - DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) + DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) } SectionDividerSpaced() @@ -207,7 +209,7 @@ expect fun SettingsSectionApp( withAuth: (title: String, desc: String, block: () -> Unit) -> Unit ) -@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { +@Composable private fun DatabaseItem(encrypted: Boolean, saved: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { SectionItemViewWithIcon(openDatabaseView) { Row( Modifier.fillMaxWidth(), @@ -217,7 +219,7 @@ expect fun SettingsSectionApp( Icon( painterResource(MR.images.ic_database), contentDescription = stringResource(MR.strings.database_passphrase_and_export), - tint = if (encrypted) MaterialTheme.colors.secondary else WarningOrange, + tint = if (encrypted && (appPlatform.isAndroid || !saved)) MaterialTheme.colors.secondary else WarningOrange, ) TextIconSpaced(true) Text(stringResource(MR.strings.database_passphrase_and_export)) @@ -473,6 +475,7 @@ fun PreviewSettingsLayout() { profile = LocalProfile.sampleData, stopped = false, encrypted = false, + passphraseSaved = false, notificationsMode = remember { mutableStateOf(NotificationsMode.OFF) }, userDisplayName = "Alice", setPerformLA = { _ -> }, 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 ea6d13a35..52449eaa9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -769,6 +769,11 @@ Good for battery. Background service checks messages every 10 minutes. You may miss calls or urgent messages.]]> Uses more battery! Background service always runs – notifications are shown as soon as messages are available.]]> + + Setup database passphrase + Random passphrase is stored in settings as plaintext.\nYou can change it later. + Use random passphrase + Paste received link @@ -984,9 +989,11 @@ Save passphrase in Keystore + Save passphrase in settings Database encrypted! Error encrypting database Remove passphrase from Keystore? + Remove passphrase from settings? Notifications will be delivered only until the app stops! Remove Encrypt @@ -995,18 +1002,23 @@ New passphrase… Confirm new passphrase… Update database passphrase + Set database passphrase Please enter correct current passphrase. Your chat database is not encrypted - set passphrase to protect it. Android Keystore is used to securely store passphrase - it allows notification service to work. + The passphrase is stored in settings as plaintext. Database is encrypted using a random passphrase, you can change it. Please note: you will NOT be able to recover or change passphrase if you lose it.]]> Android Keystore will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving notifications. + The passphrase will be stored in settings as plaintext after you change it or restart the app. You have to enter passphrase every time the app starts - it is not stored on the device. Encrypt database? Change database passphrase? Database will be encrypted. Database will be encrypted and the passphrase stored in the Keystore. + Database will be encrypted and the passphrase stored in settings. Database encryption passphrase will be updated and stored in the Keystore. + Database encryption passphrase will be updated and stored in settings. Database encryption passphrase will be updated. Please store passphrase securely, you will NOT be able to change it if you lose it. Please store passphrase securely, you will NOT be able to access chat if you lose it. diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt new file mode 100644 index 000000000..af2b269b5 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -0,0 +1,106 @@ +package chat.simplex.common.views.database + +import SectionItemView +import SectionTextFooter +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun SavePassphraseSetting( + useKeychain: Boolean, + initialRandomDBPassphrase: Boolean, + storedKey: Boolean, + progressIndicator: Boolean, + minHeight: Dp, + onCheckedChange: (Boolean) -> Unit, +) { + SectionItemView(minHeight = minHeight) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled), + stringResource(MR.strings.save_passphrase_in_settings), + tint = if (storedKey) WarningOrange else MaterialTheme.colors.secondary + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + stringResource(MR.strings.save_passphrase_in_settings), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = useKeychain, + onCheckedChange = onCheckedChange, + enabled = !initialRandomDBPassphrase && !progressIndicator + ) + } + } +} + +@Composable +actual fun DatabaseEncryptionFooter( + useKeychain: MutableState, + chatDbEncrypted: Boolean?, + storedKey: MutableState, + initialRandomDBPassphrase: MutableState, +) { + if (chatDbEncrypted == false) { + SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) + } else if (useKeychain.value) { + if (storedKey.value) { + SectionTextFooter(generalGetString(MR.strings.settings_is_storing_in_clear_text)) + if (initialRandomDBPassphrase.value) { + SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) + } else { + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.passphrase_will_be_saved_in_settings)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } +} + +actual fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.encrypt_database_question), + text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored_in_settings) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.encrypt_database), + onConfirm = onConfirm, + destructive = true, + ) +} + +actual fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.change_database_passphrase_question), + text = generalGetString(MR.strings.database_encryption_will_be_updated_in_settings) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.update_database), + onConfirm = onConfirm, + destructive = false, + ) +} + +actual fun removePassphraseAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.remove_passphrase_from_settings), + text = storeSecurelyDanger(), + confirmText = generalGetString(MR.strings.remove_passphrase), + onConfirm = onConfirm, + destructive = true, + ) +} From 6ff3024238490bf81cb70f4fe476fe0b98c6ec3d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 5 Sep 2023 12:44:21 +0100 Subject: [PATCH 12/41] mobile: translations (#3015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Chinese (Simplified)) Currently translated at 93.6% (1154 of 1232 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/ * Translated using Weblate (Japanese) Currently translated at 96.5% (1311 of 1358 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Finnish) Currently translated at 100.0% (1358 of 1358 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 19.4% (240 of 1232 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fi/ * Translated using Weblate (Polish) Currently translated at 100.0% (1232 of 1232 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Hebrew) Currently translated at 99.9% (1357 of 1358 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (French) Currently translated at 100.0% (1358 of 1358 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% (1232 of 1232 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1358 of 1358 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 99.9% (1231 of 1232 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1358 of 1358 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% (1232 of 1232 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 99.5% (1227 of 1232 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.0% (1345 of 1358 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% (1354 of 1358 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Arabic) Currently translated at 3.7% (46 of 1232 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/ * Translated using Weblate (Finnish) Currently translated at 100.0% (1358 of 1358 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 100.0% (1232 of 1232 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fi/ * Translated using Weblate (Hebrew) Currently translated at 100.0% (1358 of 1358 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Hebrew) Currently translated at 51.7% (637 of 1232 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/he/ * Translated using Weblate (Japanese) Currently translated at 99.8% (1230 of 1232 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.8% (1356 of 1358 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Czech) Currently translated at 99.5% (1352 of 1358 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.0% (1220 of 1232 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/cs/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 98.2% (1334 of 1358 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Japanese) Currently translated at 99.0% (1220 of 1232 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% (1348 of 1358 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Update translation files Updated by "Remove blank strings" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (Japanese) Currently translated at 99.2% (1223 of 1232 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% (1348 of 1358 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Japanese) Currently translated at 98.8% (1218 of 1232 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 98.9% (1344 of 1358 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Japanese) Currently translated at 98.6% (1339 of 1358 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * ios: import/export translations, android: formatted string tags --------- Co-authored-by: 小连招 Co-authored-by: a4318 Co-authored-by: petri Co-authored-by: B.O.S.S Co-authored-by: ItaiShek Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: No name Co-authored-by: John m Co-authored-by: jonnysemon Co-authored-by: zenobit Co-authored-by: marfS2 Co-authored-by: Hosted Weblate --- .../ar.xcloc/Localized Contents/ar.xliff | 20 + .../cs.xcloc/Localized Contents/cs.xliff | 23 +- .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../es.xcloc/Localized Contents/es.xliff | 26 +- .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../fi.xcloc/Localized Contents/fi.xliff | 3550 +++++++++++++---- .../fr.xcloc/Localized Contents/fr.xliff | 2 +- .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../he.xcloc/Localized Contents/he.xliff | 460 ++- .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../ja.xcloc/Localized Contents/ja.xliff | 94 + .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../nl.xcloc/Localized Contents/nl.xliff | 6 +- .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../pl.xcloc/Localized Contents/pl.xliff | 4 + .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../SimpleX NSE/en.lproj/Localizable.strings | 1 - .../Localized Contents/zh-Hans.xliff | 11 + .../SimpleX NSE/en.lproj/Localizable.strings | 1 - apps/ios/cs.lproj/Localizable.strings | 45 +- apps/ios/es.lproj/Localizable.strings | 26 +- apps/ios/fr.lproj/Localizable.strings | 2 +- apps/ios/ja.lproj/Localizable.strings | 276 ++ apps/ios/nl.lproj/Localizable.strings | 6 +- apps/ios/pl.lproj/Localizable.strings | 12 + apps/ios/zh-Hans.lproj/Localizable.strings | 27 + .../commonMain/resources/MR/ar/strings.xml | 2 +- .../commonMain/resources/MR/cs/strings.xml | 23 +- .../commonMain/resources/MR/es/strings.xml | 26 +- .../commonMain/resources/MR/fi/strings.xml | 162 +- .../commonMain/resources/MR/fr/strings.xml | 2 +- .../commonMain/resources/MR/iw/strings.xml | 4 +- .../commonMain/resources/MR/ja/strings.xml | 72 +- .../commonMain/resources/MR/nl/strings.xml | 6 +- .../resources/MR/pt-rBR/strings.xml | 28 +- 38 files changed, 4021 insertions(+), 906 deletions(-) delete mode 100644 apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/de.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/es.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/it.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/th.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings delete mode 100644 apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index 980e51142..e0477899b 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -3655,6 +3655,26 @@ SimpleX servers cannot see your profile. %1$@ في %2$@: copied message info, <sender> at <time> + + # %@ + # %@ + copied message info title, # <title> + + + ## History + ## السجل + copied message info + + + ## In reply to + ## ردًا على + copied message info + + + %@ and %@ connected + %@ و %@ متصل + 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 517682933..bef40f5ae 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -44,14 +44,17 @@ # %@ + # %@ copied message info title, # <title> ## History + ## Historie copied message info ## In reply to + ## Odpovídáno copied message info @@ -86,6 +89,7 @@ %@ and %@ connected + %@ a %@ připojen No comment provided by engineer. @@ -120,6 +124,7 @@ %@, %@ and %lld other members connected + %@, %@ a %lld ostatní členové připojeni No comment provided by engineer. @@ -477,7 +482,7 @@ Accept connection request? - Přijmout kontakt + Přijmout kontakt? No comment provided by engineer. @@ -1063,15 +1068,17 @@ 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 kontaktní odkaz? + Připojit se přes odkaz No comment provided by engineer. @@ -1091,7 +1098,7 @@ Connect via one-time link - Připojit se jednorázovým odkazem? + Připojit se jednorázovým odkazem No comment provided by engineer. @@ -1569,6 +1576,7 @@ Delivery + Doručenka No comment provided by engineer. @@ -2583,6 +2591,7 @@ Incognito mode protects your privacy by using a new random profile for each contact. + Režim inkognito chrání vaše soukromí používáním nového náhodného profilu pro každý kontakt. No comment provided by engineer. @@ -2659,6 +2668,7 @@ Invalid status + Neplatný status item status text @@ -2744,12 +2754,12 @@ Join incognito - Připojte se inkognito + Připojit se inkognito No comment provided by engineer. Joining group - Připojení ke skupině + Připojování ke skupině No comment provided by engineer. @@ -3009,6 +3019,7 @@ Most likely this connection is deleted. + Pravděpodobně je toto spojení smazáno. item status description @@ -4623,7 +4634,7 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován They can be overridden in contact and group settings. - Mohou být přepsány v nastavení kontaktů + Mohou být přepsány v nastavení kontaktů. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - 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 5df42bc48..174261590 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -466,7 +466,7 @@ About SimpleX address - Acerca de dirección SimpleX + Acerca de la dirección SimpleX No comment provided by engineer. @@ -1208,12 +1208,12 @@ Create SimpleX address - Crear dirección SimpleX + Crear tu dirección SimpleX No comment provided by engineer. Create an address to let people connect with you. - Crear una dirección para que otras personas se puedan conectar contigo. + Crea una dirección para que otras personas puedan conectar contigo. No comment provided by engineer. @@ -1248,7 +1248,7 @@ Create your profile - Crear tu perfil + Crea tu perfil No comment provided by engineer. @@ -1381,7 +1381,7 @@ Decentralized - Descentralizado + Descentralizada No comment provided by engineer. @@ -1706,7 +1706,7 @@ Don't create address - No crear dirección + No crear dirección SimpleX No comment provided by engineer. @@ -2899,7 +2899,7 @@ Markdown in messages - Sintaxis markdown en los mensajes + Sintaxis Markdown No comment provided by engineer. @@ -3663,7 +3663,7 @@ Receiving address will be changed to a different server. Address change will complete after sender comes online. - La dirección de recepción se cambiará. El cambio se completará cuando el remitente esté en línea. + La dirección de recepción pasará a otro servidor. El cambio se completará cuando el remitente esté en línea. No comment provided by engineer. @@ -4383,7 +4383,7 @@ Stop chat to enable database actions - Para habilitar las acciones sobre la base de datos, previamente debes detener Chat + Detén SimpleX para habilitar las acciones sobre la base de datos No comment provided by engineer. @@ -4590,7 +4590,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. The next generation of private messaging - La próxima generación de mensajería privada + La nueva generación de mensajería privada No comment provided by engineer. @@ -5130,7 +5130,7 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb You can create it later - Puedes crearlo más tarde + Puedes crearla más tarde No comment provided by engineer. @@ -5330,7 +5330,7 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Your chat database - Base de datos Chat + Base de datos No comment provided by engineer. @@ -5411,7 +5411,7 @@ Los servidores de SimpleX no pueden ver tu perfil. Your profile, contacts and delivered messages are stored on your device. - Tu perfil, contactos y mensajes entregados se almacenan en tu dispositivo. + Tu perfil, contactos y mensajes se almacenan en tu dispositivo. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - 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 5a66f06cc..a03c47876 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -953,792 +953,989 @@ Nykyinen tunnuslause… No comment provided by engineer. - + Currently maximum supported file size is %@. + Nykyinen tuettu enimmäistiedostokoko on %@. No comment provided by engineer. - + Dark + Tumma No comment provided by engineer. - + Database ID + Tietokannan tunnus No comment provided by engineer. - + Database encrypted! + Tietokanta salattu! No comment provided by engineer. - + Database encryption passphrase will be updated and stored in the keychain. + Tietokannan salaustunnuslause päivitetään ja tallennetaan avainnippuun. + No comment provided by engineer. - + Database encryption passphrase will be updated. + Tietokannan salauksen tunnuslause päivitetään. + No comment provided by engineer. - + Database error + Tietokantavirhe No comment provided by engineer. - + Database is encrypted using a random passphrase, you can change it. + Tietokanta on salattu satunnaisella tunnuslauseella, voit muuttaa sitä. No comment provided by engineer. - + Database is encrypted using a random passphrase. Please change it before exporting. + Tietokanta on salattu satunnaisella tunnuslauseella. Vaihda se ennen vientiä. No comment provided by engineer. - + Database passphrase + Tietokannan tunnuslause No comment provided by engineer. - + Database passphrase & export + Tietokannan tunnuslause ja vienti No comment provided by engineer. - + Database passphrase is different from saved in the keychain. + Tietokannan tunnuslause eroaa avainnippuun tallennetusta. No comment provided by engineer. - + Database passphrase is required to open chat. + Keskustelun avaamiseen tarvitaan tietokannan tunnuslause. No comment provided by engineer. - + Database will be encrypted and the passphrase stored in the keychain. + Tietokanta salataan ja tunnuslause tallennetaan avainnippuun. + No comment provided by engineer. - + Database will be encrypted. + Tietokanta salataan. + No comment provided by engineer. - + Database will be migrated when the app restarts + Tietokanta siirretään, kun sovellus käynnistyy uudelleen No comment provided by engineer. - + Decentralized + Hajautettu No comment provided by engineer. - + Delete + Poista chat item action - + Delete Contact + Poista kontakti No comment provided by engineer. - + Delete address + Poista osoite No comment provided by engineer. - + Delete address? + Poista osoite? No comment provided by engineer. - + Delete after + Poista jälkeen No comment provided by engineer. - + Delete all files + Poista kaikki tiedostot No comment provided by engineer. - + Delete archive + Poista arkisto No comment provided by engineer. - + Delete chat archive? + Poista keskusteluarkisto? No comment provided by engineer. - + Delete chat profile? + Poista keskusteluprofiili? No comment provided by engineer. - + Delete connection + Poista yhteys 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 No comment provided by engineer. - + Delete files and media? + Poista tiedostot ja media? No comment provided by engineer. - + Delete files for all chat profiles + Poista tiedostot kaikista keskusteluprofiileista No comment provided by engineer. - + Delete for everyone + Poista kaikilta chat feature - + Delete for me + Poista minulta No comment provided by engineer. - + Delete group + Poista ryhmä No comment provided by engineer. - + Delete group? + Poista ryhmä? No comment provided by engineer. - + Delete invitation + Poista kutsu No comment provided by engineer. - + Delete link + Poista linkki No comment provided by engineer. - + Delete link? + Poista linkki? No comment provided by engineer. - + Delete member message? + Poista jäsenviesti? No comment provided by engineer. - + Delete message? + Poista viesti? No comment provided by engineer. - + Delete messages + Poista viestit No comment provided by engineer. - + Delete messages after + Poista viestit tämän jälkeen No comment provided by engineer. - + Delete old database + Poista vanha tietokanta No comment provided by engineer. - + Delete old database? + Poista vanha tietokanta? No comment provided by engineer. - + Delete pending connection + Poista vireillä oleva yhteys No comment provided by engineer. - + Delete pending connection? + Poistetaanko odottava yhteys? No comment provided by engineer. - + Delete queue + Poista jono server test step - + Delete user profile? + Poista käyttäjäprofiili? No comment provided by engineer. - + Description + Kuvaus No comment provided by engineer. - + Develop + Kehitä No comment provided by engineer. - + Developer tools + Kehittäjätyökalut No comment provided by engineer. - + Device + Laite No comment provided by engineer. - + Device authentication is disabled. Turning off SimpleX Lock. + Laitteen todennus on poistettu käytöstä. SimpleX Lock kytketään pois päältä. No comment provided by engineer. - + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + Laitteen todennus ei ole käytössä. Voit ottaa SimpleX Lockin käyttöön Asetuksista, kun olet ottanut laitteen todennuksen käyttöön. No comment provided by engineer. - + Different names, avatars and transport isolation. + Eri nimet, avatarit ja kuljetuseristys. No comment provided by engineer. - + Direct messages + Yksityisviestit chat feature - + Direct messages between members are prohibited in this group. + Yksityisviestit jäsenten välillä ovat kiellettyjä tässä ryhmässä. No comment provided by engineer. - + Disable SimpleX Lock + Poista SimpleX Lock käytöstä authentication reason - + Disappearing messages + Tuhoutuvat viestit chat feature - + Disappearing messages are prohibited in this chat. + Katoavat viestit ovat kiellettyjä tässä keskustelussa. No comment provided by engineer. - + Disappearing messages are prohibited in this group. + Katoavat viestit ovat kiellettyjä tässä ryhmässä. No comment provided by engineer. - + Disconnect + Katkaise server test step - + Display name + Näyttönimi No comment provided by engineer. - + Display name: + Näyttönimi: No comment provided by engineer. - + Do NOT use SimpleX for emergency calls. + Älä käytä SimpleX-sovellusta hätäpuheluihin. No comment provided by engineer. - + Do it later + Tee myöhemmin No comment provided by engineer. - + Don't show again + Älä näytä uudelleen No comment provided by engineer. - + Duplicate display name! + Päällekkäinen näyttönimi! No comment provided by engineer. - + Edit + Muokkaa chat item action - + Edit group profile + Muokkaa ryhmäprofiilia No comment provided by engineer. - + Enable + Salli No comment provided by engineer. - + Enable SimpleX Lock + Ota SimpleX Lock käyttöön authentication reason - + Enable TCP keep-alive + Ota TCP-säilytys käyttöön No comment provided by engineer. - + Enable automatic message deletion? + Ota automaattinen viestien poisto käyttöön? No comment provided by engineer. - + Enable instant notifications? + Salli välittömät ilmoitukset? No comment provided by engineer. - + Enable notifications + Salli ilmoitukset No comment provided by engineer. - + Enable periodic notifications? + Salli säännölliset ilmoitukset? No comment provided by engineer. - + Encrypt + Salaa No comment provided by engineer. - + Encrypt database? + Salaa tietokanta? No comment provided by engineer. - + Encrypted database + Salattu tietokanta No comment provided by engineer. - + Encrypted message or another event + Salattu viesti tai muu tapahtuma notification - + Encrypted message: database error + Salattu viesti: tietokantavirhe notification - + Encrypted message: keychain error + Salattu viesti: avainnipun virhe notification - + Encrypted message: no passphrase + Salattu viesti: ei tunnuslausetta notification - + Encrypted message: unexpected error + Salattu viesti: odottamaton virhe notification - + Enter correct passphrase. + Anna oikea tunnuslause. No comment provided by engineer. - + Enter passphrase… + Syötä tunnuslause… No comment provided by engineer. - + Enter password above to show! + Kirjoita yllä oleva salasana näyttääksesi! No comment provided by engineer. - + Enter server manually + Syötä palvelin manuaalisesti No comment provided by engineer. - + Error + Virhe No comment provided by engineer. - + Error accepting contact request + Virhe kontaktipyynnön hyväksymisessä No comment provided by engineer. - + Error accessing database file + Virhe tietokantatiedoston käyttämisessä No comment provided by engineer. - + Error adding member(s) + Virhe lisättäessä jäseniä No comment provided by engineer. - + Error changing address + Virhe osoitteenvaihdossa No comment provided by engineer. - + Error changing role + Virhe roolin vaihdossa No comment provided by engineer. - + Error changing setting + Virhe asetuksen muuttamisessa No comment provided by engineer. - + Error creating address + Virhe osoitteen luomisessa No comment provided by engineer. - + Error creating group + Virhe ryhmän luomisessa No comment provided by engineer. - + Error creating group link + Virhe ryhmälinkin luomisessa No comment provided by engineer. - + Error creating profile! + Virhe profiilin luomisessa! No comment provided by engineer. - + Error deleting chat database + Virhe keskustelujen tietokannan poistamisessa No comment provided by engineer. - + Error deleting chat! + Virhe keskutelun poistamisessa! No comment provided by engineer. - + Error deleting connection + Virhe yhteyden poistamisessa No comment provided by engineer. - + Error deleting contact + Virhe kontaktin poistamisessa No comment provided by engineer. - + Error deleting database + Virhe tietokannan poistamisessa No comment provided by engineer. - + Error deleting old database + Virhe vanhan tietokannan poistamisessa No comment provided by engineer. - + Error deleting token + Virhe tokenin poistamisessa No comment provided by engineer. - + Error deleting user profile + Virhe käyttäjäprofiilin poistamisessa No comment provided by engineer. - + Error enabling notifications + Virhe ilmoitusten käyttöönotossa No comment provided by engineer. - + Error encrypting database + Virhe tietokannan salauksessa No comment provided by engineer. - + Error exporting chat database + Virhe vietäessä keskustelujen tietokantaa No comment provided by engineer. - + Error importing chat database + Virhe keskustelujen tietokannan tuonnissa No comment provided by engineer. - + Error joining group + Virhe ryhmään liittymisessä No comment provided by engineer. - + Error receiving file + Virhe tiedoston vastaanottamisessa No comment provided by engineer. - + Error removing member + Virhe poistettaessa jäsentä No comment provided by engineer. - + Error saving ICE servers + Virhe ICE-palvelimien tallentamisessa No comment provided by engineer. Error saving SMP servers No comment provided by engineer. - + Error saving group profile + Virhe ryhmäprofiilin tallentamisessa No comment provided by engineer. - + Error saving passphrase to keychain + Virhe tunnuslauseen tallentamisessa avainnippuun No comment provided by engineer. - + Error saving user password + Virhe käyttäjän salasanan tallentamisessa No comment provided by engineer. - + Error sending message + Virhe viestin lähettämisessä No comment provided by engineer. - + Error starting chat + Virhe käynnistettäessä keskustelua No comment provided by engineer. - + Error stopping chat + Virhe keskustelun lopettamisessa No comment provided by engineer. - + Error switching profile! + Virhe profiilin vaihdossa! No comment provided by engineer. - + Error updating group link + Virhe ryhmälinkin päivittämisessä No comment provided by engineer. - + Error updating message + Virhe viestin päivityksessä No comment provided by engineer. - + Error updating settings + Virhe asetusten päivittämisessä No comment provided by engineer. - + Error updating user privacy + Virhe päivitettäessä käyttäjän tietosuojaa No comment provided by engineer. - + Error: %@ + Virhe: %@ No comment provided by engineer. - + Error: URL is invalid + Virhe: URL on virheellinen No comment provided by engineer. - + Error: no database file + Virhe: ei tietokantatiedostoa No comment provided by engineer. - + Exit without saving + Poistu tallentamatta No comment provided by engineer. - + Export database + Vie tietokanta No comment provided by engineer. - + Export error: + Vientivirhe: No comment provided by engineer. - + Exported database archive. + Viety tietokanta-arkisto. No comment provided by engineer. Exporting database archive... No comment provided by engineer. - + Failed to remove passphrase + Tunnuslauseen poisto epäonnistui No comment provided by engineer. - + File will be received when your contact is online, please wait or check later! + Tiedosto vastaanotetaan, kun kontakti on online-tilassa, odota tai tarkista myöhemmin! No comment provided by engineer. - + File: %@ + Tiedosto: %@ No comment provided by engineer. - + Files & media + Tiedostot & media No comment provided by engineer. - + For console + Konsoliin No comment provided by engineer. - + French interface + Ranskalainen käyttöliittymä No comment provided by engineer. - + Full link + Koko linkki No comment provided by engineer. - + Full name (optional) + Koko nimi (valinnainen) No comment provided by engineer. - + Full name: + Koko nimi: No comment provided by engineer. - + Fully re-implemented - work in background! + Täysin uudistettu - toimii taustalla! No comment provided by engineer. - + Further reduced battery usage + Entistä pienempi akun käyttö No comment provided by engineer. - + GIFs and stickers + GIFit ja tarrat No comment provided by engineer. - + Group + Ryhmä No comment provided by engineer. - + Group display name + Ryhmän näyttönimi No comment provided by engineer. - + Group full name (optional) + Ryhmän näyttönimi (valinnainen) No comment provided by engineer. - + Group image + Ryhmäkuva No comment provided by engineer. - + Group invitation + Ryhmän kutsu No comment provided by engineer. - + Group invitation expired + Vanhentunut ryhmäkutsu No comment provided by engineer. - + Group invitation is no longer valid, it was removed by sender. + Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen. No comment provided by engineer. - + Group link + Ryhmälinkki No comment provided by engineer. - + Group links + Ryhmälinkit No comment provided by engineer. - + Group members can irreversibly delete sent messages. + Ryhmän jäsenet voivat poistaa lähetetyt viestit peruuttamattomasti. No comment provided by engineer. - + Group members can send direct messages. + Ryhmän jäsenet voivat lähettää suoraviestejä. No comment provided by engineer. - + Group members can send disappearing messages. + Ryhmän jäsenet voivat lähettää katoavia viestejä. No comment provided by engineer. - + Group members can send voice messages. + Ryhmän jäsenet voivat lähettää ääniviestejä. No comment provided by engineer. - + Group message: + Ryhmäviesti: notification - + Group moderation + Ryhmän moderointi No comment provided by engineer. - + Group preferences + Ryhmän asetukset No comment provided by engineer. - + Group profile + Ryhmäprofiili No comment provided by engineer. - + Group profile is stored on members' devices, not on the servers. + Ryhmäprofiili tallennetaan jäsenten laitteille, ei palvelimille. No comment provided by engineer. - + Group welcome message + Ryhmän tervetuloviesti No comment provided by engineer. - + Group will be deleted for all members - this cannot be undone! + Ryhmä poistetaan kaikilta jäseniltä - tätä ei voi kumota! No comment provided by engineer. - + Group will be deleted for you - this cannot be undone! + Ryhmä poistetaan sinulta - tätä ei voi perua! No comment provided by engineer. - + Help + Apua No comment provided by engineer. - + Hidden + Piilotettu No comment provided by engineer. - + Hidden chat profiles + Piilotetut keskusteluprofiilit No comment provided by engineer. - + Hidden profile password + Piilotettu profiilin salasana No comment provided by engineer. - + Hide + Piilota chat item action - + Hide app screen in the recent apps. + Piilota sovellusnäyttö viimeisimmissä sovelluksissa. No comment provided by engineer. - + Hide profile + Piilota profiili No comment provided by engineer. - + How SimpleX works + Miten SimpleX toimii No comment provided by engineer. - + How it works + Kuinka se toimii No comment provided by engineer. - + How to + Miten No comment provided by engineer. - + How to use it + Kuinka sitä käytetään No comment provided by engineer. - + How to use your servers + Miten käytät palvelimiasi No comment provided by engineer. - + ICE servers (one per line) + ICE-palvelimet (yksi per rivi) No comment provided by engineer. If you can't meet in person, **show QR code in the video call**, or share the link. No comment provided by engineer. - + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. + Jos et voi tavata henkilökohtaisesti, voit **skannata QR-koodin videopuhelussa** tai kontaktisi voi jakaa kutsulinkin. No comment provided by engineer. - + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). + Jos haluat käyttää keskustelua nyt, napauta **Tee se myöhemmin** alla (sinulle tarjotaan tietokannan siirtämistä, kun käynnistät sovelluksen uudelleen). No comment provided by engineer. - + Ignore + Sivuuta No comment provided by engineer. - + Image will be received when your contact is online, please wait or check later! + Kuva vastaanotetaan, kun kontaktisi on verkossa, odota tai tarkista myöhemmin! No comment provided by engineer. - + Immune to spam and abuse + Immuuni roskapostille ja väärinkäytöksille No comment provided by engineer. - + Import + Tuo No comment provided by engineer. - + Import chat database? + Tuo keskustelujen-tietokanta? No comment provided by engineer. - + Import database + Tuo tietokanta No comment provided by engineer. - + Improved privacy and security + Parannettu yksityisyys ja turvallisuus No comment provided by engineer. - + Improved server configuration + Parannettu palvelimen kokoonpano No comment provided by engineer. - + Incognito + Incognito No comment provided by engineer. - + Incognito mode + Incognito-tila No comment provided by engineer. @@ -1749,73 +1946,91 @@ Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created. No comment provided by engineer. - + Incoming audio call + Saapuva äänipuhelu notification - + Incoming call + Saapuva puhelu notification - + Incoming video call + Saapuva videopuhelu notification - + Incorrect security code! + Väärä turvakoodi! No comment provided by engineer. - + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) + Asenna [SimpleX Chat terminaalille](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. - + Instant push notifications will be hidden! + Välittömät push-ilmoitukset ovat piilossa! + No comment provided by engineer. - + Instantly + Heti No comment provided by engineer. - + Interface + Käyttöliittymä No comment provided by engineer. - + Invalid connection link + Virheellinen yhteyslinkki No comment provided by engineer. - + Invalid server address! + Virheellinen palvelinosoite! No comment provided by engineer. - + Invitation expired! + Vanhentunut kutsu! No comment provided by engineer. - + Invite members + Kutsu jäseniä No comment provided by engineer. - + Invite to group + Kutsu ryhmään No comment provided by engineer. - + Irreversible message deletion + Peruuttamaton viestin poisto No comment provided by engineer. - + Irreversible message deletion is prohibited in this chat. + Viestien peruuttamaton poisto on kielletty tässä keskustelussa. No comment provided by engineer. - + Irreversible message deletion is prohibited in this group. + Viestien peruuttamaton poisto on kielletty tässä ryhmässä. No comment provided by engineer. - + It allows having many anonymous connections without any shared data between them in a single chat profile. + Se mahdollistaa useiden nimettömien yhteyksien muodostamisen yhdessä keskusteluprofiilissa ilman, että niiden välillä on jaettuja tietoja. No comment provided by engineer. @@ -1827,1400 +2042,1744 @@ Please connect to the developers via Settings to receive the updates about the s We will be adding server redundancy to prevent lost messages. 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 (%@). + Näyttäisi, että olet jo yhteydessä tämän linkin kautta. Jos näin ei ole, tapahtui virhe (%@). No comment provided by engineer. - + Italian interface + Italialainen käyttöliittymä No comment provided by engineer. - + Join + Liity No comment provided by engineer. - + Join group + Liity ryhmään No comment provided by engineer. - + Join incognito + Liity incognito-tilassa No comment provided by engineer. - + Joining group + Liittyy ryhmään No comment provided by engineer. - + Keychain error + Avainnipun virhe No comment provided by engineer. - + LIVE + LIVE No comment provided by engineer. - + Large file! + Suuri tiedosto! No comment provided by engineer. - + Leave + Poistu No comment provided by engineer. - + Leave group + Poistu ryhmästä No comment provided by engineer. - + Leave group? + Poistu ryhmästä? No comment provided by engineer. - + Light + Vaalea No comment provided by engineer. - + Limitations + Rajoitukset No comment provided by engineer. - + Live message! + Live-viesti! No comment provided by engineer. - + Live messages + Live-viestit No comment provided by engineer. - + Local name + Paikallinen nimi No comment provided by engineer. - + Local profile data only + Vain paikalliset profiilitiedot No comment provided by engineer. - + Make a private connection + Luo yksityinen yhteys No comment provided by engineer. - + Make profile private! + Tee profiilista yksityinen! No comment provided by engineer. Make sure SMP server addresses are in correct format, line separated and are not duplicated (%@). No comment provided by engineer. - + Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated. + Varmista, että WebRTC ICE -palvelinosoitteet ovat oikeassa muodossa, rivieroteltuina ja että ne eivät ole päällekkäisiä. No comment provided by engineer. - + Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* + Monet ihmiset kysyivät: *Jos SimpleX:llä ei ole käyttäjätunnuksia, miten se voi toimittaa viestejä?* No comment provided by engineer. - + Mark deleted for everyone + Merkitse poistetuksi kaikilta No comment provided by engineer. - + Mark read + Merkitse luetuksi No comment provided by engineer. - + Mark verified + Merkitse vahvistetuksi No comment provided by engineer. - + Markdown in messages + Markdown viesteissä No comment provided by engineer. - + Max 30 seconds, received instantly. + Enintään 30 sekuntia, vastaanotetaan välittömästi. No comment provided by engineer. - + Member + Jäsen No comment provided by engineer. - + Member role will be changed to "%@". All group members will be notified. + Jäsenen rooli muuttuu muotoon "%@". Kaikille ryhmän jäsenille ilmoitetaan asiasta. No comment provided by engineer. - + Member role will be changed to "%@". The member will receive a new invitation. + Jäsenen rooli muutetaan muotoon "%@". Jäsen saa uuden kutsun. No comment provided by engineer. - + Member will be removed from group - this cannot be undone! + Jäsen poistetaan ryhmästä - tätä ei voi perua! No comment provided by engineer. - + Message delivery error + Viestin toimitusvirhe No comment provided by engineer. - + Message draft + Viestiluonnos No comment provided by engineer. - + Message text + Viestin teksti No comment provided by engineer. - + Messages + Viestit No comment provided by engineer. Migrating database archive... No comment provided by engineer. - + Migration error: + Siirtovirhe: No comment provided by engineer. - + Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). + Siirto epäonnistui. Jatka nykyisen tietokannan käyttöä napauttamalla alla **Poistu**. Ilmoita ongelmasta sovelluskehittäjille keskustelussa tai sähköpostitse [chat@simplex.chat](mailto:chat@simplex.chat). No comment provided by engineer. - + Migration is completed + Siirto on valmis No comment provided by engineer. - + Moderate + Moderoi chat item action - + More improvements are coming soon! + Lisää parannuksia on tulossa pian! No comment provided by engineer. - + Most likely this contact has deleted the connection with you. + Todennäköisesti tämä kontakti on poistanut yhteyden sinuun. No comment provided by engineer. - + Multiple chat profiles + Useita keskusteluprofiileja No comment provided by engineer. - + Mute + Mykistä No comment provided by engineer. - + Muted when inactive! + Mykistetty ei-aktiivisena! No comment provided by engineer. - + Name + Nimi No comment provided by engineer. - + Network & servers + Verkko ja palvelimet No comment provided by engineer. - + Network settings + Verkkoasetukset No comment provided by engineer. - + Network status + Verkon tila No comment provided by engineer. - + New contact request + Uusi kontaktipyyntö notification - + New contact: + Uusi kontakti: notification - + New database archive + Uusi tietokanta-arkisto No comment provided by engineer. - + New in %@ + Uutta %@ No comment provided by engineer. - + New member role + Uusi jäsenrooli No comment provided by engineer. - + New message + Uusi viesti notification - + New passphrase… + Uusi tunnuslause… No comment provided by engineer. - + No + Ei No comment provided by engineer. - + No contacts selected + Kontakteja ei ole valittu No comment provided by engineer. - + No contacts to add + Ei lisättäviä kontakteja No comment provided by engineer. - + No device token! + Ei laitetunnusta! No comment provided by engineer. - + Group not found! + Ryhmää ei löydy! No comment provided by engineer. - + No permission to record voice message + Ei lupaa ääniviestin tallentamiseen No comment provided by engineer. - + No received or sent files + Ei vastaanotettuja tai lähetettyjä tiedostoja No comment provided by engineer. - + Notifications + Ilmoitukset No comment provided by engineer. - + Notifications are disabled! + Ilmoitukset on poistettu käytöstä! No comment provided by engineer. - + Now admins can: - delete members' messages. - disable members ("observer" role) + Nyt järjestelmänvalvojat voivat: +- poistaa jäsenten viestit. +- poista jäsenet käytöstä ("tarkkailija" rooli) No comment provided by engineer. - + Off (Local) + Pois (Paikallinen) No comment provided by engineer. - + Ok + Ok No comment provided by engineer. - + Old database + Vanha tietokanta No comment provided by engineer. - + Old database archive + Vanha tietokanta-arkisto No comment provided by engineer. - + One-time invitation link + Kertakutsulinkki No comment provided by engineer. - + Onion hosts will be required for connection. Requires enabling VPN. + Yhteyden muodostamiseen tarvitaan Onion-isäntiä. Edellyttää VPN:n sallimista. No comment provided by engineer. - + Onion hosts will be used when available. Requires enabling VPN. + Onion-isäntiä käytetään, kun niitä on saatavilla. Edellyttää VPN:n sallimista. No comment provided by engineer. - + Onion hosts will not be used. + Onion-isäntiä ei käytetä. No comment provided by engineer. - + Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. + Vain asiakaslaitteet tallentavat käyttäjäprofiileja, yhteystietoja, ryhmiä ja viestejä, jotka on lähetetty **kaksinkertaisella päästä päähän -salauksella**. No comment provided by engineer. - + Only group owners can change group preferences. + Vain ryhmän omistajat voivat muuttaa ryhmän asetuksia. No comment provided by engineer. - + Only group owners can enable voice messages. + Vain ryhmän omistajat voivat ottaa ääniviestit käyttöön. No comment provided by engineer. - + Only you can irreversibly delete messages (your contact can mark them for deletion). + Vain sinä voit poistaa viestejä peruuttamattomasti (kontaktisi voi merkitä ne poistettavaksi). No comment provided by engineer. - + Only you can send disappearing messages. + Vain sinä voit lähettää katoavia viestejä. No comment provided by engineer. - + Only you can send voice messages. + Vain sinä voit lähettää ääniviestejä. No comment provided by engineer. - + Only your contact can irreversibly delete messages (you can mark them for deletion). + Vain kontaktisi voi poistaa viestejä peruuttamattomasti (voit merkitä ne poistettavaksi). No comment provided by engineer. - + Only your contact can send disappearing messages. + Vain kontaktisi voi lähettää katoavia viestejä. No comment provided by engineer. - + Only your contact can send voice messages. + Vain kontaktisi voi lähettää ääniviestejä. No comment provided by engineer. - + Open Settings + Avaa Asetukset No comment provided by engineer. - + Open chat + Avaa keskustelu No comment provided by engineer. - + Open chat console + Avaa keskustelukonsoli authentication reason - + Open user profiles + Avaa käyttäjäprofiilit authentication reason - + Open-source protocol and code – anybody can run the servers. + Avoimen lähdekoodin protokolla ja koodi - kuka tahansa voi käyttää palvelimia. 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ä No comment provided by engineer. - + PING interval + PING-väli No comment provided by engineer. - + Password to show + Salasana näytettäväksi No comment provided by engineer. - + Paste + Liitä No comment provided by engineer. - + Paste image + Liitä kuva No comment provided by engineer. - + Paste received link + Liitä vastaanotettu linkki No comment provided by engineer. Paste the link you received into the box below to connect with your contact. No comment provided by engineer. - + People can connect to you only via the links you share. + Ihmiset voivat ottaa sinuun yhteyttä vain jakamiesi linkkien kautta. No comment provided by engineer. - + Periodically + Ajoittain No comment provided by engineer. - + Please ask your contact to enable sending voice messages. + Pyydä kontaktiasi sallimaan ääniviestien lähettäminen. No comment provided by engineer. - + Please check that you used the correct link or ask your contact to send you another one. + Tarkista, että käytit oikeaa linkkiä tai pyydä kontaktiasi lähettämään sinulle uusi linkki. No comment provided by engineer. - + Please check your network connection with %@ and try again. + Tarkista verkkoyhteytesi %@:lla ja yritä uudelleen. No comment provided by engineer. - + Please check yours and your contact preferences. + Tarkista omasi ja kontaktin asetukset. No comment provided by engineer. - + Please contact group admin. + Ota yhteyttä ryhmän ylläpitäjään. No comment provided by engineer. - + Please enter correct current passphrase. + Anna oikea nykyinen tunnuslause. No comment provided by engineer. - + Please enter the previous password after restoring database backup. This action can not be undone. + Anna edellinen salasana tietokannan varmuuskopion palauttamisen jälkeen. Tätä toimintoa ei voi kumota. No comment provided by engineer. - + Please restart the app and migrate the database to enable push notifications. + Käynnistä sovellus uudelleen ja siirrä tietokanta push-ilmoitusten ottamiseksi käyttöön. No comment provided by engineer. - + Please store passphrase securely, you will NOT be able to access chat if you lose it. + Säilytä tunnuslause turvallisesti, ET pääse keskusteluihin, jos kadotat sen. No comment provided by engineer. - + Please store passphrase securely, you will NOT be able to change it if you lose it. + Säilytä tunnuslause turvallisesti, ET voi muuttaa sitä, jos kadotat sen. No comment provided by engineer. - + Possibly, certificate fingerprint in server address is incorrect + Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen server test error - + Preserve the last message draft, with attachments. + Säilytä viimeinen viestiluonnos liitteineen. No comment provided by engineer. - + Preset server + Esiasetettu palvelin No comment provided by engineer. - + Preset server address + Esiasetettu palvelimen osoite No comment provided by engineer. - + Privacy & security + Yksityisyys ja turvallisuus No comment provided by engineer. - + Privacy redefined + Yksityisyys uudelleen määritettynä No comment provided by engineer. - + Private filenames + Yksityiset tiedostonimet No comment provided by engineer. - + Profile and server connections + Profiili- ja palvelinyhteydet No comment provided by engineer. - + Profile image + Profiilikuva No comment provided by engineer. - + Prohibit irreversible message deletion. + Estä peruuttamaton viestien poistaminen. No comment provided by engineer. - + Prohibit sending direct messages to members. + Estä suorien viestien lähettäminen jäsenille. No comment provided by engineer. - + Prohibit sending disappearing messages. + Estä katoavien viestien lähettäminen. No comment provided by engineer. - + Prohibit sending voice messages. + Estä ääniviestien lähettäminen. No comment provided by engineer. - + Protect app screen + Suojaa sovellusnäyttö No comment provided by engineer. - + Protect your chat profiles with a password! + Suojaa keskusteluprofiilisi salasanalla! No comment provided by engineer. - + Protocol timeout + Protokollan aikakatkaisu No comment provided by engineer. - + Push notifications + Push-ilmoitukset No comment provided by engineer. - + Rate the app + Arvioi sovellus No comment provided by engineer. - + Read + Lue No comment provided by engineer. - + Read more in our GitHub repository. + Lue lisää GitHub-tietovarastostamme. No comment provided by engineer. - + Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). + Lue lisää [GitHub-arkistosta](https://github.com/simplex-chat/simplex-chat#readme). No comment provided by engineer. - + Received file event + Tiedoston vastaanottotapahtuma notification - + Receiving via + Vastaanotto kautta No comment provided by engineer. - + Recipients see updates as you type them. + Vastaanottajat näkevät päivitykset, kun kirjoitat niitä. No comment provided by engineer. - + Reduced battery usage + Pienempi akun käyttö No comment provided by engineer. - + Reject + Hylkää reject incoming call via notification Reject contact (sender NOT notified) No comment provided by engineer. - + Reject contact request + Hylkää yhteyspyyntö No comment provided by engineer. - + Relay server is only used if necessary. Another party can observe your IP address. + Välityspalvelinta käytetään vain tarvittaessa. Toinen osapuoli voi tarkkailla IP-osoitettasi. No comment provided by engineer. - + Relay server protects your IP address, but it can observe the duration of the call. + Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa. No comment provided by engineer. - + Remove + Poista No comment provided by engineer. - + Remove member + Poista jäsen No comment provided by engineer. - + Remove member? + Poista jäsen? No comment provided by engineer. - + Remove passphrase from keychain? + Poista tunnuslause avainnipusta? No comment provided by engineer. - + Reply + Vastaa chat item action - + Required + Pakollinen No comment provided by engineer. - + Reset + Oletustilaan No comment provided by engineer. - + Reset colors + Oletusvärit No comment provided by engineer. - + Reset to defaults + Palauta oletusasetukset No comment provided by engineer. - + Restart the app to create a new chat profile + Käynnistä sovellus uudelleen uuden keskusteluprofiilin luomiseksi No comment provided by engineer. - + Restart the app to use imported chat database + Käynnistä sovellus uudelleen käyttääksesi tuotua keskustelujen-tietokantaa No comment provided by engineer. - + Restore + Palauta No comment provided by engineer. - + Restore database backup + Palauta tietokannan varmuuskopio No comment provided by engineer. - + Restore database backup? + Palauta tietokannan varmuuskopio? No comment provided by engineer. - + Restore database error + Virhe tietokannan palauttamisessa No comment provided by engineer. - + Reveal + Paljasta chat item action - + Revert + Palauta No comment provided by engineer. - + Role + Rooli No comment provided by engineer. - + Run chat + Käynnistä chat No comment provided by engineer. - + SMP servers + SMP-palvelimet No comment provided by engineer. - + Save + Tallenna chat item action - + Save (and notify contacts) + Tallenna (ja ilmoita kontakteille) No comment provided by engineer. - + Save and notify contact + Tallenna ja ilmoita kontaktille No comment provided by engineer. - + Save and notify group members + Tallenna ja ilmoita ryhmän jäsenille No comment provided by engineer. - + Save and update group profile + Tallenna ja päivitä ryhmäprofiili No comment provided by engineer. - + Save archive + Tallenna arkisto No comment provided by engineer. - + Save group profile + Tallenna ryhmäprofiili No comment provided by engineer. - + Save passphrase and open chat + Tallenna tunnuslause ja avaa keskustelu No comment provided by engineer. - + Save passphrase in Keychain + Tallenna tunnuslause Avainnippuun No comment provided by engineer. - + Save preferences? + Tallenna asetukset? No comment provided by engineer. - + Save profile password + Tallenna profiilin salasana No comment provided by engineer. - + Save servers + Tallenna palvelimet No comment provided by engineer. - + Save servers? + Tallenna palvelimet? No comment provided by engineer. - + Save welcome message? + Tallenna tervetuloviesti? No comment provided by engineer. - + Saved WebRTC ICE servers will be removed + Tallennetut WebRTC ICE -palvelimet poistetaan No comment provided by engineer. - + Scan QR code + Skannaa QR-koodi No comment provided by engineer. - + Scan code + Skannaa koodi No comment provided by engineer. - + Scan security code from your contact's app. + Skannaa turvakoodi kontaktisi sovelluksesta. No comment provided by engineer. - + Scan server QR code + Skannaa palvelimen QR-koodi No comment provided by engineer. - + Search + Haku No comment provided by engineer. - + Secure queue + Turvallinen jono server test step - + Security assessment + Turvallisuusarviointi No comment provided by engineer. - + Security code + Turvakoodi No comment provided by engineer. - + Send + Lähetä No comment provided by engineer. - + Send a live message - it will update for the recipient(s) as you type it + Lähetä live-viesti - se päivittyy vastaanottajille, kun kirjoitat sitä No comment provided by engineer. - + Send direct message + Lähetä yksityisviesti No comment provided by engineer. - + Send link previews + Lähetä linkkien esikatselu No comment provided by engineer. - + Send live message + Lähetä live-viesti No comment provided by engineer. - + Send notifications + Lähetys ilmoitukset No comment provided by engineer. - + Send notifications: + Lähetys ilmoitukset: No comment provided by engineer. - + Send questions and ideas + Lähetä kysymyksiä ja ideoita No comment provided by engineer. - + Send them from gallery or custom keyboards. + Lähetä ne galleriasta tai mukautetuista näppäimistöistä. No comment provided by engineer. - + Sender cancelled file transfer. + Lähettäjä peruutti tiedoston siirron. No comment provided by engineer. - + Sender may have deleted the connection request. + Lähettäjä on saattanut poistaa yhteyspyynnön. No comment provided by engineer. - + Sending via + Lähetetään kautta No comment provided by engineer. - + Sent file event + Lähetetty tiedosto tapahtuma notification - + Sent messages will be deleted after set time. + Lähetetyt viestit poistetaan asetetun ajan kuluttua. No comment provided by engineer. - + Server requires authorization to create queues, check password + Palvelin vaatii valtuutuksen jonojen luomiseen, tarkista salasana server test error - + Server test failed! + Palvelintesti epäonnistui! No comment provided by engineer. - + Servers + Palvelimet No comment provided by engineer. - + Set 1 day + Aseta 1 päivä No comment provided by engineer. - + Set contact name… + Aseta kontaktin nimi… No comment provided by engineer. - + Set group preferences + Aseta ryhmän asetukset No comment provided by engineer. - + Set passphrase to export + Aseta tunnuslause vientiä varten No comment provided by engineer. - + Set the message shown to new members! + Aseta uusille jäsenille näytettävä viesti! No comment provided by engineer. - + Set timeouts for proxy/VPN + Aseta aikakatkaisut välityspalvelimelle/VPN:lle No comment provided by engineer. - + Settings + Asetukset No comment provided by engineer. - + Share + Jaa chat item action Share invitation link No comment provided by engineer. - + Share link + Jaa linkki No comment provided by engineer. - + Share one-time invitation link + Jaa kertakutsulinkki No comment provided by engineer. Show QR code No comment provided by engineer. - + Show calls in phone history + Näytä puhelut puhelinhistoriassa No comment provided by engineer. - + Show preview + Näytä esikatselu No comment provided by engineer. - + SimpleX Chat security was audited by Trail of Bits. + Trail of Bits on tarkastanut SimpleX Chatin tietoturvan. No comment provided by engineer. - + SimpleX Lock + SimpleX Lock No comment provided by engineer. - + SimpleX Lock turned on + SimpleX Lock päällä No comment provided by engineer. - + SimpleX contact address + SimpleX-yhteystiedot simplex link type - + SimpleX encrypted message or connection event + SimpleX-salattu viesti tai yhteystapahtuma notification - + SimpleX group link + SimpleX-ryhmän linkki simplex link type - + SimpleX links + SimpleX-linkit No comment provided by engineer. - + SimpleX one-time invitation + SimpleX-kertakutsu simplex link type - + Skip + Ohita No comment provided by engineer. - + Skipped messages + Ohitetut viestit No comment provided by engineer. - + Somebody + Joku notification title - + Start a new chat + Aloita uusi keskustelu No comment provided by engineer. - + Start chat + Aloita keskustelu No comment provided by engineer. - + Start migration + Aloita siirto No comment provided by engineer. - + Stop + Lopeta No comment provided by engineer. - + Stop SimpleX + Lopeta SimpleX authentication reason - + Stop chat to enable database actions + Pysäytä keskustelu tietokantatoimien mahdollistamiseksi No comment provided by engineer. - + Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. + Pysäytä keskustelut viedäksesi, tuodaksesi tai poistaaksesi keskustelujen tietokannan. Et voi vastaanottaa ja lähettää viestejä, kun keskustelut on pysäytetty. No comment provided by engineer. - + Stop chat? + Lopeta keskustelu? No comment provided by engineer. - + Support SimpleX Chat + SimpleX Chat tuki No comment provided by engineer. - + System + Järjestelmä No comment provided by engineer. - + TCP connection timeout + TCP-yhteyden aikakatkaisu No comment provided by engineer. - + TCP_KEEPCNT + TCP_KEEPCNT No comment provided by engineer. - + TCP_KEEPIDLE + TCP_KEEPIDLE No comment provided by engineer. - + TCP_KEEPINTVL + TCP_KEEPINTVL No comment provided by engineer. - + Take picture + Ota kuva No comment provided by engineer. - + Tap button + Napauta painiketta No comment provided by engineer. - + Tap to activate profile. + Aktivoi profiili napauttamalla. No comment provided by engineer. - + Tap to join + Liity napauttamalla No comment provided by engineer. - + Tap to join incognito + Napauta liittyäksesi incognito-tilassa No comment provided by engineer. - + Tap to start a new chat + Aloita uusi keskustelu napauttamalla No comment provided by engineer. - + Test failed at step %@. + Testi epäonnistui vaiheessa %@. server test failure - + Test server + Testipalvelin No comment provided by engineer. - + Test servers + Testipalvelimet No comment provided by engineer. - + Tests failed! + Testit epäonnistuivat! No comment provided by engineer. - + Thank you for installing SimpleX Chat! + Kiitos SimpleX Chatin asentamisesta! No comment provided by engineer. - + Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + Kiitos käyttäjille - [osallistu Weblaten avulla](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. - + Thanks to the users – contribute via Weblate! + Kiitokset käyttäjille – osallistu Weblaten kautta! No comment provided by engineer. - + The 1st platform without any user identifiers – private by design. + Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi. No comment provided by engineer. - + The app can notify you when you receive messages or contact requests - please open settings to enable. + Sovellus voi ilmoittaa sinulle, kun saat viestejä tai yhteydenottopyyntöjä - avaa asetukset ottaaksesi ne käyttöön. No comment provided by engineer. - + The attempt to change database passphrase was not completed. + Tietokannan tunnuslauseen muuttamista ei suoritettu loppuun. No comment provided by engineer. - + The connection you accepted will be cancelled! + Hyväksymäsi yhteys peruuntuu! No comment provided by engineer. - + The contact you shared this link with will NOT be able to connect! + Kontakti, jolle jaoit tämän linkin, EI voi muodostaa yhteyttä! No comment provided by engineer. - + The created archive is available via app Settings / Database / Old database archive. + Luotu arkisto on käytettävissä sovelluksen Asetukset / Tietokanta / Vanha tietokanta-arkisto kautta. 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 message will be deleted for all members. + Viesti poistetaan kaikilta jäseniltä. No comment provided by engineer. - + The message will be marked as moderated for all members. + Viesti merkitään moderoiduksi kaikille jäsenille. No comment provided by engineer. - + The next generation of private messaging + Seuraavan sukupolven yksityisviestit No comment provided by engineer. - + The old database was not removed during the migration, it can be deleted. + Vanhaa tietokantaa ei poistettu siirron aikana, se voidaan kuitenkin poistaa. No comment provided by engineer. - + The profile is only shared with your contacts. + Profiili jaetaan vain kontaktiesi kanssa. No comment provided by engineer. - + The sender will NOT be notified + Lähettäjälle EI ilmoiteta No comment provided by engineer. - + The servers for new connections of your current chat profile **%@**. + Palvelimet nykyisen keskusteluprofiilisi uusille yhteyksille **%@**. No comment provided by engineer. - + Theme + Teema No comment provided by engineer. - + There should be at least one user profile. + Käyttäjäprofiileja tulee olla vähintään yksi. No comment provided by engineer. - + There should be at least one visible user profile. + Näkyviä käyttäjäprofiileja tulee olla vähintään yksi. No comment provided by engineer. - + This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. + Tätä toimintoa ei voi kumota - kaikki vastaanotetut ja lähetetyt tiedostot ja media poistetaan. Matalan resoluution kuvat säilyvät. No comment provided by engineer. - + This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. + Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja. No comment provided by engineer. - + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. + Tätä toimintoa ei voi kumota - profiilisi, kontaktisi, viestisi ja tiedostosi poistuvat peruuttamattomasti. No comment provided by engineer. This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member). No comment provided by engineer. - + This group no longer exists. + Tätä ryhmää ei enää ole olemassa. No comment provided by engineer. - + This setting applies to messages in your current chat profile **%@**. + Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä *%@**. No comment provided by engineer. - + To ask any questions and to receive updates: + Voit esittää kysymyksiä ja saada päivityksiä: No comment provided by engineer. To find the profile used for an incognito connection, tap the contact or group name on top of the chat. No comment provided by engineer. - + To make a new connection + Uuden yhteyden luominen No comment provided by engineer. - + To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. + Yksityisyyden suojaamiseksi kaikkien muiden alustojen käyttämien käyttäjätunnusten sijaan SimpleX käyttää viestijonojen tunnisteita, jotka ovat kaikille kontakteille erillisiä. No comment provided by engineer. - + To protect timezone, image/voice files use UTC. + Aikavyöhykkeen suojaamiseksi kuva-/äänitiedostot käyttävät UTC:tä. No comment provided by engineer. - + To protect your information, turn on SimpleX Lock. You will be prompted to complete authentication before this feature is enabled. + Suojaa tietosi ottamalla SimpleX Lock käyttöön. +Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus otetaan käyttöön. No comment provided by engineer. - + To record voice message please grant permission to use Microphone. + Jos haluat nauhoittaa ääniviestin, anna lupa käyttää mikrofonia. No comment provided by engineer. - + To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. + Voit paljastaa piilotetun profiilisi syöttämällä koko salasanan hakukenttään **Keskusteluprofiilisi** -sivulla. No comment provided by engineer. - + To support instant push notifications the chat database has to be migrated. + Keskustelujen-tietokanta on siirrettävä välittömien push-ilmoitusten tukemiseksi. No comment provided by engineer. - + To verify end-to-end encryption with your contact compare (or scan) the code on your devices. + Voit tarkistaa päästä päähän -salauksen kontaktisi kanssa vertaamalla (tai skannaamalla) laitteidenne koodia. No comment provided by engineer. - + Transport isolation + Kuljetuksen eristäminen No comment provided by engineer. - + Trying to connect to the server used to receive messages from this contact (error: %@). + Yritetään muodostaa yhteyttä palvelimeen, jota käytetään tämän kontaktin viestien vastaanottamiseen (virhe: %@). No comment provided by engineer. - + Trying to connect to the server used to receive messages from this contact. + Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta. No comment provided by engineer. - + Turn off + Sammuta No comment provided by engineer. - + Turn off notifications? + Kytke ilmoitukset pois päältä? No comment provided by engineer. - + Turn on + Kytke päälle No comment provided by engineer. - + Unable to record voice message + Ääniviestiä ei voi tallentaa No comment provided by engineer. - + Unexpected error: %@ + Odottamaton virhe: %@ No comment provided by engineer. - + Unexpected migration state + Odottamaton siirtotila No comment provided by engineer. - + Unhide + Näytä No comment provided by engineer. - + Unknown caller + Tuntematon soittaja callkit banner - + Unknown database error: %@ + Tuntematon tietokantavirhe: %@ No comment provided by engineer. - + Unknown error + Tuntematon virhe No comment provided by engineer. - + Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions. + Ellet käytä iOS:n puhelinkäyttöliittymää, ota Älä häiritse -tila käyttöön keskeytysten välttämiseksi. 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. To connect, please ask your contact to create another connection link and check that you have a stable network connection. + Ellei yhteyshenkilösi poistanut yhteyttä tai tämä linkki oli jo käytössä, se voi olla virhe - ilmoita siitä. +Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja tarkista, että verkkoyhteytesi on vakaa. No comment provided by engineer. - + Unlock + Avaa authentication reason - + Unmute + Poista mykistys No comment provided by engineer. - + Unread + Lukematon No comment provided by engineer. - + Update + Päivitä No comment provided by engineer. - + Update .onion hosts setting? + Päivitä .onion-isäntien asetus? No comment provided by engineer. - + Update database passphrase + Päivitä tietokannan tunnuslause No comment provided by engineer. - + Update network settings? + Päivitä verkkoasetukset? No comment provided by engineer. - + Update transport isolation mode? + Päivitä kuljetuksen eristystila? No comment provided by engineer. - + Updating settings will re-connect the client to all servers. + Asetusten päivittäminen yhdistää asiakkaan uudelleen kaikkiin palvelimiin. No comment provided by engineer. - + Updating this setting will re-connect the client to all servers. + Tämän asetuksen päivittäminen yhdistää asiakkaan uudelleen kaikkiin palvelimiin. No comment provided by engineer. - + Use .onion hosts + Käytä .onion-isäntiä No comment provided by engineer. - + Use SimpleX Chat servers? + Käytä SimpleX Chat palvelimia? No comment provided by engineer. - + Use chat + Käytä chattia No comment provided by engineer. - + Use for new connections + Käytä uusiin yhteyksiin No comment provided by engineer. - + Use iOS call interface + Käytä iOS:n puhelujen käyttöliittymää No comment provided by engineer. - + Use server + Käytä palvelinta No comment provided by engineer. - + User profile + Käyttäjäprofiili No comment provided by engineer. - + Using .onion hosts requires compatible VPN provider. + .onion-isäntien käyttäminen vaatii yhteensopivan VPN-palveluntarjoajan. No comment provided by engineer. - + Using SimpleX Chat servers. + Käyttää SimpleX Chat -palvelimia. No comment provided by engineer. - + Verify connection security + Tarkista yhteyden suojaus No comment provided by engineer. - + Verify security code + Tarkista turvakoodi No comment provided by engineer. - + Via browser + Selaimella No comment provided by engineer. - + Video call + Videopuhelu No comment provided by engineer. - + View security code + Näytä turvakoodi No comment provided by engineer. - + Voice messages + Ääniviestit chat feature - + Voice messages are prohibited in this chat. + Ääniviestit ovat kiellettyjä tässä keskustelussa. No comment provided by engineer. - + Voice messages are prohibited in this group. + Ääniviestit ovat kiellettyjä tässä ryhmässä. No comment provided by engineer. - + Voice messages prohibited! + Ääniviestit kielletty! No comment provided by engineer. - + Voice message… + Ääniviesti… No comment provided by engineer. - + Waiting for file + Odottaa tiedostoa No comment provided by engineer. - + Waiting for image + Odottaa kuvaa No comment provided by engineer. - + WebRTC ICE servers + WebRTC ICE -palvelimet No comment provided by engineer. - + Welcome %@! + Tervetuloa %@! No comment provided by engineer. - + Welcome message + Tervetuloviesti No comment provided by engineer. - + What's new + Uusimmat No comment provided by engineer. - + When available + Kun saatavilla No comment provided by engineer. - + When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. + Kun jaat inkognitoprofiilin jonkun kanssa, tätä profiilia käytetään ryhmissä, joihin tämä sinut kutsuu. No comment provided by engineer. - + With optional welcome message. + Valinnaisella tervetuloviestillä. No comment provided by engineer. - + Wrong database passphrase + Väärä tietokannan tunnuslause No comment provided by engineer. - + Wrong passphrase! + Väärä tunnuslause! No comment provided by engineer. - + You + Sinä No comment provided by engineer. - + You accepted connection + Hyväksyit yhteyden No comment provided by engineer. - + You allow + Sallit No comment provided by engineer. - + You already have a chat profile with the same display name. Please choose another name. + Sinulla on jo keskusteluprofiili samalla näyttönimellä. Valitse toinen nimi. No comment provided by engineer. - + You are already connected to %@. + Olet jo muodostanut yhteyden %@:n kanssa. 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. No comment provided by engineer. - + You are invited to group + Sinut on kutsuttu ryhmään No comment provided by engineer. - + You can accept calls from lock screen, without device and app authentication. + Voit vastaanottaa puheluita lukitusnäytöltä ilman laitteen ja sovelluksen todennusta. No comment provided by engineer. - + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button. + Voit myös muodostaa yhteyden klikkaamalla linkkiä. Jos se avautuu selaimessa, napsauta **Avaa mobiilisovelluksessa**-painiketta. No comment provided by engineer. @@ -3228,148 +3787,180 @@ To connect, please ask your contact to create another connection link and check SimpleX Lock must be enabled. No comment provided by engineer. - + You can now send messages to %@ + Voit nyt lähettää viestejä %@:lle notification body - + You can set lock screen notification preview via settings. + Voit määrittää lukitusnäytön ilmoituksen esikatselun asetuksista. No comment provided by engineer. - + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. + Voit jakaa linkin tai QR-koodin - kuka tahansa voi liittyä ryhmään. Et menetä ryhmän jäseniä, jos poistat sen myöhemmin. No comment provided by engineer. You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. No comment provided by engineer. - + You can start chat via app Settings / Database or by restarting the app + Voit aloittaa keskustelun sovelluksen Asetukset / Tietokanta kautta tai käynnistämällä sovelluksen uudelleen No comment provided by engineer. - + You can use markdown to format messages: + Voit käyttää markdownia viestien muotoiluun: No comment provided by engineer. - + You can't send messages! + Et voi lähettää viestejä! No comment provided by engineer. - + You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them. + Sinä hallitset, minkä palvelim(i)en kautta **viestit vastaanotetaan**, kontaktisi - palvelimet, joita käytät viestien lähettämiseen niille. No comment provided by engineer. - + You could not be verified; please try again. + Sinua ei voitu todentaa; yritä uudelleen. No comment provided by engineer. - + You have no chats + Sinulla ei ole keskusteluja No comment provided by engineer. - + You have to enter passphrase every time the app starts - it is not stored on the device. + Sinun on annettava tunnuslause aina, kun sovellus käynnistyy - sitä ei tallenneta laitteeseen. No comment provided by engineer. You invited your contact No comment provided by engineer. - + You joined this group + Liityit tähän ryhmään No comment provided by engineer. - + You joined this group. Connecting to inviting group member. + Liityit tähän ryhmään. Muodostetaan yhteyttä ryhmän jäsenten kutsumiseksi. No comment provided by engineer. - + You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. + Sinun tulee käyttää keskustelujen-tietokannan uusinta versiota AINOSTAAN yhdessä laitteessa, muuten saatat lakata vastaanottamasta viestejä joiltakin kontakteilta. No comment provided by engineer. - + You need to allow your contact to send voice messages to be able to send them. + Sinun on sallittava kontaktiesi lähettää ääniviestejä, jotta voit lähettää niitä. No comment provided by engineer. - + You rejected group invitation + Hylkäsit ryhmäkutsun No comment provided by engineer. - + You sent group invitation + Lähetit ryhmäkutsun No comment provided by engineer. - + You will be connected to group when the group host's device is online, please wait or check later! + 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 your connection request is accepted, please wait or check later! + Sinut yhdistetään, kun yhteyspyyntösi on hyväksytty, odota tai tarkista myöhemmin! No comment provided by engineer. - + You will be connected when your contact's device is online, please wait or check later! + Sinut yhdistetään, kun kontaktisi laite on online-tilassa, odota tai tarkista myöhemmin! 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. No comment provided by engineer. - + You will stop receiving messages from this group. Chat history will be preserved. + Et enää saa viestejä tästä ryhmästä. Keskusteluhistoria säilytetään. No comment provided by engineer. - + You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile + Yrität kutsua kontaktia, jonka kanssa olet jakanut inkognito-profiilin, ryhmään, jossa käytät pääprofiiliasi No comment provided by engineer. - + You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed + Käytät tässä ryhmässä incognito-profiilia. Kontaktien kutsuminen ei ole sallittua, jotta pääprofiilisi ei tule jaetuksi No comment provided by engineer. - + Your ICE servers + ICE-palvelimesi No comment provided by engineer. - + Your SMP servers + SMP-palvelimesi No comment provided by engineer. Your SimpleX contact address No comment provided by engineer. - + Your calls + Puhelusi No comment provided by engineer. - + Your chat database + Keskustelut-tietokantasi 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 profile will be sent to your contact No comment provided by engineer. - + Your chat profiles + Keskusteluprofiilisi No comment provided by engineer. @@ -3384,142 +3975,177 @@ SimpleX Lock must be enabled. Your contact can scan it from the app. No comment provided by engineer. - + Your contact needs to be online for the connection to complete. You can cancel this connection and remove the contact (and try later with a new link). + Kontaktin tulee olla online-tilassa, jotta yhteys voidaan muodostaa. +Voit peruuttaa tämän yhteyden ja poistaa kontaktin (ja yrittää myöhemmin uudella linkillä). No comment provided by engineer. - + Your contact sent a file that is larger than currently supported maximum size (%@). + Yhteyshenkilösi lähetti tiedoston, joka on suurempi kuin tällä hetkellä tuettu enimmäiskoko (%@). No comment provided by engineer. - + Your contacts can allow full message deletion. + Kontaktisi voivat sallia viestien täydellisen poistamisen. No comment provided by engineer. - + Your current chat database will be DELETED and REPLACED with the imported one. + Nykyinen keskustelut-tietokantasi poistetaan ja korvataan tuodulla tietokannalla. No comment provided by engineer. - + Your current profile + Nykyinen profiilisi No comment provided by engineer. - + Your preferences + Asetuksesi No comment provided by engineer. - + Your privacy + Yksityisyytesi No comment provided by engineer. - + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. + Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa. +SimpleX-palvelimet eivät näe profiiliasi. No comment provided by engineer. Your profile will be sent to the contact that you received this link from No comment provided by engineer. - + Your profile, contacts and delivered messages are stored on your device. + Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi. No comment provided by engineer. - + Your random profile + Satunnainen profiilisi No comment provided by engineer. - + Your server + Palvelimesi No comment provided by engineer. - + Your server address + Palvelimesi osoite No comment provided by engineer. - + Your settings + Asetuksesi No comment provided by engineer. - + [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) + [Osallistu](https://github.com/simplex-chat/simplex-chat#contribute) No comment provided by engineer. - + [Send us email](mailto:chat@simplex.chat) + [Lähetä meille sähköpostia](mailto:chat@simplex.chat) No comment provided by engineer. - + [Star on GitHub](https://github.com/simplex-chat/simplex-chat) + [Tähti GitHubissa](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. - + \_italic_ + \_italic_ No comment provided by engineer. - + \`a + b` + \`a + b` No comment provided by engineer. - + above, then choose: + edellä, valitse sitten: No comment provided by engineer. - + accepted call + hyväksytty puhelu call status - + admin + ylläpitäjä member role - + always + aina pref value - + audio call (not e2e encrypted) + äänipuhelu (ei e2e-salattu) No comment provided by engineer. - + bad message ID + virheellinen viestin tunniste integrity error chat item - + bad message hash + virheellinen viestin tarkiste integrity error chat item - + bold + lihavoitu No comment provided by engineer. - + call error + soittovirhe call status - + call in progress + puhelu käynnissä call status - + calling… + soittaa… call status - + cancelled %@ + peruutettu %@ feature offered item - + changed address for you + muuttunut osoite sinulle chat item text - + changed role of %1$@ to %2$@ + %1$@:n roolin muuttui %2$@:ksi rcv group event chat item - + changed your role to %@ + roolisi muuttui %@:ksi rcv group event chat item @@ -3530,409 +4156,510 @@ SimpleX servers cannot see your profile. changing address... chat item text - + colored + värillinen No comment provided by engineer. - + complete + valmis No comment provided by engineer. - + connect to SimpleX Chat developers. + ole yhteydessä SimpleX Chat -kehittäjiin. No comment provided by engineer. - + connected + yhdistetty No comment provided by engineer. - + connecting + yhdistää No comment provided by engineer. - + connecting (accepted) + yhdistäminen (hyväksytty) No comment provided by engineer. - + connecting (announced) + yhdistäminen (ilmoitettu) No comment provided by engineer. - + connecting (introduced) + yhdistäminen (esitelty) No comment provided by engineer. - + connecting (introduction invitation) + yhdistäminen (esittelykutsu) No comment provided by engineer. - + connecting call… + yhdistää puhelun… call status - + connecting… + yhdistää… chat list item title - + connection established + yhteys luotu chat list item title (it should not be shown - + connection:%@ + yhteys:%@ connection information - + contact has e2e encryption + kontaktilla on e2e-salaus No comment provided by engineer. - + contact has no e2e encryption + kontaktilla ei ole e2e-salausta No comment provided by engineer. - + creator + luoja No comment provided by engineer. - + default (%@) + oletusarvo (%@) pref value - + deleted + poistettu deleted chat item - + deleted group + poistettu ryhmä rcv group event chat item - + direct + suora connection level description - + duplicate message + päällekkäinen viesti integrity error chat item - + e2e encrypted + e2e-salattu No comment provided by engineer. - + enabled + käytössä enabled status - + enabled for contact + käytössä kontaktille enabled status - + enabled for you + käytössä sinulle enabled status - + ended + päättyi No comment provided by engineer. - + ended call %@ + puhelu päättyi %@:lle call status - + error + virhe No comment provided by engineer. - + group deleted + ryhmä poistettu No comment provided by engineer. - + group profile updated + ryhmäprofiili päivitetty snd group event chat item - + iOS Keychain is used to securely store passphrase - it allows receiving push notifications. + iOS-Avainnippua käytetään tunnuslauseen turvalliseen tallentamiseen - se mahdollistaa push-ilmoitusten vastaanottamisen. No comment provided by engineer. - + iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. + iOS-Avainnippua käytetään tunnuslauseen turvalliseen tallentamiseen sen muuttamisen tai sovelluksen uudelleen käynnistämisen jälkeen - se mahdollistaa push-ilmoitusten vastaanottamisen. No comment provided by engineer. - + incognito via contact address link + incognito kontaktilinkin kautta chat list item description - + incognito via group link + incognito ryhmälinkin kautta chat list item description - + incognito via one-time link + incognito kertalinkillä chat list item description - + indirect (%d) + epäsuora (%d) connection level description - + invalid chat + virheellinen keskustelu invalid chat data - + invalid chat data + virheelliset keskustelu-tiedot No comment provided by engineer. - + invalid data + virheelliset tiedot invalid chat item - + invitation to group %@ + kutsu ryhmään %@ group name - + invited + kutsuttu No comment provided by engineer. - + invited %@ + kutsuttu %@ rcv group event chat item - + invited to connect + kutsuttu yhteydenpitoon chat list item title - + invited via your group link + kutsuttu ryhmäsi linkin kautta rcv group event chat item - + italic + kursivoitu No comment provided by engineer. - + join as %@ + Liity %@:nä No comment provided by engineer. - + left + poistunut rcv group event chat item - + marked deleted + merkitty poistetuksi marked deleted chat item preview text - + member + jäsen member role - + connected + yhdistetty rcv group event chat item - + message received + viesti vastaanotettu notification - + missed call + vastaamaton puhelu call status - + moderated + moderoitu moderated chat item - + moderated by %@ + %@ moderoi No comment provided by engineer. - + never + ei koskaan No comment provided by engineer. - + new message + uusi viesti notification - + no + ei pref value - + no e2e encryption + ei e2e-salausta No comment provided by engineer. - + observer + tarkkailija member role - + off + pois enabled status group pref value - + offered %@ + tarjottu %@ feature offered item - + offered %1$@: %2$@ + tarjottu %1$@: %2$@ feature offered item - + on + päällä group pref value - + or chat with the developers + tai keskustele kehittäjien kanssa No comment provided by engineer. - + owner + omistaja member role - + peer-to-peer + vertais No comment provided by engineer. - + received answer… + vastaus saatu… No comment provided by engineer. - + received confirmation… + vahvistus saatu… No comment provided by engineer. - + rejected call + hylätty puhelu call status - + removed + poistettu No comment provided by engineer. - + removed %@ + %@ poistettu rcv group event chat item - + removed you + poisti sinut rcv group event chat item - + sec + sek network option - + secret + salainen No comment provided by engineer. - + starting… + alkaa… No comment provided by engineer. - + strike + soita No comment provided by engineer. - + this contact + tämä kontakti notification title - + unknown + tuntematon connection info - + updated group profile + päivitetty ryhmäprofiili rcv group event chat item - + v%@ (%@) + v%@ (%@) No comment provided by engineer. - + via contact address link + kontaktiosoitelinkillä chat list item description - + via group link + ryhmälinkillä chat list item description - + via one-time link + kertalinkillä chat list item description - + via relay + releellä No comment provided by engineer. - + video call (not e2e encrypted) + videopuhelu (ei e2e-salattu) No comment provided by engineer. - + waiting for answer… + odottaa vastaamista… No comment provided by engineer. - + waiting for confirmation… + odottaa vahvistusta… No comment provided by engineer. - + wants to connect to you! + haluaa olla yhteydessä sinuun! No comment provided by engineer. - + yes + kyllä pref value - + you are invited to group + sinut on kutsuttu ryhmään No comment provided by engineer. - + you are observer + olet tarkkailija No comment provided by engineer. - + you changed address + muutit osoitetta chat item text - + you changed address for %@ + muutit osoitetta %@:ksi chat item text - + you changed role for yourself to %@ + vaihdoit roolin itsellesi %@:ksi snd group event chat item - + you changed role of %1$@ to %2$@ + olet vaihtanut %1$@:n roolin %2$@:ksi snd group event chat item - + you left + lähdit snd group event chat item - + you removed %@ + poistit %@ snd group event chat item - + you shared one-time link + jaoit kertalinkin chat list item description - + you shared one-time link incognito + jaoit kertalinkin incognito-tilassa chat list item description - + you: + sinä: No comment provided by engineer. - + \~strike~ + \~strike~ No comment provided by engineer. @@ -4117,7 +4844,7 @@ SimpleX servers cannot see your profile. 0s - 0s + 0s No comment provided by engineer. @@ -4246,6 +4973,1371 @@ SimpleX servers cannot see your profile. Kontaktit No comment provided by engineer. + + # %@ + # %@ + copied message info title, # <title> + + + ## History + ## Historia + copied message info + + + ## In reply to + ## vastauksena + copied message info + + + %@ and %@ connected + %@ ja %@ yhdistetty + No comment provided by engineer. + + + You can hide or mute a user profile - swipe it to the right. + Voit piilottaa tai mykistää käyttäjäprofiilin pyyhkäisemällä sitä oikealle. + No comment provided by engineer. + + + Database upgrade + Tietokannan päivitys + No comment provided by engineer. + + + Deleted at + Poistettu klo + No comment provided by engineer. + + + Deleted at: %@ + Poistettu klo: %@ + copied message info + + + Duration + Kesto + No comment provided by engineer. + + + Files and media are prohibited in this group. + Tiedostot ja media ovat tässä ryhmässä kiellettyjä. + No comment provided by engineer. + + + Incompatible database version + Yhteensopimaton tietokantaversio + No comment provided by engineer. + + + Moderated at: %@ + Moderoitu klo: %@ + copied message info + + + New display name + Uusi näyttönimi + No comment provided by engineer. + + + Only your contact can add message reactions. + Vain kontaktisi voi lisätä viestireaktioita. + No comment provided by engineer. + + + Only your contact can make calls. + Vain kontaktisi voi soittaa puheluita. + No comment provided by engineer. + + + Polish interface + Puolalainen käyttöliittymä + No comment provided by engineer. + + + Select + Valitse + No comment provided by engineer. + + + Sent at: %@ + Lähetetty klo: %@ + copied message info + + + Set passcode + Aseta pääsykoodi + No comment provided by engineer. + + + Share address + Jaa osoite + No comment provided by engineer. + + + Share with contacts + Jaa kontaktien kanssa + No comment provided by engineer. + + + no text + ei tekstiä + copied message info in history + + + seconds + sekuntia + time unit + + + weeks + viikkoa + time unit + + + Database IDs and Transport isolation option. + Tietokantatunnukset ja kuljetuseristysvaihtoehto. + No comment provided by engineer. + + + Database downgrade + Tietokannan alentaminen + No comment provided by engineer. + + + Downgrade and open chat + Alenna ja avaa keskustelu + No comment provided by engineer. + + + Enter Passcode + Syötä pääsykoodi + No comment provided by engineer. + + + File will be received when your contact completes uploading it. + Tiedosto vastaanotetaan, kun kontaktisi on ladannut sen. + No comment provided by engineer. + + + Image will be received when your contact completes uploading it. + Kuva vastaanotetaan, kun kontaktisi on ladannut sen. + No comment provided by engineer. + + + Immediately + Heti + No comment provided by engineer. + + + Incorrect passcode + Väärä pääsykoodi + PIN entry + + + KeyChain error + Avainnipun virhe + No comment provided by engineer. + + + Messages & files + Viestit ja tiedostot + No comment provided by engineer. + + + Migrations: %@ + Siirrot: %@ + No comment provided by engineer. + + + No app password + Ei sovelluksen salasanaa + Authentication unavailable + + + Passcode entry + Pääsykoodin syöttö + No comment provided by engineer. + + + Passcode not changed! + Pääsykoodia ei ole muutettu! + No comment provided by engineer. + + + Passcode set! + Pääsykoodi asetettu! + No comment provided by engineer. + + + Show developer options + Näytä kehittäjävaihtoehdot + No comment provided by engineer. + + + SimpleX Lock mode + SimpleX Lock -tila + No comment provided by engineer. + + + Upgrade and open chat + Päivitä ja avaa keskustelu + No comment provided by engineer. + + + Video will be received when your contact is online, please wait or check later! + Video vastaanotetaan, kun kontaktisi on online-tilassa, odota tai tarkista myöhemmin! + No comment provided by engineer. + + + Warning: you may lose some data! + Varoitus: saatat menettää joitain tietoja! + No comment provided by engineer. + + + XFTP servers + XFTP-palvelimet + No comment provided by engineer. + + + different migration in the app/database: %@ / %@ + eri siirtyminen sovelluksessa/tietokannassa: %@ / %@ + No comment provided by engineer. + + + A new random profile will be shared. + Uusi satunnainen profiili jaetaan. + No comment provided by engineer. + + + Accept connection request? + Hyväksy yhteyspyyntö? + No comment provided by engineer. + + + Connect directly + Yhdistä suoraan + No comment provided by engineer. + + + Connect incognito + Yhdistä Incognito + No comment provided by engineer. + + + Custom time + Mukautettu aika + No comment provided by engineer. + + + Don't create address + Älä luo osoitetta + No comment provided by engineer. + + + Encrypted message: database migration error + Salattu viesti: tietokannan siirtovirhe + notification + + + Fix connection + Korjaa yhteys + No comment provided by engineer. + + + Fix connection? + Korjaa yhteys? + No comment provided by engineer. + + + Fix not supported by contact + Kontakti ei tue korjausta + No comment provided by engineer. + + + Fix not supported by group member + Ryhmän jäsen ei tue korjausta + No comment provided by engineer. + + + Only you can add message reactions. + Vain sinä voit lisätä viestireaktioita. + No comment provided by engineer. + + + Only you can make calls. + Vain sinä voit soittaa puheluita. + No comment provided by engineer. + + + Paste the link you received to connect with your contact. + Liitä saamasi linkki, jonka avulla voit muodostaa yhteyden kontaktiisi. + placeholder + + + Please remember or store it securely - there is no way to recover a lost passcode! + Muista tai säilytä se turvallisesti - kadonnutta pääsykoodia ei voi palauttaa! + No comment provided by engineer. + + + Profile update will be sent to your contacts. + Profiilipäivitys lähetetään kontakteillesi. + No comment provided by engineer. + + + Prohibit sending files and media. + Estä tiedostojen ja median lähettäminen. + No comment provided by engineer. + + + Receipts are disabled + Kuittaukset pois käytöstä + No comment provided by engineer. + + + Record updated at: %@ + Tietue päivitetty klo: %@ + copied message info + + + Reject (sender NOT notified) + Hylkää (lähettäjälle EI ilmoiteta) + No comment provided by engineer. + + + Renegotiate encryption + Uudelleenneuvottele salaus + No comment provided by engineer. + + + Save settings? + Tallenna asetukset? + No comment provided by engineer. + + + Self-destruct + Itsetuho + No comment provided by engineer. + + + Send disappearing message + Lähetä katoava viesti + No comment provided by engineer. + + + Send receipts + Lähetä kuittaukset + No comment provided by engineer. + + + Sending receipts is disabled for %lld groups + Kuittien lähettäminen ei ole käytössä %lld ryhmille + No comment provided by engineer. + + + Show: + Näytä: + No comment provided by engineer. + + + SimpleX address + SimpleX-osoite + No comment provided by engineer. + + + Some non-fatal errors occurred during import - you may see Chat console for more details. + Tuonnin aikana tapahtui joitakin ei-vakavia virheitä – saatat nähdä Chat-konsolissa lisätietoja. + No comment provided by engineer. + + + They can be overridden in contact and group settings. + Ne voidaan ohittaa kontakti- ja ryhmäasetuksissa. + 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ä. + No comment provided by engineer. + + + Use new incognito profile + Käytä uutta incognito-profiilia + No comment provided by engineer. + + + Waiting for video + Odottaa videota + No comment provided by engineer. + + + You invited a contact + Kutsuit kontaktin + No comment provided by engineer. + + + agreeing encryption… + hyväksyy salausta… + chat item text + + + disabled + ei käytössä + No comment provided by engineer. + + + encryption ok for %@ + salaus ok %@:lle + chat item text + + + encryption re-negotiation allowed + salauksen uudelleenneuvottelu sallittu + chat item text + + + minutes + minuuttia + time unit + + + Initial role + Alkuperäinen rooli + No comment provided by engineer. + + + Don't enable + Älä salli + No comment provided by engineer. + + + Enable lock + Ota lukitus käyttöön + No comment provided by engineer. + + + Enable self-destruct + Ota itsetuho käyttöön + No comment provided by engineer. + + + Error enabling delivery receipts! + Virhe toimituskuittauksien sallimisessa! + No comment provided by engineer. + + + Error setting delivery receipts! + Virhe toimituskuittauksien asettamisessa! + No comment provided by engineer. + + + Sent message + Lähetetty viesti + message info title + + + Server requires authorization to upload, check password + Palvelin vaatii valtuutuksen tiedoston lataamiseksi, tarkista salasana + server test error + + + Set it instead of system authentication. + Aseta se järjestelmän todennuksen sijaan. + No comment provided by engineer. + + + Share address with contacts? + Jaa osoite kontakteille? + No comment provided by engineer. + + + Share 1-time link + Jaa kertakäyttölinkki + No comment provided by engineer. + + + Show last messages + Näytä viimeiset viestit + No comment provided by engineer. + + + Stop receiving file? + Lopeta tiedoston vastaanottaminen? + No comment provided by engineer. + + + SimpleX Lock not enabled! + SimpleX Lock ei ole käytössä! + No comment provided by engineer. + + + Small groups (max 20) + Pienryhmät (max 20) + No comment provided by engineer. + + + Stop sending file? + Lopeta tiedoston lähettäminen? + No comment provided by engineer. + + + Submit + Lähetä + No comment provided by engineer. + + + System authentication + Järjestelmän todennus + No comment provided by engineer. + + + These settings are for your current profile **%@**. + Nämä asetukset koskevat nykyistä profiiliasi **%@**. + No comment provided by engineer. + + + Passcode + Pääsykoodi + No comment provided by engineer. + + + Please report it to the developers. + Ilmoita siitä kehittäjille. + No comment provided by engineer. + + + Profile password + Profiilin salasana + No comment provided by engineer. + + + Prohibit audio/video calls. + Estä ääni- ja videopuhelut. + No comment provided by engineer. + + + Prohibit message reactions. + Estä viestireaktiot. + No comment provided by engineer. + + + Prohibit messages reactions. + Estä viestireaktiot. + No comment provided by engineer. + + + React… + Reagoi… + chat item menu + + + Read more + Lue lisää + No comment provided by engineer. + + + Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address). + Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address). + No comment provided by engineer. + + + Received message + Vastaanotettu viesti + message info title + + + Invite friends + Kutsu ystäviä + No comment provided by engineer. + + + Invalid status + Virheellinen tila + item status text + + + Files and media + Tiedostot ja media + chat feature + + + Files and media prohibited! + Tiedostot ja media kielletty! + No comment provided by engineer. + + + Finally, we have them! 🚀 + Vihdoinkin meillä! 🚀 + No comment provided by engineer. + + + Filter unread and favorite chats. + Suodata lukemattomia- ja suosikkikeskusteluja. + No comment provided by engineer. + + + Fix + Korjaa + No comment provided by engineer. + + + Find chats faster + Löydä keskustelut nopeammin + No comment provided by engineer. + + + Group members can add message reactions. + Ryhmän jäsenet voivat lisätä viestireaktioita. + No comment provided by engineer. + + + If you enter your self-destruct passcode while opening the app: + Jos syötät itsetuhoutuvan pääsykoodin sovellusta avattaessa: + No comment provided by engineer. + + + Japanese interface + Japanilainen käyttöliittymä + No comment provided by engineer. + + + Make one message disappear + Hävitä yksi viesti + No comment provided by engineer. + + + Message reactions are prohibited in this group. + Viestireaktiot ovat kiellettyjä tässä ryhmässä. + No comment provided by engineer. + + + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. + Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille näkyvissä keskusteluprofiileissa. + No comment provided by engineer. + + + Sending delivery receipts will be enabled for all contacts. + Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille. + No comment provided by engineer. + + + Sending receipts is disabled for %lld contacts + Kuittauksien lähettäminen ei ole käytössä %lld kontakteille + No comment provided by engineer. + + + Sent at + Lähetetty klo + No comment provided by engineer. + + + Unhide chat profile + Näytä keskusteluprofiili + No comment provided by engineer. + + + Upload file + Lataa tiedosto + server test step + + + Use current profile + Käytä nykyistä profiilia + No comment provided by engineer. + + + You can share your address as a link or QR code - anybody can connect to you. + Voit jakaa osoitteesi linkkinä tai QR-koodina - kuka tahansa voi muodostaa yhteyden sinuun. + No comment provided by engineer. + + + You can turn on SimpleX Lock via Settings. + Voit ottaa SimpleX Lockin käyttöön Asetusten kautta. + No comment provided by engineer. + + + Your contacts will remain connected. + Kontaktisi pysyvät yhdistettyinä. + No comment provided by engineer. + + + Decryption error + Salauksen purkuvirhe + message decrypt error item + + + Delete chat profile + Poista keskusteluprofiili + No comment provided by engineer. + + + Let's talk in SimpleX Chat + Jutellaan SimpleX Chatissa + email subject + + + Your SimpleX address + SimpleX-osoitteesi + No comment provided by engineer. + + + Unit + Yksikkö + No comment provided by engineer. + + + Enter welcome message… (optional) + Kirjoita tervetuloviesti... (valinnainen) + placeholder + + + The hash of the previous message is different. + Edellisen viestin tarkiste on erilainen. + No comment provided by engineer. + + + Unlock app + Avaa sovellus + authentication reason + + + You can create it later + Voit luoda sen myöhemmin + No comment provided by engineer. + + + Delete file + Poista tiedosto + server test step + + + Delivery receipts are disabled! + Toimituskuittaukset poissa käytöstä! + No comment provided by engineer. + + + Disable (keep overrides) + Poista käytöstä (pidä ohitukset) + No comment provided by engineer. + + + Disable for all + Poista käytöstä kaikilta + No comment provided by engineer. + + + Disappearing message + Tuhoutuva viesti + No comment provided by engineer. + + + Disappears at: %@ + Katoaa klo: %@ + copied message info + + + Enable (keep overrides) + Salli (pidä ohitukset) + No comment provided by engineer. + + + Error synchronizing connection + Virhe yhteyden synkronoinnissa + No comment provided by engineer. + + + Even when disabled in the conversation. + Jopa kun ei käytössä keskustelussa. + No comment provided by engineer. + + + Favorite + Suosikki + No comment provided by engineer. + + + File will be deleted from servers. + Tiedosto poistetaan palvelimilta. + No comment provided by engineer. + + + Fix encryption after restoring backups. + Korjaa salaus varmuuskopioiden palauttamisen jälkeen. + No comment provided by engineer. + + + If you enter this passcode when opening the app, all app data will be irreversibly removed! + Jos syötät tämän pääsykoodin sovellusta avatessasi, kaikki sovelluksen tiedot poistetaan peruuttamattomasti! + No comment provided by engineer. + + + Info + Tiedot + chat item action + + + Migrating database archive… + Siirretään tietokannan arkistoa… + No comment provided by engineer. + + + No filtered chats + Ei suodatettuja keskusteluja + No comment provided by engineer. + + + Only group owners can enable files and media. + Vain ryhmän omistajat voivat sallia tiedostoja ja mediaa. + No comment provided by engineer. + + + Passcode changed! + Pääsykoodi vaihdettu! + No comment provided by engineer. + + + Permanent decryption error + Pysyvä salauksen purkuvirhe + message decrypt error item + + + Protocol timeout per KB + Protokollan aikakatkaisu per KB + No comment provided by engineer. + + + Receiving address will be changed to a different server. Address change will complete after sender comes online. + Vastaanotto-osoite vaihdetaan toiseen palvelimeen. Osoitteenmuutos tehdään sen jälkeen, kun lähettäjä tulee verkkoon. + No comment provided by engineer. + + + Reconnect servers? + Yhdistä palvelimet uudelleen? + No comment provided by engineer. + + + Record updated at + Tietue päivitetty klo + No comment provided by engineer. + + + Renegotiate + Neuvottele uudelleen + No comment provided by engineer. + + + Send delivery receipts to + Lähetä toimituskuittaukset vastaanottajalle + No comment provided by engineer. + + + Self-destruct passcode changed! + Itsetuhoutuva pääsykoodi vaihdettu! + No comment provided by engineer. + + + Sending file will be stopped. + Tiedoston lähettäminen lopetetaan. + No comment provided by engineer. + + + Stop file + Pysäytä tiedosto + cancel file action + + + Stop sharing + Lopeta jakaminen + No comment provided by engineer. + + + Stop sharing address? + Lopeta osoitteen jakaminen? + No comment provided by engineer. + + + The second tick we missed! ✅ + Toinen kuittaus, joka uupui! ✅ + No comment provided by engineer. + + + To connect, your contact can scan QR code or use the link in the app. + Kontaktisi voi muodostaa yhteyden skannaamalla QR-koodin tai käyttämällä sovelluksessa olevaa linkkiä. + No comment provided by engineer. + + + Unfav. + Epäsuotuisa. + No comment provided by engineer. + + + Unhide profile + Näytä profiili + No comment provided by engineer. + + + Videos and files up to 1gb + Videot ja tiedostot 1 Gt asti + No comment provided by engineer. + + + When people request to connect, you can accept or reject it. + Kun ihmiset pyytävät yhteyden muodostamista, voit hyväksyä tai hylätä sen. + No comment provided by engineer. + + + You can enable them later via app Privacy & Security settings. + Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys & Turvallisuus -asetuksista. + No comment provided by engineer. + + + You won't lose your contacts if you later delete your address. + Et menetä kontaktejasi, jos poistat osoitteesi myöhemmin. + No comment provided by engineer. + + + Your %@ servers + %@-palvelimesi + No comment provided by engineer. + + + Your XFTP servers + XFTP-palvelimesi + No comment provided by engineer. + + + changing address for %@… + osoitteen muuttaminen %@:lle… + chat item text + + + changing address… + muuttamassa osoitetta… + chat item text + + + default (no) + oletusarvo (ei) + No comment provided by engineer. + + + default (yes) + oletusarvo (kyllä) + No comment provided by engineer. + + + database version is newer than the app, but no down migration for: %@ + tietokantaversio on uudempi kuin sovellus, mutta ei alaspäin siirtymistä varten: %@ + No comment provided by engineer. + + + encryption agreed for %@ + salaus sovittu %@:lle + chat item text + + + encryption ok + salaus ok + chat item text + + + encryption agreed + salaus sovittu + chat item text + + + encryption re-negotiation required for %@ + tarvitaan salauksen uudelleenneuvottelu %@:lle + chat item text + + + hours + tuntia + time unit + + + months + kuukautta + time unit + + + Enable self-destruct passcode + Ota itsetuhoava pääsykoodi käyttöön + set passcode view + + + Hide: + Piilota: + No comment provided by engineer. + + + Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). + Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/readme.html#connect-to-friends). + No comment provided by engineer. + + + Received at + Vastaanotettu klo + No comment provided by engineer. + + + Received at: %@ + Vastaanotettu klo: %@ + copied message info + + + Delete profile + Poista profiili + No comment provided by engineer. + + + Make sure %@ server addresses are in correct format, line separated and are not duplicated (%@). + Varmista, että %@-palvelinosoitteet ovat oikeassa muodossa, että ne on erotettu toisistaan riveittäin ja että ne eivät ole päällekkäisiä (%@). + No comment provided by engineer. + + + Receiving file will be stopped. + Tiedoston vastaanotto pysäytetään. + No comment provided by engineer. + + + Revoke file + Peruuta tiedosto + cancel file action + + + Revoke file? + Peruuta tiedosto? + No comment provided by engineer. + + + %1$@ at %2$@: + %1$@ klo %2$@: + copied message info, <sender> at <time> + + + Delivery receipts! + Toimituskuittaukset! + No comment provided by engineer. + + + It can happen when: +1. The messages expired in the sending client after 2 days or on the server after 30 days. +2. Message decryption failed, because you or your contact used old database backup. +3. The connection was compromised. + Se voi tapahtua, kun: +1. Viestit vanhenivat lähettävässä päätelaitteessa kahden päivän päästä tai palvelimella 30 päivän kuluttua. +2. Viestin salauksen purku epäonnistui, koska sinä tai kontaktisi käytitte vanhaa varmuuskopiota tietokannasta. +3. Yhteys vaarantui. + No comment provided by engineer. + + + Preview + Esikatselu + No comment provided by engineer. + + + SimpleX Address + SimpleX-osoite + No comment provided by engineer. + + + %@, %@ and %lld other members connected + %@, %@ ja %lld muut jäsenet yhdistetty + No comment provided by engineer. + + + Connect via contact link + Yhdistä kontaktilinkillä + No comment provided by engineer. + + + Connect via one-time link + Yhdistä kertalinkillä + No comment provided by engineer. + + + Database ID: %d + Tietokannan tunnus: %d + copied message info + + + Delivery + Toimitus + No comment provided by engineer. + + + Disappears at + Katoaa klo + No comment provided by engineer. + + + Download file + Lataa tiedosto + server test step + + + Enable for all + Salli kaikille + No comment provided by engineer. + + + Enter welcome message… + Kirjoita tervetuloviesti… + placeholder + + + Error aborting address change + Virhe osoitteenmuutoksen keskeytyksessä + No comment provided by engineer. + + + Error loading %@ servers + Virhe %@-palvelimien lataamisessa + No comment provided by engineer. + + + Error saving %@ servers + Virhe %@ palvelimien tallentamisessa + No comment provided by engineer. + + + Error saving passcode + Virhe pääsykoodin tallentamisessa + No comment provided by engineer. + + + Error sending email + Virhe sähköpostin lähettämisessä + No comment provided by engineer. + + + Error: + Virhe: + No comment provided by engineer. + + + Exporting database archive… + Tietokanta-arkiston vienti… + No comment provided by engineer. + + + Fast and no wait until the sender is online! + Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa! + No comment provided by engineer. + + + Group members can send files and media. + Ryhmän jäsenet voivat lähettää tiedostoja ja mediaa. + No comment provided by engineer. + + + History + Historia + No comment provided by engineer. + + + If you can't meet in person, show QR code in a video call, or share the link. + Jos et voi tavata henkilökohtaisesti, näytä QR-koodi videopuhelussa tai jaa linkki. + No comment provided by engineer. + + + In reply to + Vastauksena + No comment provided by engineer. + + + Incognito mode protects your privacy by using a new random profile for each contact. + Incognito-tila suojaa yksityisyyttäsi käyttämällä uutta satunnaista profiilia jokaiselle kontaktille. + No comment provided by engineer. + + + It can happen when you or your connection used the old database backup. + Se voi tapahtua, kun sinä tai kontaktisi käytitte vanhaa varmuuskopiota tietokannasta. + No comment provided by engineer. + + + Keep your connections + Pidä kontaktisi + No comment provided by engineer. + + + Learn more + Lue lisää + No comment provided by engineer. + + + Lock after + Lukitse jälkeen + No comment provided by engineer. + + + Lock mode + Lukitustila + No comment provided by engineer. + + + Message delivery receipts! + Viestien toimituskuittaukset! + No comment provided by engineer. + + + Message reactions + Viestireaktiot + chat feature + + + Message reactions are prohibited in this chat. + Viestireaktiot ovat kiellettyjä tässä keskustelussa. + No comment provided by engineer. + + + Moderated at + Moderoitu klo + No comment provided by engineer. + + + Most likely this connection is deleted. + Todennäköisesti tämä yhteys on poistettu. + item status description + + + New Passcode + Uusi pääsykoodi + No comment provided by engineer. + + + No delivery information + Ei toimitustietoja + No comment provided by engineer. + + + No history + Ei historiaa + No comment provided by engineer. + + + Off + Pois + No comment provided by engineer. + + + Opening database… + Avataan tietokantaa… + No comment provided by engineer. + + + Reconnect all connected servers to force message delivery. It uses additional traffic. + Yhdistä kaikki yhdistetyt palvelimet uudelleen pakottaaksesi viestin toimituksen. Tämä käyttää ylimääräistä liikennettä. + No comment provided by engineer. + + + Renegotiate encryption? + Uudelleenneuvottele salaus? + No comment provided by engineer. + + + Sending receipts is enabled for %lld contacts + Kuittauksien lähettäminen on käytössä %lld kontakteille + No comment provided by engineer. + + + Sending receipts is enabled for %lld groups + Kuittauksien lähettäminen on käytössä %lld ryhmille + No comment provided by engineer. + + + Revoke + Peruuta + No comment provided by engineer. + + + Save auto-accept settings + Tallenna automaattisen hyväksynnän asetukset + No comment provided by engineer. + + + Self-destruct passcode + Itsetuhoutuva pääsykoodi + No comment provided by engineer. + + + Self-destruct passcode enabled! + Itsetuhoutuva pääsykoodi käytössä! + No comment provided by engineer. + + + The ID of the next message is incorrect (less or equal to the previous). +It can happen because of some bug or when the connection is compromised. + Seuraavan viestin tunnus on väärä (pienempi tai yhtä suuri kuin edellisen). +Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut. + 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. + + + Video will be received when your contact completes uploading it. + Video vastaanotetaan, kun kontaktisi on ladannut sen. + No comment provided by engineer. + + + You can enable later via Settings + Voit ottaa käyttöön myöhemmin asetusten kautta + No comment provided by engineer. + + + You can share this address with your contacts to let them connect with **%@**. + Voit jakaa tämän osoitteen kontaktiesi kanssa, jotta ne voivat muodostaa yhteyden **%@** kanssa. + No comment provided by engineer. + + + Your contacts in SimpleX will see it. +You can change it in Settings. + Kontaktisi SimpleX:ssä näkevät sen. +Voit muuttaa sitä Asetuksista. + No comment provided by engineer. + + + Your profile **%@** will be shared. + Profiilisi **%@** jaetaan. + No comment provided by engineer. + + + agreeing encryption for %@… + salauksesta sovitaan %@:lle… + chat item text + + + custom + mukautettu + dropdown time picker choice + + + days + päivää + time unit + + + encryption re-negotiation allowed for %@ + salauksen uudelleenneuvottelu sallittu %@:lle + chat item text + + + encryption re-negotiation required + tarvitaan salauksen uudelleenneuvottelu + chat item text + + + event happened + tapahtuma tapahtui + No comment provided by engineer. + + + security code changed + turvakoodi on muuttunut + chat item text + @@ -4253,24 +6345,29 @@ SimpleX servers cannot see your profile. - + SimpleX + SimpleX Bundle name - + SimpleX needs camera access to scan QR codes to connect to other users and for video calls. + SimpleX tarvitsee pääsyn kameraan, jotta se voi skannata QR-koodeja muodostaakseen yhteyden muihin käyttäjiin ja videopuheluita varten. Privacy - Camera Usage Description - + SimpleX uses Face ID for local authentication + SimpleX käyttää Face ID:tä paikalliseen todennukseen Privacy - Face ID 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. Privacy - Microphone Usage Description - + SimpleX needs access to Photo Library for saving captured and received media + SimpleX tarvitsee pääsyn valokuvakirjastoon kuvattujen ja vastaanotettujen medioiden tallentamista varten Privacy - Photo Library Additions Usage Description @@ -4280,16 +6377,19 @@ SimpleX servers cannot see your profile. - + SimpleX NSE + SimpleX NSE Bundle display name - + SimpleX NSE + SimpleX NSE Bundle name - + Copyright © 2022 SimpleX Chat. All rights reserved. + Copyright © 2022 SimpleX Chat. Kaikki oikeudet pidätetään. Copyright (human-readable) 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 fcb2cdecc..113e00309 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -5725,7 +5725,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. encryption ok - chiffrement ok + chiffrement OK chat item text diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff index 428cb8dfb..ac71ed26b 100644 --- a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff +++ b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff @@ -284,7 +284,7 @@ Available in v5.1 . - . + . No comment provided by engineer. @@ -1971,8 +1971,9 @@ Available in v5.1 חברי הקבוצה יכולים לשלוח הודעות קוליות. No comment provided by engineer. - + Group message: + הודעה קבוצתית: notification @@ -2377,262 +2378,327 @@ Available in v5.1 נתוני פרופיל מקומיים בלבד No comment provided by engineer. - + Lock after + נעל אחרי No comment provided by engineer. - + Lock mode + מצב נעילה No comment provided by engineer. - + Make a private connection + צור חיבור פרטי No comment provided by engineer. - + Make profile private! + הפוך את הפרופיל לפרטי! No comment provided by engineer. - + Make sure %@ server addresses are in correct format, line separated and are not duplicated (%@). + ודא שכתובות השרת %@ הן בפורמט הנכון, מופרדות בשורה ואינן משוכפלות (%@). No comment provided by engineer. - + Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated. + ודאו שכתובות שרתי ה־WebRTC ICE הן בפורמט הנכון, מופרדות בשורה ולא משוכפלות. No comment provided by engineer. - + Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* + אנשים רבים שאלו: *אם ל-SimpleX אין מזהי משתמש, איך הוא יכול לשלוח הודעות?* No comment provided by engineer. - + Mark deleted for everyone + לסמן נמחק לכולם No comment provided by engineer. - + Mark read + סמן כנקרא No comment provided by engineer. - + Mark verified + סמן מאומת No comment provided by engineer. - + Markdown in messages + מרקדאון בהודעות No comment provided by engineer. - + Max 30 seconds, received instantly. + מקסימום 30 שניות, התקבל באופן מיידי. No comment provided by engineer. - + Member + חבר קבוצה No comment provided by engineer. - + Member role will be changed to "%@". All group members will be notified. + תפקיד חבר הקבוצה ישתנה ל-"%@". כל חברי הקבוצה יקבלו הודעה. No comment provided by engineer. - + Member role will be changed to "%@". The member will receive a new invitation. + תפקיד חבר הקבוצה ישתנה ל-"%@". חבר הקבוצה יקבל הזמנה חדשה. No comment provided by engineer. - + Member will be removed from group - this cannot be undone! + חבר הקבוצה יוסר מהקבוצה – לא ניתן לבטל זאת! No comment provided by engineer. - + Message delivery error + שגיאת מסירת הודעה No comment provided by engineer. - + Message draft + טיוטת הודעה No comment provided by engineer. - + Message text + טקסט הודעה No comment provided by engineer. - + Messages + הודעות No comment provided by engineer. - + Messages & files + הודעות וקבצים No comment provided by engineer. Migrating database archive... No comment provided by engineer. - + Migration error: + שגיאת העברה: No comment provided by engineer. - + Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). + ההעברה נכשלה. הקש על **דלג** למטה כדי להמשיך להשתמש במסד הנתונים הנוכחי. אנא דווח על הבעיה למפתחי האפליקציה באמצעות צ'אט או דוא"ל [chat@simplex.chat](mailto:chat@simplex.chat). No comment provided by engineer. - + Migration is completed + ההעברה הושלמה No comment provided by engineer. - + Migrations: %@ + העברות: %@ No comment provided by engineer. - + Moderate + חסימת הודעה chat item action - + More improvements are coming soon! + שיפורים נוספים יגיעו בקרוב! No comment provided by engineer. - + Most likely this contact has deleted the connection with you. + ככל הנראה איש קשר זה מחק את החיבור איתך. No comment provided by engineer. - + Multiple chat profiles + פרופילי צ׳אט מרובים No comment provided by engineer. - + Mute + השתק No comment provided by engineer. - + Muted when inactive! + מושתק כאשר אין פעילות! No comment provided by engineer. - + Name + שם No comment provided by engineer. - + Network & servers + רשת ושרתים No comment provided by engineer. - + Network settings + הגדרות רשת No comment provided by engineer. - + Network status + מצב רשת No comment provided by engineer. - + New Passcode + קוד גישה חדש No comment provided by engineer. - + New contact request + בקשה חדשה ליצירת קשר notification - + New contact: + איש קשר חדש: notification - + New database archive + ארכיון מסד נתונים חדש No comment provided by engineer. - + New in %@ + חדש ב %@ No comment provided by engineer. - + New member role + תפקיד חבר קבוצה חדש No comment provided by engineer. - + New message + הודעה חדשה notification - + New passphrase… + סיסמה חדשה… No comment provided by engineer. - + No + לא No comment provided by engineer. - + No app password + אין סיסמה לאפליקציה Authentication unavailable - + No contacts selected + לא נבחרו אנשי קשר No comment provided by engineer. - + No contacts to add + אין אנשי קשר להוסיף No comment provided by engineer. - + No device token! + אין אסימון מכשיר! No comment provided by engineer. - + Group not found! + קבוצה לא נמצאה! No comment provided by engineer. - + No permission to record voice message + אין הרשאה להקליט הודעה קולית No comment provided by engineer. - + No received or sent files + לא התקבלו או נשלחו קבצים No comment provided by engineer. - + Notifications + התראות No comment provided by engineer. - + Notifications are disabled! + ההתראות מושבתות! No comment provided by engineer. - + Now admins can: - delete members' messages. - disable members ("observer" role) + כעת מנהלים יכולים: +- למחוק הודעות של חברי קבוצה. +- להשבית חברי קבוצה (תפקיד ”צופה”) No comment provided by engineer. - + Off + כבוי No comment provided by engineer. - + Off (Local) + כבוי (מקומי) No comment provided by engineer. - + Ok + אישור No comment provided by engineer. - + Old database + מסד נתונים ישן No comment provided by engineer. - + Old database archive + ארכיון מסד נתונים ישן No comment provided by engineer. - + One-time invitation link + קישור הזמנה חד־פעמי No comment provided by engineer. - + Onion hosts will be required for connection. Requires enabling VPN. + לחיבור יידרשו מארחי Onion. דורש הפעלת VPN. No comment provided by engineer. - + Onion hosts will be used when available. Requires enabling VPN. + מארחי Onion ישומשו כאשר יהיו זמינים. דורש הפעלת VPN. No comment provided by engineer. - + Onion hosts will not be used. + לא ייעשה שימוש במארחי Onion. No comment provided by engineer. @@ -4981,6 +5047,270 @@ SimpleX servers cannot see your profile. %1$@ בזמן %2$@: copied message info, <sender> at <time> + + # %@ + # %@ + copied message info title, # <title> + + + ## History + ## היסטוריה + copied message info + + + ## In reply to + ## בתגובה ל + copied message info + + + - more stable message delivery. +- a bit better groups. +- and more! + - שליחת הודעות יציבה יותר. +- קבוצות קצת יותר טובות. +- ועוד! + No comment provided by engineer. + + + A few more things + עוד כמה דברים + No comment provided by engineer. + + + A new random profile will be shared. + ישותף פרופיל אקראי חדש. + No comment provided by engineer. + + + Accept connection request? + לאשר בקשת חיבור? + No comment provided by engineer. + + + Connect directly + התחבר ישירות + No comment provided by engineer. + + + Connect incognito + התחבר בזהות נסתרת + No comment provided by engineer. + + + Connect via one-time link + התחבר באמצעות קישור חד־פעמי + No comment provided by engineer. + + + Contacts + אנשי קשר + No comment provided by engineer. + + + Delivery + מסירה + No comment provided by engineer. + + + Error synchronizing connection + שגיאה בסנכרון החיבור + No comment provided by engineer. + + + Fix encryption after restoring backups. + תקן הצפנה לאחר שחזור גיבויים. + No comment provided by engineer. + + + Fix not supported by group member + תיקון אינו נתמך על ידי חבר הקבוצה + No comment provided by engineer. + + + Invalid status + סטטוס לא חוקי + item status text + + + Migrating database archive… + מעביר את ארכיון מסד הנתונים… + No comment provided by engineer. + + + Moderated at: %@ + נחסם ב: %@ + copied message info + + + Most likely this connection is deleted. + סביר להניח שהחיבור הזה נמחק. + item status description + + + %@ and %@ connected + %@ ו-%@ מחוברים + No comment provided by engineer. + + + Connect via contact link + התחבר באמצעות קישור איש קשר + No comment provided by engineer. + + + Delivery receipts! + קבלות על המשלוח! + No comment provided by engineer. + + + Disable for all + השבת לכולם + No comment provided by engineer. + + + Error enabling delivery receipts! + שגיאה בהפעלת קבלות משלוח! + No comment provided by engineer. + + + Even when disabled in the conversation. + גם אם הוא מושבת בשיחה. + No comment provided by engineer. + + + Fix + תקן + No comment provided by engineer. + + + Fix connection + תקן את החיבור + No comment provided by engineer. + + + Find chats faster + מצא צ'אטים מהר יותר + No comment provided by engineer. + + + Fix connection? + לתקן את החיבור? + No comment provided by engineer. + + + Make one message disappear + העלם הודעה אחת + No comment provided by engineer. + + + Fix not supported by contact + תיקון לא נתמך על ידי איש קשר + No comment provided by engineer. + + + Incognito mode protects your privacy by using a new random profile for each contact. + מצב זהות נסתרת מגן על הפרטיות שלך על ידי שימוש בפרופיל אקראי חדש עבור כל איש קשר. + No comment provided by engineer. + + + In reply to + בתגובה ל + No comment provided by engineer. + + + Keep your connections + שימרו על הקשרים שלכם + No comment provided by engineer. + + + Message delivery receipts! + קבלות על הודעות! + No comment provided by engineer. + + + Message reactions are prohibited in this chat. + תגובות אמוג׳י להודעות אסורות בצ׳אט זה. + No comment provided by engineer. + + + Message reactions are prohibited in this group. + תגובות אמוג׳י להודעות אסורות בקבוצה זו. + No comment provided by engineer. + + + Delivery receipts are disabled! + קבלות על משלוח מושבתות! + No comment provided by engineer. + + + Disable (keep overrides) + השבת (שמור עקיפות) + No comment provided by engineer. + + + Don't enable + אל תפעיל + No comment provided by engineer. + + + Enable (keep overrides) + הפעל (שמור עקיפות) + No comment provided by engineer. + + + Enable for all + הפעל עבור כולם + No comment provided by engineer. + + + Error setting delivery receipts! + שגיאה בהגדרת קבלות משלוח! + No comment provided by engineer. + + + Filter unread and favorite chats. + סנן צ'אטים שלא נקראו וצ'אטים מועדפים. + No comment provided by engineer. + + + Moderated at + נחסם + No comment provided by engineer. + + + Message reactions + תגובות אמוג׳י להודעות + chat feature + + + Exporting database archive… + מייצא את ארכיון מסד הנתונים… + No comment provided by engineer. + + + %@, %@ and %lld other members connected + %@, %@ ו-%lld חברים אחרים מחוברים + No comment provided by engineer. + + + New display name + שם תצוגה חדש + No comment provided by engineer. + + + No delivery information + אין מידע על מסירה + No comment provided by engineer. + + + No filtered chats + אין צ'אטים מסוננים + No comment provided by engineer. + + + No history + ללא היסטוריה + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - 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 b28d7391d..1bba3acde 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -44,14 +44,17 @@ # %@ + # %@ copied message info title, # <title> ## History + ## 履歴 copied message info ## In reply to + ## 返信先 copied message info @@ -86,10 +89,12 @@ %@ and %@ connected + %@ と %@ は接続中 No comment provided by engineer. %1$@ at %2$@: + %1$@ at %2$@: copied message info, <sender> at <time> @@ -119,6 +124,7 @@ %@, %@ and %lld other members connected + %@, %@ および %lld 人のメンバーが接続中 No comment provided by engineer. @@ -325,6 +331,9 @@ - more stable message delivery. - a bit better groups. - and more! + - より安定したメッセージ配信。 +- 改良されたグループ。 +- などなど! No comment provided by engineer. @@ -405,6 +414,7 @@ A few more things + その他 No comment provided by engineer. @@ -414,6 +424,7 @@ A new random profile will be shared. + 新しいランダムなプロファイルが共有されます。 No comment provided by engineer. @@ -430,14 +441,17 @@ Abort + 中止 No comment provided by engineer. Abort changing address + アドレス変更の中止 No comment provided by engineer. Abort changing address? + アドレス変更を中止しますか? No comment provided by engineer. @@ -523,6 +537,7 @@ Address change will be aborted. Old receiving address will be used. + アドレス変更は中止されます。古い受信アドレスが使用されます。 No comment provided by engineer. @@ -617,6 +632,7 @@ Allow to send files and media. + ファイルやメディアの送信を許可する。 No comment provided by engineer. @@ -791,6 +807,7 @@ Better messages + より良いメッセージ No comment provided by engineer. @@ -1051,10 +1068,12 @@ Connect directly + 直接接続する No comment provided by engineer. Connect incognito + シークレットモードで接続 No comment provided by engineer. @@ -1159,6 +1178,7 @@ Contacts + 連絡先 No comment provided by engineer. @@ -1556,10 +1576,12 @@ Delivery + Delivery No comment provided by engineer. Delivery receipts are disabled! + Delivery receipts are disabled! No comment provided by engineer. @@ -1613,6 +1635,7 @@ Disable (keep overrides) + 無効にする(設定の優先を維持) No comment provided by engineer. @@ -1622,6 +1645,7 @@ Disable for all + すべて無効 No comment provided by engineer. @@ -1686,6 +1710,7 @@ Don't enable + 有効にしない No comment provided by engineer. @@ -1730,6 +1755,7 @@ Enable (keep overrides) + 有効にする(設定の優先を維持) No comment provided by engineer. @@ -1749,6 +1775,7 @@ Enable for all + すべて有効 No comment provided by engineer. @@ -1868,6 +1895,7 @@ Error aborting address change + アドレス変更中止エラー No comment provided by engineer. @@ -2065,6 +2093,7 @@ Error synchronizing connection + 接続の同期エラー No comment provided by engineer. @@ -2109,6 +2138,7 @@ Even when disabled in the conversation. + 会話中に無効になっている場合でも。 No comment provided by engineer. @@ -2148,6 +2178,7 @@ Favorite + お気に入り No comment provided by engineer. @@ -2177,50 +2208,62 @@ Files and media + ファイルとメディア chat feature Files and media are prohibited in this group. + このグループでは、ファイルとメディアは禁止されています。 No comment provided by engineer. Files and media prohibited! + ファイルとメディアは禁止されています! No comment provided by engineer. Filter unread and favorite chats. + 未読とお気に入りをフィルターします。 No comment provided by engineer. Finally, we have them! 🚀 + ついに、私たちはそれらを手に入れました! 🚀 No comment provided by engineer. Find chats faster + チャットを素早く検索 No comment provided by engineer. Fix + 修正 No comment provided by engineer. Fix connection + 接続を修正 No comment provided by engineer. Fix connection? + 接続を修正しますか? No comment provided by engineer. Fix encryption after restoring backups. + バックアップの復元後に暗号化を修正します。 No comment provided by engineer. Fix not supported by contact + 連絡先による修正はサポートされていません No comment provided by engineer. Fix not supported by group member + グループメンバーによる修正はサポートされていません No comment provided by engineer. @@ -2330,6 +2373,7 @@ Group members can send files and media. + グループメンバーはファイルやメディアを送信できます。 No comment provided by engineer. @@ -2529,6 +2573,7 @@ In reply to + 返信先 No comment provided by engineer. @@ -2543,6 +2588,7 @@ Incognito mode protects your privacy by using a new random profile for each contact. + シークレットモードとは、メインのプロフィールとプロフィール画像を守るために、新しい連絡先を追加する時に、その連絡先に対してランダムなプロフィールが作成されるという対策です。 No comment provided by engineer. @@ -2619,6 +2665,7 @@ Invalid status + 無効なステータス item status text @@ -2714,6 +2761,7 @@ Keep your connections + 接続を維持 No comment provided by engineer. @@ -2808,6 +2856,7 @@ Make one message disappear + メッセージを1つ消す No comment provided by engineer. @@ -2966,6 +3015,7 @@ Most likely this connection is deleted. + おそらく、この接続は削除されています。 item status description @@ -3075,6 +3125,7 @@ No delivery information + 送信情報なし No comment provided by engineer. @@ -3084,6 +3135,7 @@ No filtered chats + フィルタされたチャットはありません No comment provided by engineer. @@ -3093,6 +3145,7 @@ No history + 履歴はありません No comment provided by engineer. @@ -3181,6 +3234,7 @@ Only group owners can enable files and media. + ファイルやメディアを有効にできるのは、グループオーナーだけです。 No comment provided by engineer. @@ -3505,6 +3559,7 @@ Prohibit sending files and media. + ファイルやメディアの送信を禁止します。 No comment provided by engineer. @@ -3529,6 +3584,7 @@ Protocol timeout per KB + KB あたりのプロトコル タイムアウト No comment provided by engineer. @@ -3543,6 +3599,7 @@ React… + 反応する… chat item menu @@ -3601,6 +3658,7 @@ Receiving address will be changed to a different server. Address change will complete after sender comes online. + 開発中の機能です!相手のクライアントが4.2でなければ機能しません。アドレス変更が完了すると、会話にメッセージが出ます。連絡相手 (またはグループのメンバー) からメッセージを受信できないかをご確認ください。 No comment provided by engineer. @@ -3620,10 +3678,12 @@ Reconnect all connected servers to force message delivery. It uses additional traffic. + 接続されているすべてのサーバーを再接続して、メッセージを強制的に配信します。 追加のトラフィックを使用します。 No comment provided by engineer. Reconnect servers? + サーバーに再接続しますか? No comment provided by engineer. @@ -3688,14 +3748,17 @@ Renegotiate + 再ネゴシエート No comment provided by engineer. Renegotiate encryption + 暗号化の再ネゴシエート No comment provided by engineer. Renegotiate encryption? + 暗号化を再ネゴシエートしますか? No comment provided by engineer. @@ -4182,6 +4245,7 @@ Show last messages + 最新のメッセージを表示 No comment provided by engineer. @@ -4266,10 +4330,12 @@ Small groups (max 20) + 小グループ(最大20名) No comment provided by engineer. Some non-fatal errors occurred during import - you may see Chat console for more details. + インポート中に致命的でないエラーが発生しました - 詳細はチャットコンソールを参照してください。 No comment provided by engineer. @@ -4486,6 +4552,7 @@ 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. @@ -4525,6 +4592,7 @@ It can happen because of some bug or when the connection is compromised. The second tick we missed! ✅ + 長らくお待たせしました! ✅ No comment provided by engineer. @@ -4554,10 +4622,12 @@ It can happen because of some bug or when the connection is compromised. These settings are for your current profile **%@**. + これらの設定は現在のプロファイル **%@** 用です。 No comment provided by engineer. They can be overridden in contact and group settings. + これらは連絡先の設定が優先します。 No comment provided by engineer. @@ -4688,6 +4758,7 @@ You will be prompted to complete authentication before this feature is enabled.< Unfav. + お気に入りを取り消す。 No comment provided by engineer. @@ -4819,6 +4890,7 @@ To connect, please ask your contact to create another connection link and check Use current profile + 現在のプロファイルを使用する No comment provided by engineer. @@ -4833,6 +4905,7 @@ To connect, please ask your contact to create another connection link and check Use new incognito profile + 新しいシークレットプロファイルを使用する No comment provided by engineer. @@ -5047,10 +5120,12 @@ To connect, please ask your contact to create another connection link and check You can enable later via Settings + あとで設定から有効にできます No comment provided by engineer. You can enable them later via app Privacy & Security settings. + あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。 No comment provided by engineer. @@ -5309,6 +5384,7 @@ You can change it in Settings. Your profile **%@** will be shared. + あなたのプロファイル **%@** が共有されます。 No comment provided by engineer. @@ -5385,10 +5461,12 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 agreeing encryption for %@… + %@の暗号化に同意しています… chat item text agreeing encryption… + 暗号化に同意しています… chat item text @@ -5453,10 +5531,12 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 changing address for %@… + %@ のアドレスを変更しています… chat item text changing address… + アドレスを変更しています… chat item text @@ -5561,10 +5641,12 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 default (no) + デフォルト(いいえ) No comment provided by engineer. default (yes) + デフォルト(はい) No comment provided by engineer. @@ -5589,6 +5671,7 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 disabled + 無効 No comment provided by engineer. @@ -5618,34 +5701,42 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 encryption agreed + 暗号化に同意しました chat item text encryption agreed for %@ + %@ の暗号化に同意しました chat item text encryption ok + 暗号化OK chat item text encryption ok for %@ + %@ の暗号化OK chat item text encryption re-negotiation allowed + 暗号化の再ネゴシエーションを許可 chat item text encryption re-negotiation allowed for %@ + %@ の暗号化の再ネゴシエーションを許可 chat item text encryption re-negotiation required + 暗号化の再ネゴシエーションが必要 chat item text encryption re-negotiation required for %@ + %@ の暗号化の再ネゴシエーションが必要 chat item text @@ -5665,6 +5756,7 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 event happened + イベント発生 No comment provided by engineer. @@ -5834,6 +5926,7 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 no text + テキストなし copied message info in history @@ -5924,6 +6017,7 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 security code changed + セキュリティコードが変更されました chat item text diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - 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 b7a73f1f9..c32f79b78 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -244,7 +244,7 @@ %u messages failed to decrypt. - %u-berichten kunnen niet worden gedecodeerd. + %u berichten kunnen niet worden ontsleuteld. No comment provided by engineer. @@ -2713,7 +2713,7 @@ It can happen when you or your connection used the old database backup. - Het kan gebeuren wanneer u of uw verbinding de oude databaseback-up gebruikte. + Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt. No comment provided by engineer. @@ -4919,7 +4919,7 @@ Om verbinding te maken, vraagt u uw contactpersoon om een andere verbinding link Use new incognito profile - Gebruik een nieuw incognito -profiel + Gebruik een nieuw incognitoprofiel No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - 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 767dcccba..68bc9b929 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 %@ connected + %@ i %@ połączeni No comment provided by engineer. @@ -123,6 +124,7 @@ %@, %@ and %lld other members connected + %@, %@ i %lld innych członków połączeni No comment provided by engineer. @@ -4256,6 +4258,7 @@ Show last messages + Pokaż ostatnie wiadomości No comment provided by engineer. @@ -5767,6 +5770,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. event happened + nowe wydarzenie No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - 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 eda74c5fd..d77ef1e81 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 @@ -44,14 +44,17 @@ # %@ + # %@ copied message info title, # <title> ## History + ## 历史 copied message info ## In reply to + ## 回复 copied message info @@ -86,10 +89,12 @@ %@ and %@ connected + %@ 和%@ 以建立连接 No comment provided by engineer. %1$@ at %2$@: + %2$@: copied message info, <sender> at <time> @@ -119,6 +124,7 @@ %@, %@ and %lld other members connected + %@, %@ 和 %lld 个成员 No comment provided by engineer. @@ -325,6 +331,9 @@ - more stable message delivery. - a bit better groups. - and more! + - 更稳定的传输! +- 更好的社群! +- 以及更多! No comment provided by engineer. @@ -405,6 +414,7 @@ A few more things + No comment provided by engineer. @@ -414,6 +424,7 @@ A new random profile will be shared. + 创建一个随机的共享文件 No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 8b1378917..000000000 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index ef5a2e2d2..3d7d5f8fe 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -88,6 +88,15 @@ /* No comment provided by engineer. */ "*bold*" = "\\*tučně*"; +/* copied message info title, # */ +"# %@" = "# %@"; + +/* copied message info */ +"## History" = "## Historie"; + +/* copied message info */ +"## In reply to" = "## Odpovídáno"; + /* No comment provided by engineer. */ "#secret#" = "#tajný#"; @@ -106,6 +115,9 @@ /* No comment provided by engineer. */ "%@ %@" = "%@ %@"; +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ a %@ připojen"; + /* copied message info, <sender> at <time> */ "%@ at %@:" = "%1$@ na %2$@:"; @@ -124,6 +136,9 @@ /* notification title */ "%@ wants to connect!" = "%@ se chce připojit!"; +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ a %lld ostatní členové připojeni"; + /* copied message info */ "%@:" = "%@:"; @@ -282,7 +297,7 @@ "Accept" = "Přijmout"; /* No comment provided by engineer. */ -"Accept connection request?" = "Přijmout kontakt"; +"Accept connection request?" = "Přijmout kontakt?"; /* notification body */ "Accept contact request from %@?" = "Přijmout žádost o kontakt od %@?"; @@ -693,11 +708,17 @@ /* 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 kontaktní odkaz?"; +"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?"; @@ -709,7 +730,7 @@ "Connect via link / QR code" = "Připojit se prostřednictvím odkazu / QR kódu"; /* No comment provided by engineer. */ -"Connect via one-time link" = "Připojit se jednorázovým odkazem?"; +"Connect via one-time link" = "Připojit se jednorázovým odkazem"; /* No comment provided by engineer. */ "connected" = "připojeno"; @@ -1053,6 +1074,9 @@ /* rcv group event chat item */ "deleted group" = "odstraněna skupina"; +/* No comment provided by engineer. */ +"Delivery" = "Doručenka"; + /* No comment provided by engineer. */ "Delivery receipts are disabled!" = "Potvrzení o doručení jsou vypnuté!"; @@ -1722,6 +1746,9 @@ /* No comment provided by engineer. */ "Incognito mode" = "Režim inkognito"; +/* No comment provided by engineer. */ +"Incognito mode protects your privacy by using a new random profile for each contact." = "Režim inkognito chrání vaše soukromí používáním nového náhodného profilu pro každý kontakt."; + /* chat list item description */ "incognito via contact address link" = "inkognito přes odkaz na kontaktní adresu"; @@ -1785,6 +1812,9 @@ /* No comment provided by engineer. */ "Invalid server address!" = "Neplatná adresa serveru!"; +/* item status text */ +"Invalid status" = "Neplatný status"; + /* No comment provided by engineer. */ "Invitation expired!" = "Platnost pozvánky vypršela!"; @@ -1858,10 +1888,10 @@ "Join group" = "Připojit ke skupině"; /* No comment provided by engineer. */ -"Join incognito" = "Připojte se inkognito"; +"Join incognito" = "Připojit se inkognito"; /* No comment provided by engineer. */ -"Joining group" = "Připojení ke skupině"; +"Joining group" = "Připojování ke skupině"; /* No comment provided by engineer. */ "Keep your connections" = "Zachovat vaše připojení"; @@ -2046,6 +2076,9 @@ /* No comment provided by engineer. */ "More improvements are coming soon!" = "Další vylepšení se chystají již brzy!"; +/* item status description */ +"Most likely this connection is deleted." = "Pravděpodobně je toto spojení smazáno."; + /* No comment provided by engineer. */ "Most likely this contact has deleted the connection with you." = "Tento kontakt s největší pravděpodobností smazal spojení s vámi."; @@ -3071,7 +3104,7 @@ "These settings are for your current profile **%@**." = "Toto nastavení je pro váš aktuální profil **%@**."; /* No comment provided by engineer. */ -"They can be overridden in contact and group settings." = "Mohou být přepsány v nastavení kontaktů"; +"They can be overridden in contact and group settings." = "Mohou být přepsány v nastavení kontaktů."; /* No comment provided by engineer. */ "This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Tuto akci nelze vrátit zpět - všechny přijaté a odeslané soubory a média budou smazány. Obrázky s nízkým rozlišením zůstanou zachovány."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index d1cd5816a..e351114d7 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -281,7 +281,7 @@ "About SimpleX" = "Acerca de SimpleX"; /* No comment provided by engineer. */ -"About SimpleX address" = "Acerca de dirección SimpleX"; +"About SimpleX address" = "Acerca de la dirección SimpleX"; /* No comment provided by engineer. */ "About SimpleX Chat" = "Sobre SimpleX Chat"; @@ -832,7 +832,7 @@ "Create" = "Crear"; /* No comment provided by engineer. */ -"Create an address to let people connect with you." = "Crear una dirección para que otras personas se puedan conectar contigo."; +"Create an address to let people connect with you." = "Crea una dirección para que otras personas puedan conectar contigo."; /* server test step */ "Create file" = "Crear archivo"; @@ -853,10 +853,10 @@ "Create secret group" = "Crea grupo secreto"; /* No comment provided by engineer. */ -"Create SimpleX address" = "Crear dirección SimpleX"; +"Create SimpleX address" = "Crear tu dirección SimpleX"; /* No comment provided by engineer. */ -"Create your profile" = "Crear tu perfil"; +"Create your profile" = "Crea tu perfil"; /* No comment provided by engineer. */ "Created on %@" = "Creado en %@"; @@ -943,7 +943,7 @@ "days" = "días"; /* No comment provided by engineer. */ -"Decentralized" = "Descentralizado"; +"Decentralized" = "Descentralizada"; /* message decrypt error item */ "Decryption error" = "Error descifrado"; @@ -1162,7 +1162,7 @@ "Do NOT use SimpleX for emergency calls." = "NO uses SimpleX para llamadas de emergencia."; /* No comment provided by engineer. */ -"Don't create address" = "No crear dirección"; +"Don't create address" = "No crear dirección SimpleX"; /* No comment provided by engineer. */ "Don't enable" = "No activar"; @@ -1981,7 +1981,7 @@ "Mark verified" = "Marcar como verificado"; /* No comment provided by engineer. */ -"Markdown in messages" = "Sintaxis markdown en los mensajes"; +"Markdown in messages" = "Sintaxis Markdown"; /* marked deleted chat item preview text */ "marked deleted" = "marcado eliminado"; @@ -2510,7 +2510,7 @@ "Received message" = "Mensaje entrante"; /* No comment provided by engineer. */ -"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "La dirección de recepción se cambiará. El cambio se completará cuando el remitente esté en línea."; +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "La dirección de recepción pasará a otro servidor. El cambio se completará cuando el remitente esté en línea."; /* No comment provided by engineer. */ "Receiving file will be stopped." = "Se detendrá la recepción del archivo."; @@ -2966,7 +2966,7 @@ "Stop" = "Detener"; /* No comment provided by engineer. */ -"Stop chat to enable database actions" = "Para habilitar las acciones sobre la base de datos, previamente debes detener Chat"; +"Stop chat to enable database actions" = "Detén SimpleX para habilitar las acciones sobre la base de datos"; /* No comment provided by engineer. */ "Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Para poder exportar, importar o eliminar la base de datos primero debes detener Chat. Durante el tiempo que esté detenido no podrás recibir ni enviar mensajes."; @@ -3095,7 +3095,7 @@ "The message will be marked as moderated for all members." = "El mensaje será marcado como moderado para todos los miembros."; /* No comment provided by engineer. */ -"The next generation of private messaging" = "La próxima generación de mensajería privada"; +"The next generation of private messaging" = "La nueva generación de mensajería privada"; /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "La base de datos antigua no se eliminó durante la migración, puede eliminarse."; @@ -3464,7 +3464,7 @@ "You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button." = "También puedes conectarte haciendo clic en el enlace. Si se abre en el navegador, haz clic en el botón **Abrir en aplicación móvil**."; /* No comment provided by engineer. */ -"You can create it later" = "Puedes crearlo más tarde"; +"You can create it later" = "Puedes crearla más tarde"; /* No comment provided by engineer. */ "You can enable later via Settings" = "Puedes activar más tarde en Configuración"; @@ -3599,7 +3599,7 @@ "Your calls" = "Llamadas"; /* No comment provided by engineer. */ -"Your chat database" = "Base de datos Chat"; +"Your chat database" = "Base de datos"; /* 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."; @@ -3647,7 +3647,7 @@ "Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos.\nLos servidores de SimpleX no pueden ver tu perfil."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Tu perfil, contactos y mensajes entregados se almacenan en tu dispositivo."; +"Your profile, contacts and delivered messages are stored on your device." = "Tu perfil, contactos y mensajes se almacenan en tu dispositivo."; /* No comment provided by engineer. */ "Your random profile" = "Tu perfil aleatorio"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index b2428f5e7..9ce7245cd 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1273,7 +1273,7 @@ "encryption agreed for %@" = "chiffrement accepté pour %@"; /* chat item text */ -"encryption ok" = "chiffrement ok"; +"encryption ok" = "chiffrement OK"; /* chat item text */ "encryption ok for %@" = "chiffrement ok pour %@"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 7a00b096b..3d78a9f6e 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_斜体_"; +/* No comment provided by engineer. */ +"- more stable message delivery.\n- a bit better groups.\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- 編集履歴。"; @@ -85,6 +88,15 @@ /* No comment provided by engineer. */ "*bold*" = "\\*太文字*"; +/* copied message info title, # <title> */ +"# %@" = "# %@"; + +/* copied message info */ +"## History" = "## 履歴"; + +/* copied message info */ +"## In reply to" = "## 返信先"; + /* No comment provided by engineer. */ "#secret#" = "シークレット"; @@ -103,6 +115,12 @@ /* No comment provided by engineer. */ "%@ %@" = "%@ %@"; +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ と %@ は接続中"; + +/* copied message info, <sender> at <time> */ +"%@ at %@:" = "%1$@ at %2$@:"; + /* notification title */ "%@ is connected!" = "%@ 接続中!"; @@ -118,6 +136,9 @@ /* notification title */ "%@ wants to connect!" = "%@ が接続を希望しています!"; +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ および %lld 人のメンバーが接続中"; + /* copied message info */ "%@:" = "%@:"; @@ -232,15 +253,30 @@ /* No comment provided by engineer. */ "30 seconds" = "30秒"; +/* No comment provided by engineer. */ +"A few more things" = "その他"; + /* notification title */ "A new contact" = "新しい連絡先"; +/* No comment provided by engineer. */ +"A new random profile will be shared." = "新しいランダムなプロファイルが共有されます。"; + /* No comment provided by engineer. */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "**アプリ内のチャット プロフィールごとに**、個別の TCP 接続が使用されます。"; /* No comment provided by engineer. */ "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**アプリ内のチャット プロファイルごとに**、個別の TCP 接続が使用されます。\n**注意**:多くの接続がある場合、バッテリーと通信量の消費が大幅に増加し、一部の接続に失敗することがあります。"; +/* No comment provided by engineer. */ +"Abort" = "中止"; + +/* No comment provided by engineer. */ +"Abort changing address" = "アドレス変更の中止"; + +/* No comment provided by engineer. */ +"Abort changing address?" = "アドレス変更を中止しますか?"; + /* No comment provided by engineer. */ "About SimpleX" = "SimpleXについて"; @@ -296,6 +332,9 @@ /* No comment provided by engineer. */ "Address" = "アドレス"; +/* No comment provided by engineer. */ +"Address change will be aborted. Old receiving address will be used." = "アドレス変更は中止されます。古い受信アドレスが使用されます。"; + /* member role */ "admin" = "管理者"; @@ -305,6 +344,12 @@ /* No comment provided by engineer. */ "Advanced network settings" = "ネットワーク詳細設定"; +/* chat item text */ +"agreeing encryption for %@…" = "%@の暗号化に同意しています…"; + +/* chat item text */ +"agreeing encryption…" = "暗号化に同意しています…"; + /* No comment provided by engineer. */ "All app data is deleted." = "すべてのアプリデータが削除されます。"; @@ -353,6 +398,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages." = "送信済みメッセージの永久削除を許可する。"; +/* No comment provided by engineer. */ +"Allow to send files and media." = "ファイルやメディアの送信を許可する。"; + /* No comment provided by engineer. */ "Allow to send voice messages." = "音声メッセージの送信を許可する。"; @@ -467,6 +515,9 @@ /* No comment provided by engineer. */ "Bad message ID" = "メッセージ ID が正しくありません"; +/* No comment provided by engineer. */ +"Better messages" = "より良いメッセージ"; + /* No comment provided by engineer. */ "bold" = "太文字"; @@ -564,6 +615,12 @@ /* rcv group event chat item */ "changed your role to %@" = "あなたの役割を %@ に変更しました"; +/* chat item text */ +"changing address for %@…" = "%@ のアドレスを変更しています…"; + +/* chat item text */ +"changing address…" = "アドレスを変更しています…"; + /* No comment provided by engineer. */ "Chat archive" = "チャットのアーカイブ"; @@ -651,6 +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 開発者に接続します。"; @@ -750,6 +813,9 @@ /* No comment provided by engineer. */ "Contact preferences" = "連絡先の設定"; +/* No comment provided by engineer. */ +"Contacts" = "連絡先"; + /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "連絡先はメッセージを削除対象とすることができます。あなたには閲覧可能です。"; @@ -885,6 +951,12 @@ /* pref value */ "default (%@)" = "デフォルト (%@)"; +/* No comment provided by engineer. */ +"default (no)" = "デフォルト(いいえ)"; + +/* No comment provided by engineer. */ +"default (yes)" = "デフォルト(はい)"; + /* chat item action */ "Delete" = "削除"; @@ -1002,6 +1074,12 @@ /* rcv group event chat item */ "deleted group" = "削除されたグループ"; +/* No comment provided by engineer. */ +"Delivery" = "Delivery"; + +/* No comment provided by engineer. */ +"Delivery receipts are disabled!" = "Delivery receipts are disabled!"; + /* No comment provided by engineer. */ "Description" = "説明"; @@ -1035,9 +1113,18 @@ /* No comment provided by engineer. */ "Direct messages between members are prohibited in this group." = "このグループではメンバー間のダイレクトメッセージが使用禁止です。"; +/* No comment provided by engineer. */ +"Disable (keep overrides)" = "無効にする(設定の優先を維持)"; + +/* No comment provided by engineer. */ +"Disable for all" = "すべて無効"; + /* authentication reason */ "Disable SimpleX Lock" = "SimpleXロックを無効にする"; +/* No comment provided by engineer. */ +"disabled" = "無効"; + /* No comment provided by engineer. */ "Disappearing message" = "消えるメッセージ"; @@ -1074,6 +1161,9 @@ /* No comment provided by engineer. */ "Don't create address" = "アドレスを作成しないでください"; +/* No comment provided by engineer. */ +"Don't enable" = "有効にしない"; + /* No comment provided by engineer. */ "Don't show again" = "次から表示しない"; @@ -1104,9 +1194,15 @@ /* No comment provided by engineer. */ "Enable" = "有効"; +/* No comment provided by engineer. */ +"Enable (keep overrides)" = "有効にする(設定の優先を維持)"; + /* No comment provided by engineer. */ "Enable automatic message deletion?" = "自動メッセージ削除を有効にしますか?"; +/* No comment provided by engineer. */ +"Enable for all" = "すべて有効"; + /* No comment provided by engineer. */ "Enable instant notifications?" = "即時通知を有効にしますか?"; @@ -1167,6 +1263,30 @@ /* notification */ "Encrypted message: unexpected error" = "暗号化されたメッセージ : 予期しないエラー"; +/* chat item text */ +"encryption agreed" = "暗号化に同意しました"; + +/* chat item text */ +"encryption agreed for %@" = "%@ の暗号化に同意しました"; + +/* chat item text */ +"encryption ok" = "暗号化OK"; + +/* chat item text */ +"encryption ok for %@" = "%@ の暗号化OK"; + +/* chat item text */ +"encryption re-negotiation allowed" = "暗号化の再ネゴシエーションを許可"; + +/* chat item text */ +"encryption re-negotiation allowed for %@" = "%@ の暗号化の再ネゴシエーションを許可"; + +/* chat item text */ +"encryption re-negotiation required" = "暗号化の再ネゴシエーションが必要"; + +/* chat item text */ +"encryption re-negotiation required for %@" = "%@ の暗号化の再ネゴシエーションが必要"; + /* No comment provided by engineer. */ "ended" = "終了"; @@ -1200,6 +1320,9 @@ /* No comment provided by engineer. */ "Error" = "エラー"; +/* No comment provided by engineer. */ +"Error aborting address change" = "アドレス変更中止エラー"; + /* No comment provided by engineer. */ "Error accepting contact request" = "連絡先リクエストの承諾にエラー発生"; @@ -1311,6 +1434,9 @@ /* No comment provided by engineer. */ "Error switching profile!" = "プロフィール切り替えにエラー発生!"; +/* No comment provided by engineer. */ +"Error synchronizing connection" = "接続の同期エラー"; + /* No comment provided by engineer. */ "Error updating group link" = "グループのリンクのアップデートにエラー発生"; @@ -1335,6 +1461,12 @@ /* No comment provided by engineer. */ "Error: URL is invalid" = "エラー: 無効なURL"; +/* No comment provided by engineer. */ +"Even when disabled in the conversation." = "会話中に無効になっている場合でも。"; + +/* No comment provided by engineer. */ +"event happened" = "イベント発生"; + /* No comment provided by engineer. */ "Exit without saving" = "保存せずに閉じる"; @@ -1356,6 +1488,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "送信者がオンラインになるまでの待ち時間がなく、速い!"; +/* No comment provided by engineer. */ +"Favorite" = "お気に入り"; + /* No comment provided by engineer. */ "File will be deleted from servers." = "ファイルはサーバーから削除されます。"; @@ -1371,6 +1506,42 @@ /* No comment provided by engineer. */ "Files & media" = "ファイルとメディア"; +/* chat feature */ +"Files and media" = "ファイルとメディア"; + +/* No comment provided by engineer. */ +"Files and media are prohibited in this group." = "このグループでは、ファイルとメディアは禁止されています。"; + +/* No comment provided by engineer. */ +"Files and media prohibited!" = "ファイルとメディアは禁止されています!"; + +/* No comment provided by engineer. */ +"Filter unread and favorite chats." = "未読とお気に入りをフィルターします。"; + +/* No comment provided by engineer. */ +"Finally, we have them! 🚀" = "ついに、私たちはそれらを手に入れました! 🚀"; + +/* No comment provided by engineer. */ +"Find chats faster" = "チャットを素早く検索"; + +/* No comment provided by engineer. */ +"Fix" = "修正"; + +/* No comment provided by engineer. */ +"Fix connection" = "接続を修正"; + +/* No comment provided by engineer. */ +"Fix connection?" = "接続を修正しますか?"; + +/* No comment provided by engineer. */ +"Fix encryption after restoring backups." = "バックアップの復元後に暗号化を修正します。"; + +/* No comment provided by engineer. */ +"Fix not supported by contact" = "連絡先による修正はサポートされていません"; + +/* No comment provided by engineer. */ +"Fix not supported by group member" = "グループメンバーによる修正はサポートされていません"; + /* No comment provided by engineer. */ "For console" = "コンソール"; @@ -1437,6 +1608,9 @@ /* No comment provided by engineer. */ "Group members can send disappearing messages." = "グループのメンバーが消えるメッセージを送信できます。"; +/* No comment provided by engineer. */ +"Group members can send files and media." = "グループメンバーはファイルやメディアを送信できます。"; + /* No comment provided by engineer. */ "Group members can send voice messages." = "グループのメンバーが音声メッセージを送信できます。"; @@ -1560,12 +1734,18 @@ /* No comment provided by engineer. */ "Improved server configuration" = "サーバ設定の向上"; +/* No comment provided by engineer. */ +"In reply to" = "返信先"; + /* No comment provided by engineer. */ "Incognito" = "シークレットモード"; /* No comment provided by engineer. */ "Incognito mode" = "シークレットモード"; +/* No comment provided by engineer. */ +"Incognito mode protects your privacy by using a new random profile for each contact." = "シークレットモードとは、メインのプロフィールとプロフィール画像を守るために、新しい連絡先を追加する時に、その連絡先に対してランダムなプロフィールが作成されるという対策です。"; + /* chat list item description */ "incognito via contact address link" = "連絡先リンク経由でシークレットモード"; @@ -1629,6 +1809,9 @@ /* No comment provided by engineer. */ "Invalid server address!" = "無効なサーバアドレス!"; +/* item status text */ +"Invalid status" = "無効なステータス"; + /* No comment provided by engineer. */ "Invitation expired!" = "招待が期限切れました!"; @@ -1707,6 +1890,9 @@ /* No comment provided by engineer. */ "Joining group" = "グループに参加"; +/* No comment provided by engineer. */ +"Keep your connections" = "接続を維持"; + /* No comment provided by engineer. */ "Keychain error" = "キーチェーンのエラー"; @@ -1764,6 +1950,9 @@ /* No comment provided by engineer. */ "Make a private connection" = "プライベートな接続をする"; +/* No comment provided by engineer. */ +"Make one message disappear" = "メッセージを1つ消す"; + /* No comment provided by engineer. */ "Make profile private!" = "プロフィールを非表示にできます!"; @@ -1881,6 +2070,9 @@ /* No comment provided by engineer. */ "More improvements are coming soon!" = "まだまだ改善してまいります!"; +/* item status description */ +"Most likely this connection is deleted." = "おそらく、この接続は削除されています。"; + /* No comment provided by engineer. */ "Most likely this contact has deleted the connection with you." = "恐らくこの連絡先があなたとの接続を削除しました。"; @@ -1953,21 +2145,33 @@ /* No comment provided by engineer. */ "No contacts to add" = "追加できる連絡先がありません"; +/* No comment provided by engineer. */ +"No delivery information" = "送信情報なし"; + /* No comment provided by engineer. */ "No device token!" = "デバイストークンがありません!"; /* No comment provided by engineer. */ "no e2e encryption" = "エンドツーエンド暗号化がありません"; +/* No comment provided by engineer. */ +"No filtered chats" = "フィルタされたチャットはありません"; + /* No comment provided by engineer. */ "No group!" = "グループが見つかりません!"; +/* No comment provided by engineer. */ +"No history" = "履歴はありません"; + /* No comment provided by engineer. */ "No permission to record voice message" = "音声メッセージを録音する権限がありません"; /* No comment provided by engineer. */ "No received or sent files" = "送受信済みのファイルがありません"; +/* copied message info in history */ +"no text" = "テキストなし"; + /* No comment provided by engineer. */ "Notifications" = "通知"; @@ -2026,6 +2230,9 @@ /* No comment provided by engineer. */ "Only group owners can change group preferences." = "グループ設定を変えられるのはグループのオーナーだけです。"; +/* No comment provided by engineer. */ +"Only group owners can enable files and media." = "ファイルやメディアを有効にできるのは、グループオーナーだけです。"; + /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "音声メッセージを利用可能に設定できるのはグループのオーナーだけです。"; @@ -2227,6 +2434,9 @@ /* No comment provided by engineer. */ "Prohibit sending disappearing messages." = "消えるメッセージを使用禁止にする。"; +/* No comment provided by engineer. */ +"Prohibit sending files and media." = "ファイルやメディアの送信を禁止します。"; + /* No comment provided by engineer. */ "Prohibit sending voice messages." = "音声メッセージを使用禁止にする。"; @@ -2239,12 +2449,18 @@ /* No comment provided by engineer. */ "Protocol timeout" = "プロトコル・タイムアウト"; +/* No comment provided by engineer. */ +"Protocol timeout per KB" = "KB あたりのプロトコル タイムアウト"; + /* No comment provided by engineer. */ "Push notifications" = "プッシュ通知"; /* No comment provided by engineer. */ "Rate the app" = "アプリを評価"; +/* chat item menu */ +"React…" = "反応する…"; + /* No comment provided by engineer. */ "Read" = "読む"; @@ -2281,6 +2497,9 @@ /* message info title */ "Received message" = "受信したメッセージ"; +/* No comment provided by engineer. */ +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "開発中の機能です!相手のクライアントが4.2でなければ機能しません。アドレス変更が完了すると、会話にメッセージが出ます。連絡相手 (またはグループのメンバー) からメッセージを受信できないかをご確認ください。"; + /* No comment provided by engineer. */ "Receiving file will be stopped." = "ファイルの受信を停止します。"; @@ -2290,6 +2509,12 @@ /* No comment provided by engineer. */ "Recipients see updates as you type them." = "受信者には、入力時に更新内容が表示されます。"; +/* No comment provided by engineer. */ +"Reconnect all connected servers to force message delivery. It uses additional traffic." = "接続されているすべてのサーバーを再接続して、メッセージを強制的に配信します。 追加のトラフィックを使用します。"; + +/* No comment provided by engineer. */ +"Reconnect servers?" = "サーバーに再接続しますか?"; + /* No comment provided by engineer. */ "Record updated at" = "レコード更新日時"; @@ -2338,6 +2563,15 @@ /* rcv group event chat item */ "removed you" = "あなたを除名しました"; +/* No comment provided by engineer. */ +"Renegotiate" = "再ネゴシエート"; + +/* No comment provided by engineer. */ +"Renegotiate encryption" = "暗号化の再ネゴシエート"; + +/* No comment provided by engineer. */ +"Renegotiate encryption?" = "暗号化を再ネゴシエートしますか?"; + /* chat item action */ "Reply" = "返信"; @@ -2476,6 +2710,9 @@ /* No comment provided by engineer. */ "Security code" = "セキュリティコード"; +/* chat item text */ +"security code changed" = "セキュリティコードが変更されました"; + /* No comment provided by engineer. */ "Select" = "選択"; @@ -2614,6 +2851,9 @@ /* No comment provided by engineer. */ "Show developer options" = "開発者向けオプションを表示"; +/* No comment provided by engineer. */ +"Show last messages" = "最新のメッセージを表示"; + /* No comment provided by engineer. */ "Show preview" = "プレビューを表示"; @@ -2662,9 +2902,15 @@ /* No comment provided by engineer. */ "Skipped messages" = "飛ばしたメッセージ"; +/* No comment provided by engineer. */ +"Small groups (max 20)" = "小グループ(最大20名)"; + /* No comment provided by engineer. */ "SMP servers" = "SMPサーバ"; +/* No comment provided by engineer. */ +"Some non-fatal errors occurred during import - you may see Chat console for more details." = "インポート中に致命的でないエラーが発生しました - 詳細はチャットコンソールを参照してください。"; + /* notification title */ "Somebody" = "誰か"; @@ -2794,6 +3040,9 @@ /* No comment provided by engineer. */ "The created archive is available via app Settings / Database / Old database archive." = "作成されたアーカイブは、アプリの設定/データベース/過去のデータベースアーカイブから利用できます。"; +/* 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." = "グループは完全分散型で、メンバーしか内容を見れません。"; @@ -2818,6 +3067,9 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "プロフィールは連絡先にしか共有されません。"; +/* No comment provided by engineer. */ +"The second tick we missed! ✅" = "長らくお待たせしました! ✅"; + /* No comment provided by engineer. */ "The sender will NOT be notified" = "送信者には通知されません"; @@ -2833,6 +3085,12 @@ /* No comment provided by engineer. */ "There should be at least one visible user profile." = "少なくとも1つのユーザープロフィールが表示されている必要があります。"; +/* No comment provided by engineer. */ +"These settings are for your current profile **%@**." = "これらの設定は現在のプロファイル **%@** 用です。"; + +/* No comment provided by engineer. */ +"They can be overridden in contact and group settings." = "これらは連絡先の設定が優先します。"; + /* No comment provided by engineer. */ "This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "ファイルとメディアが全て削除されます (※元に戻せません※)。低解像度の画像が残ります。"; @@ -2908,6 +3166,9 @@ /* No comment provided by engineer. */ "Unexpected migration state" = "予期しない移行状態"; +/* No comment provided by engineer. */ +"Unfav." = "お気に入りを取り消す。"; + /* No comment provided by engineer. */ "Unhide" = "表示にする"; @@ -2986,12 +3247,18 @@ /* No comment provided by engineer. */ "Use chat" = "チャット"; +/* No comment provided by engineer. */ +"Use current profile" = "現在のプロファイルを使用する"; + /* No comment provided by engineer. */ "Use for new connections" = "新しい接続に使う"; /* No comment provided by engineer. */ "Use iOS call interface" = "iOS通話インターフェースを使用する"; +/* No comment provided by engineer. */ +"Use new incognito profile" = "新しいシークレットプロファイルを使用する"; + /* No comment provided by engineer. */ "Use server" = "サーバを使う"; @@ -3160,6 +3427,12 @@ /* No comment provided by engineer. */ "You can create it later" = "後からでも作成できます"; +/* No comment provided by engineer. */ +"You can enable later via Settings" = "あとで設定から有効にできます"; + +/* No comment provided by engineer. */ +"You can enable them later via app Privacy & Security settings." = "あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。"; + /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "ユーザープロファイルを右にスワイプすると、非表示またはミュートにすることができます。"; @@ -3328,6 +3601,9 @@ /* No comment provided by engineer. */ "Your privacy" = "あなたのプライバシー"; +/* No comment provided by engineer. */ +"Your profile **%@** will be shared." = "あなたのプロファイル **%@** が共有されます。"; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "プロフィールはデバイスに保存され、連絡先とのみ共有されます。\nSimpleX サーバーはあなたのプロファイルを参照できません。"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 79d7b619e..199afb842 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -209,7 +209,7 @@ "%lldw" = "%lldw"; /* No comment provided by engineer. */ -"%u messages failed to decrypt." = "%u-berichten kunnen niet worden gedecodeerd."; +"%u messages failed to decrypt." = "%u berichten kunnen niet worden ontsleuteld."; /* No comment provided by engineer. */ "%u messages skipped." = "%u berichten zijn overgeslagen."; @@ -1867,7 +1867,7 @@ "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 uw verbinding de oude databaseback-up gebruikte."; +"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."; /* 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 contactpersoon een oude databaseback-up heeft gebruikt.\n3. De verbinding is verbroken."; @@ -3299,7 +3299,7 @@ "Use iOS call interface" = "De iOS-oproepinterface gebruiken"; /* No comment provided by engineer. */ -"Use new incognito profile" = "Gebruik een nieuw incognito -profiel"; +"Use new incognito profile" = "Gebruik een nieuw incognitoprofiel"; /* No comment provided by engineer. */ "Use server" = "Gebruik server"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 4bf7ea862..fc20b1c7f 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -115,6 +115,9 @@ /* No comment provided by engineer. */ "%@ %@" = "%@ %@"; +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ i %@ połączeni"; + /* copied message info, <sender> at <time> */ "%@ at %@:" = "%1$@ o %2$@:"; @@ -133,6 +136,9 @@ /* notification title */ "%@ wants to connect!" = "%@ chce się połączyć!"; +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ i %lld innych członków połączeni"; + /* copied message info */ "%@:" = "%@:"; @@ -1467,6 +1473,9 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Nawet po wyłączeniu w rozmowie."; +/* No comment provided by engineer. */ +"event happened" = "nowe wydarzenie"; + /* No comment provided by engineer. */ "Exit without saving" = "Wyjdź bez zapisywania"; @@ -2881,6 +2890,9 @@ /* No comment provided by engineer. */ "Show developer options" = "Pokaż opcje dewelopera"; +/* No comment provided by engineer. */ +"Show last messages" = "Pokaż ostatnie wiadomości"; + /* No comment provided by engineer. */ "Show preview" = "Pokaż podgląd"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 474182846..a7f42837e 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. */ +"- more stable message delivery.\n- a bit better groups.\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- 编辑消息历史。"; @@ -85,6 +88,15 @@ /* No comment provided by engineer. */ "*bold*" = "\\*加粗*"; +/* copied message info title, # <title> */ +"# %@" = "# %@"; + +/* copied message info */ +"## History" = "## 历史"; + +/* copied message info */ +"## In reply to" = "## 回复"; + /* No comment provided by engineer. */ "#secret#" = "#秘密#"; @@ -103,6 +115,12 @@ /* No comment provided by engineer. */ "%@ %@" = "%@ %@"; +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ 和%@ 以建立连接"; + +/* copied message info, <sender> at <time> */ +"%@ at %@:" = "%2$@:"; + /* notification title */ "%@ is connected!" = "%@ 已连接!"; @@ -118,6 +136,9 @@ /* notification title */ "%@ wants to connect!" = "%@ 要连接!"; +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ 和 %lld 个成员"; + /* copied message info */ "%@:" = "%@:"; @@ -232,9 +253,15 @@ /* No comment provided by engineer. */ "30 seconds" = "30秒"; +/* No comment provided by engineer. */ +"A few more things" = ""; + /* notification title */ "A new contact" = "新联系人"; +/* No comment provided by engineer. */ +"A new random profile will be shared." = "创建一个随机的共享文件"; + /* No comment provided by engineer. */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "一个单独的 TCP 连接将被用于**您在应用程序中的每个聊天资料**。"; 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 03adcb4ec..3c39d8f80 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -24,7 +24,7 @@ <string name="member_role_will_be_changed_with_notification">سيتم تغيير الدور إلى \"%s\". سيتم إبلاغ كل فرد في المجموعة.</string> <string name="member_role_will_be_changed_with_invitation">سيتم تغيير الدور إلى \"%s\". سيتلقى العضو دعوة جديدة.</string> <string name="smp_servers_per_user">خوادم الاتصالات الجديدة لملف تعريف الدردشة الحالي الخاص بك</string> - <string name="switch_receiving_address_desc">هذه الميزة تجريبية! ستعمل فقط إذا كان لدى العميل الآخر الإصدار 4.2 مثبتًا. يجب أن ترى الرسالة في المحادثة بمجرد اكتمال تغيير العنوان - يرجى التحقق من أنه لا يزال بإمكانك تلقي الرسائل من جهة الاتصال هذه (أو عضو المجموعة).</string> + <string name="switch_receiving_address_desc">سيتم تغيير عنوان الاستلام إلى خادم مختلف. سيتم إكمال تغيير العنوان بعد اتصال المرسل بالإنترنت.</string> <string name="this_link_is_not_a_valid_connection_link">هذا الارتباط ليس ارتباط اتصال صالح!</string> <string name="allow_verb">يسمح</string> <string name="smp_servers_preset_add">أضف خوادم محددة مسبقًا</string> 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 badd8cbc3..4b4bac96d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1337,7 +1337,7 @@ <string name="v5_2_message_delivery_receipts_descr">Druhé zaškrtnutí jsme přehlédli! ✅</string> <string name="switch_receiving_address_desc">Přijímací adresa bude změněna na jiný server. Změna adresy bude dokončena po připojení odesílatele.</string> <string name="choose_file_title">Vybrat soubor</string> - <string name="connect_via_link_incognito">Připojit se inkognito</string> + <string name="connect_via_link_incognito">Spojit se inkognito</string> <string name="turn_off_battery_optimization_button">Povolit</string> <string name="disable_notifications_button">Vypnout upozornění</string> <string name="turn_off_system_restriction_button">Otevřít nastavení aplikace</string> @@ -1355,4 +1355,25 @@ <string name="send_receipts_disabled">vypnut</string> <string name="send_receipts_disabled_alert_title">Receipts jsou zakázány</string> <string name="in_developing_title">Již brzy!</string> + <string name="connect_use_current_profile">Použít aktuální profil</string> + <string name="connect_use_new_incognito_profile">Použít nový incognito profil</string> + <string name="system_restricted_background_desc">SimpleX nemůže běžet na pozadí. Pouze při spuštěné aplikaci obdržíte upozornění.</string> + <string name="system_restricted_background_warn"><![CDATA[Chcete-li povolit oznámení, vyberte prosím <b>Baterii</b> / <b>bez omezení</b> v nastavení aplikace.]]></string> + <string name="system_restricted_background_in_call_desc">Aplikace může být uzavřena po 1 minutě na pozadí.</string> + <string name="system_restricted_background_in_call_warn"><![CDATA[Chcete-li volat na pozadí, vyberte prosím <b>Baterii</b> / <b>bez omezení</b> v nastavení aplikace.]]></string> + <string name="connect__your_profile_will_be_shared">Váš profil %1$s bude sdílen.</string> + <string name="connect_via_member_address_alert_desc">Požadavek na připojení bude zaslán tomuto členu skupiny.</string> + <string name="delivery">Doručenka</string> + <string name="receipts_groups_title_disable">Zakázat doručenky pro skupiny\?</string> + <string name="receipts_groups_title_enable">Povolit doručenky pro skupiny\?</string> + <string name="receipts_groups_override_enabled">Odeslání doručenek je povoleno pro %d skupiny</string> + <string name="receipts_section_groups">Malé skupiny (max. 20)</string> + <string name="recipient_colon_delivery_status">%s: %s</string> + <string name="rcv_group_event_2_members_connected">%s a %s připojen</string> + <string name="rcv_group_event_3_members_connected">%s, %s a %s připojeni</string> + <string name="rcv_group_event_n_members_connected">%s, %s a %d dalších členů připojeno</string> + <string name="privacy_message_draft">Rozepsáno</string> + <string name="privacy_show_last_messages">Zobrazit poslední zprávy</string> + <string name="send_receipts_disabled_alert_msg">Tato skupina má více než %1$d členů, doručenky nejsou odeslány.</string> + <string name="in_developing_desc">Tato funkce zatím není podporována. Vyzkoušejte další vydání.</string> </resources> \ 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 b3630c236..dffad174f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -146,7 +146,7 @@ <string name="smp_server_test_disconnect">Desconectar</string> <string name="notification_preview_mode_contact">Contacto</string> <string name="copy_verb">Copiar</string> - <string name="create_your_profile">Crear tu perfil</string> + <string name="create_your_profile">Crea tu perfil</string> <string name="always_use_relay">Usar siempre retransmisor</string> <string name="set_password_to_export_desc">La base de datos está cifrada con una contraseña aleatoria. Cámbiala antes de exportar.</string> <string name="total_files_count_and_size">%d archivo(s) con tamaño total de %s</string> @@ -192,7 +192,7 @@ <string name="delete_address__question">¿Eliminar la dirección\?</string> <string name="display_name__field">Nombre mostrado:</string> <string name="callstate_connecting">conectando…</string> - <string name="decentralized">Descentralizado</string> + <string name="decentralized">Descentralizada</string> <string name="database_will_be_encrypted">La base de datos será cifrada.</string> <string name="delete_chat_archive_question">¿Eliminar archivo del chat\?</string> <string name="create_group_link">Crear enlace de grupo</string> @@ -485,7 +485,7 @@ <string name="mark_unread">Marcar como no leído</string> <string name="invalid_QR_code">Código QR inválido</string> <string name="incorrect_code">¡Código de seguridad incorrecto!</string> - <string name="markdown_in_messages">Sintaxis markdown en los mensajes</string> + <string name="markdown_in_messages">Sintaxis Markdown</string> <string name="network_use_onion_hosts_no">No</string> <string name="callstatus_missed">llamada perdida</string> <string name="import_database_confirmation">Importar</string> @@ -717,7 +717,7 @@ <string name="la_notice_title_simplex_lock">Bloqueo SimpleX</string> <string name="auth_unlock">Desbloquear</string> <string name="this_text_is_available_in_settings">Este texto está disponible en Configuración</string> - <string name="switch_receiving_address_desc">La dirección de recepción se cambiará. El cambio se completará cuando el remitente esté en línea.</string> + <string name="switch_receiving_address_desc">La dirección de recepción pasará a otro servidor. El cambio se completará cuando el remitente esté en línea.</string> <string name="chat_lock">Bloqueo SimpleX</string> <string name="using_simplex_chat_servers">Usando servidores SimpleX Chat.</string> <string name="network_session_mode_transport_isolation">Aislamiento de transporte</string> @@ -749,11 +749,11 @@ <string name="share_invitation_link">Compartir enlace de un uso</string> <string name="update_network_session_mode_question">¿Actualizar el modo de aislamiento de transporte\?</string> <string name="icon_descr_speaker_on">Altavoz activado</string> - <string name="stop_chat_to_enable_database_actions">Para habilitar las acciones sobre la base de datos, previamente debes detener Chat</string> + <string name="stop_chat_to_enable_database_actions">Detén SimpleX para habilitar las acciones sobre la base de datos.</string> <string name="connection_you_accepted_will_be_cancelled">¡La conexión que has aceptado se cancelará!</string> <string name="database_initialization_error_desc">La base de datos no funciona correctamente. Pulsa para saber más</string> <string name="moderate_message_will_be_marked_warning">El mensaje será marcado como moderado para todos los miembros.</string> - <string name="next_generation_of_private_messaging">La próxima generación de mensajería privada</string> + <string name="next_generation_of_private_messaging">La nueva generación de mensajería privada</string> <string name="delete_files_and_media_desc">Esta acción no se puede deshacer. Se eliminarán todos los archivos y multimedia recibidos y enviados. Las imágenes de baja resolución permanecerán.</string> <string name="enable_automatic_deletion_message">Esta acción no se puede deshacer. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Puede tardar varios minutos.</string> <string name="messages_section_description">Esta configuración se aplica a los mensajes del perfil actual</string> @@ -914,7 +914,7 @@ <string name="your_settings">Configuración</string> <string name="your_SMP_servers">Servidores SMP</string> <string name="you_control_your_chat">¡Tú controlas tu chat!</string> - <string name="your_profile_is_stored_on_your_device">Tu perfil, contactos y mensajes entregados se almacenan en tu dispositivo.</string> + <string name="your_profile_is_stored_on_your_device">Tu perfil, contactos y mensajes se almacenan en tu dispositivo.</string> <string name="callstate_waiting_for_answer">esperando respuesta…</string> <string name="callstate_waiting_for_confirmation">esperando confirmación…</string> <string name="onboarding_notifications_mode_off">Cuando la aplicación se está ejecutando</string> @@ -923,7 +923,7 @@ <string name="your_ice_servers">Servidores ICE</string> <string name="your_privacy">Privacidad</string> <string name="settings_section_title_you">MIS DATOS</string> - <string name="your_chat_database">Base de datos Chat</string> + <string name="your_chat_database">Base de datos</string> <string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Puedes iniciar el chat en Configuración / Base de datos o reiniciando la aplicación.</string> <string name="you_sent_group_invitation">Has enviado una invitación de grupo</string> <string name="num_contacts_selected">%d contacto(s) seleccionado(s)</string> @@ -1126,7 +1126,7 @@ <string name="learn_more">Más información</string> <string name="if_you_cant_meet_in_person">Si no puedes reunirte en persona, **muestra el código QR por videollamada**, o comparte el enlace.</string> <string name="scan_qr_to_connect_to_contact">Para conectarse, tu contacto puede escanear el código QR o usar el enlace en la aplicación.</string> - <string name="create_simplex_address">Crear dirección SimpleX</string> + <string name="create_simplex_address">Crear tu dirección SimpleX</string> <string name="auto_accept_contact">Auto aceptar</string> <string name="group_welcome_preview">Vista previa</string> <string name="opening_database">Abriendo base de datos…</string> @@ -1144,15 +1144,15 @@ <string name="export_theme">Exportar tema</string> <string name="color_surface">Menús y alertas</string> <string name="add_address_to_your_profile">Añade la dirección a tu perfil para que tus contactos puedan compartirla con otros. La actualización del perfil se enviará a tus contactos.</string> - <string name="learn_more_about_address">Acerca de dirección SimpleX</string> + <string name="learn_more_about_address">Acerca de la dirección SimpleX</string> <string name="address_section_title">Dirección</string> <string name="all_your_contacts_will_remain_connected_update_sent">Todos tus contactos permanecerán conectados. La actualización del perfil se enviará a tus contactos.</string> <string name="continue_to_next_step">Continuar</string> <string name="dark_theme">Tema oscuro</string> <string name="customize_theme_title">Personalizar tema</string> <string name="enter_welcome_message_optional">Introduce mensaje de bienvenida… (opcional)</string> - <string name="create_address_and_let_people_connect">Crear una dirección para que otras personas se puedan conectar contigo.</string> - <string name="dont_create_address">No crear dirección</string> + <string name="create_address_and_let_people_connect">Crea una dirección para que otras personas puedan conectar contigo.</string> + <string name="dont_create_address">No crear dirección SimpleX</string> <string name="email_invite_body">¡Hola! \nConecta conmigo a través de SimpleX Chat: %s</string> <string name="import_theme">Importar tema</string> @@ -1170,7 +1170,7 @@ <string name="stop_sharing">Dejar de compartir</string> <string name="stop_sharing_address">¿Dejar de compartir la dirección\?</string> <string name="theme_colors_section_title">COLORES DEL TEMA</string> - <string name="you_can_create_it_later">Puedes crearlo más tarde</string> + <string name="you_can_create_it_later">Puedes crearla más tarde</string> <string name="share_address_with_contacts_question">¿Compartir la dirección con los contactos\?</string> <string name="share_with_contacts">Compartir con contactos</string> <string name="color_title">Título</string> 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 ef116dce8..cee4837a7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -82,7 +82,7 @@ <string name="info_row_connection">Yhteys</string> <string name="ttl_d">%dd</string> <string name="ttl_days">%d päivää</string> - <string name="disappearing_prohibited_in_this_chat">Tuhoutuvat viestit ovat kiellettyjä tässä keskustelussa.</string> + <string name="disappearing_prohibited_in_this_chat">Katoavat viestit ovat kiellettyjä tässä keskustelussa.</string> <string name="network_session_mode_user_description"><![CDATA[Erillistä TCP-yhteyttä (ja SOCKS-tunnistetietoja) käytetään <b>jokaisessa käyttämässäsi sovelluksen chat-profiilissa</b>.]]></string> <string name="app_version_code">Sovellusversio: %s</string> <string name="delete_address">Poista osoite</string> @@ -140,7 +140,7 @@ <string name="delete_verb">Poista</string> <string name="delete_message__question">Poista viesti\?</string> <string name="group_connection_pending">yhdistää…</string> - <string name="disappearing_message">Tuhoutuva viesti</string> + <string name="disappearing_message">Katoava viesti</string> <string name="send_disappearing_message_custom_time">Mukautettu aika</string> <string name="delete_contact_menu_action">Poista</string> <string name="delete_group_menu_action">Poista</string> @@ -158,7 +158,7 @@ <string name="settings_developer_tools">Kehittäjän työkalut</string> <string name="cannot_access_keychain">Ei pääsyä Keystoreen tietokannan salasanan tallentamiseksi</string> <string name="share_text_database_id">Tietokannan tunnus: %d</string> - <string name="info_row_disappears_at">Tuhoutuu klo</string> + <string name="info_row_disappears_at">Katoaa klo</string> <string name="chat_database_deleted">Keskustelujen tietokanta poistettu</string> <string name="delete_chat_profile_question">Poista keskusteluprofiili\?</string> <string name="delete_messages_after">Poista viestit tämän jälkeen</string> @@ -187,7 +187,7 @@ <string name="audio_call_no_encryption">äänipuhelu (ei e2e-salattu)</string> <string name="always_use_relay">Käytä aina relettä</string> <string name="allow_your_contacts_to_send_disappearing_messages">Salli kontaktiesi lähettää katoavia viestejä.</string> - <string name="timed_messages">Tuhoutuvat viestit</string> + <string name="timed_messages">Katoavat viestit</string> <string name="icon_descr_context">Kontekstikuvake</string> <string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><![CDATA[<b>Skannaa QR-koodi</b>: muodostaaksesi yhteyden kontaktiisi, joka näyttää QR-koodin sinulle.]]></string> <string name="icon_descr_cancel_live_message">Peruuta live-viesti</string> @@ -199,7 +199,7 @@ <string name="settings_audio_video_calls">Ääni- ja videopuhelut</string> <string name="call_on_lock_screen">Puhelut lukitusnäytöllä:</string> <string name="conn_level_desc_direct">suora</string> - <string name="disappearing_messages_are_prohibited">Tuhoutuvat viestit ovat kiellettyjä tässä ryhmässä.</string> + <string name="disappearing_messages_are_prohibited">Katoavat viestit ovat kiellettyjä tässä ryhmässä.</string> <string name="server_connected">yhdistetty</string> <string name="display_name_connecting">yhdistää…</string> <string name="display_name_connection_established">yhteys luotu</string> @@ -240,7 +240,7 @@ <string name="both_you_and_your_contact_can_send_disappearing">Sekä sinä että kontaktisi voitte lähettää katoavia viestejä.</string> <string name="chat_preferences_contact_allows">Kontakti sallii</string> <string name="chat_preferences_default">oletus (%s)</string> - <string name="v4_4_disappearing_messages">Tuhoutuvat viestit</string> + <string name="v4_4_disappearing_messages">Katoavat viestit</string> <string name="copied">Kopioitu leikepöydälle</string> <string name="share_one_time_link">Luo kertaluonteinen kutsulinkki</string> <string name="mtr_error_no_down_migration">tietokantaversio on uudempi kuin sovellus, mutta ei alaspäin siirtymistä: %s</string> @@ -336,7 +336,7 @@ <string name="change_verb">Muuta</string> <string name="item_info_current">(nykyinen)</string> <string name="share_text_deleted_at">Poistettu: %s</string> - <string name="share_text_disappears_at">Tuhoutuu klo: %s</string> + <string name="share_text_disappears_at">Katoaa klo: %s</string> <string name="create_secret_group_title">Luo salainen ryhmä</string> <string name="chat_preferences_always">aina</string> <string name="cant_delete_user_profile">Käyttäjäprofiilia ei voi poistaa!</string> @@ -406,7 +406,7 @@ <string name="join_group_question">Liity ryhmään\?</string> <string name="network_option_enable_tcp_keep_alive">Ota TCP-säilytys käyttöön</string> <string name="incognito">Incognito</string> - <string name="incognito_info_protects">Incognito-tila suojaa pääprofiilisi nimen ja kuvan yksityisyyttä – jokaiselle uudelle yhteyshenkilölle luodaan uusi satunnainen profiili.</string> + <string name="incognito_info_protects">Incognito-tila suojaa yksityisyyttäsi käyttämällä uutta satunnaista profiilia jokaiselle kontaktille.</string> <string name="ttl_mth">%dmth</string> <string name="ttl_m">%dm</string> <string name="v4_6_group_welcome_message">Ryhmän tervetuloviesti</string> @@ -451,7 +451,7 @@ <string name="import_database_question">Tuo keskustelujen-tietokanta\?</string> <string name="file_with_path">Tiedosto: %s</string> <string name="error_removing_member">Virhe poistettaessa jäsentä</string> - <string name="message_deletion_prohibited">Viestien peruuttamaton poistaminen on kielletty tässä keskustelussa.</string> + <string name="message_deletion_prohibited">Viestien peruuttamaton poisto on kielletty tässä keskustelussa.</string> <string name="join_group_button">Liity</string> <string name="join_group_incognito_button">Liity incognito-tilassa</string> <string name="joining_group">Liittyy ryhmään</string> @@ -474,10 +474,10 @@ <string name="image_will_be_received_when_contact_is_online">Kuva vastaanotetaan, kun kontaktisi on verkossa, odota tai tarkista myöhemmin!</string> <string name="if_you_cant_meet_in_person">Jos et voi tavata henkilökohtaisesti, näytä QR-koodi videopuhelussa tai jaa linkki.</string> <string name="onboarding_notifications_mode_subtitle">Voit muuttaa sitä myöhemmin asetuksista.</string> - <string name="encrypt_database_question">Salataanko tietokanta\?</string> + <string name="encrypt_database_question">Salaa tietokanta\?</string> <string name="button_edit_group_profile">Muokkaa ryhmäprofiilia</string> <string name="delete_group_for_all_members_cannot_undo_warning">Ryhmä poistetaan kaikilta jäseniltä - tätä ei voi kumota!</string> - <string name="delete_group_for_self_cannot_undo_warning">Ryhmä poistetaan sinulta - tätä ei voi peruuttaa!</string> + <string name="delete_group_for_self_cannot_undo_warning">Ryhmä poistetaan sinulta - tätä ei voi perua!</string> <string name="error_creating_link_for_group">Virhe ryhmälinkin luomisessa</string> <string name="info_row_group">Ryhmä</string> <string name="incognito_info_allows">Se mahdollistaa useiden nimettömien yhteyksien muodostamisen yhdessä keskusteluprofiilissa ilman, että niiden välillä on jaettuja tietoja.</string> @@ -514,7 +514,7 @@ <string name="onboarding_notifications_mode_service">Välitön</string> <string name="encrypted_audio_call">e2e-salattu äänipuhelu</string> <string name="allow_accepting_calls_from_lock_screen">Ota puhelut käyttöön lukitusnäytöltä asetuksista.</string> - <string name="status_e2e_encrypted">e2e salattu</string> + <string name="status_e2e_encrypted">e2e-salattu</string> <string name="settings_section_title_incognito">Incognito-tila</string> <string name="error_with_info">Virhe: %s</string> <string name="alert_message_group_invitation_expired">Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen.</string> @@ -543,15 +543,15 @@ <string name="downgrade_and_open_chat">Alenna ja avaa chat</string> <string name="icon_descr_group_inactive">Ei-aktiivinen ryhmä</string> <string name="v4_3_improved_privacy_and_security_desc">Piilota sovellusnäyttö viimeisimmissä sovelluksissa.</string> - <string name="v4_5_italian_interface">Italian käyttöliittymä</string> - <string name="v5_0_large_files_support_descr">Nopea ja ei odota, kunnes lähettäjä on online-tilassa!</string> + <string name="v4_5_italian_interface">Italialainen käyttöliittymä</string> + <string name="v5_0_large_files_support_descr">Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa!</string> <string name="v4_6_reduced_battery_usage">Entisestä vähentynyt akun käyttö</string> <string name="v5_1_japanese_portuguese_interface">Japanin ja portugalin käyttöliittymä</string> <string name="error_saving_file">Virhe tiedoston tallentamisessa</string> <string name="icon_descr_help">apua</string> <string name="incorrect_code">Väärä turvakoodi!</string> <string name="error_sending_message">Virhe viestin lähettämisessä</string> - <string name="turn_off_battery_optimization"><![CDATA[Jotta voit käyttää sitä, <b>poista akun optimointi käytöstä</b> kohteelle SimpleX seuraavassa valintaikkunassa. Muussa tapauksessa ilmoitukset poistetaan käytöstä.]]></string> + <string name="turn_off_battery_optimization"><![CDATA[Käyttääksesi sitä, <b> salli SimpleX:n toimia taustalla </b> seuraavassa ikkunassa. Muutoin ilmoitukset poistetaan käytöstä.]]></string> <string name="notification_preview_mode_hidden">Piilotettu</string> <string name="la_immediately">Heti</string> <string name="la_enter_app_passcode">Syötä pääsykoodi</string> @@ -570,7 +570,7 @@ <string name="enable_lock">Ota lukitus käyttöön</string> <string name="group_invitation_item_description">kutsu ryhmään %1$s</string> <string name="icon_descr_add_members">Kutsu jäseniä</string> - <string name="group_invitation_expired">Ryhmäkutsu on vanhentunut</string> + <string name="group_invitation_expired">Vanhentunut ryhmäkutsu</string> <string name="rcv_group_event_member_added">kutsuttu %1$s</string> <string name="group_full_name_field">Ryhmän koko nimi:</string> <string name="full_name_optional__prompt">Koko nimi (valinnainen)</string> @@ -592,7 +592,7 @@ <string name="section_title_for_console">KONSOLIIN</string> <string name="group_member_status_group_deleted">poistettu ryhmä</string> <string name="snd_group_event_group_profile_updated">ryhmäprofiili päivitetty</string> - <string name="alert_title_group_invitation_expired">Kutsu on vanhentunut!</string> + <string name="alert_title_group_invitation_expired">Vanhentunut kutsu!</string> <string name="group_member_status_invited">kutsuttu</string> <string name="conn_level_desc_indirect">epäsuora (%1$s)</string> <string name="group_display_name_field">Ryhmän näyttönimi:</string> @@ -602,10 +602,10 @@ <string name="group_members_can_delete">Ryhmän jäsenet voivat poistaa lähetetyt viestit peruuttamattomasti.</string> <string name="group_members_can_send_dms">Ryhmän jäsenet voivat lähettää suoraviestejä.</string> <string name="group_members_can_send_voice">Ryhmän jäsenet voivat lähettää ääniviestejä.</string> - <string name="message_deletion_prohibited_in_chat">Viestien peruuttamaton poistaminen on kielletty tässä ryhmässä.</string> + <string name="message_deletion_prohibited_in_chat">Viestien peruuttamaton poisto on kielletty tässä ryhmässä.</string> <string name="ttl_months">%d kuukautta</string> <string name="ttl_sec">%d sek</string> - <string name="v5_1_message_reactions_descr">Vihdoinkin meillä on ne! 🚀</string> + <string name="v5_1_message_reactions_descr">Vihdoinkin meillä! 🚀</string> <string name="custom_time_unit_hours">tuntia</string> <string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Tarkista, että käytit oikeaa linkkiä tai pyydä kontaktiasi lähettämään sinulle uusi linkki.</string> <string name="sender_may_have_deleted_the_connection_request">Lähettäjä on saattanut poistaa yhteyspyynnön.</string> @@ -619,7 +619,7 @@ <string name="notification_preview_mode_message">Viestin teksti</string> <string name="notification_preview_mode_contact_desc">Näytä vain kontakti</string> <string name="notification_preview_new_message">uusi viesti</string> - <string name="la_notice_title_simplex_lock">Simplex Lock</string> + <string name="la_notice_title_simplex_lock">SimpleX Lock</string> <string name="auth_simplex_lock_turned_on">SimpleX Lock päällä</string> <string name="auth_open_chat_console">Avaa keskustelukonsoli</string> <string name="message_delivery_error_title">Viestin toimitusvirhe</string> @@ -848,7 +848,7 @@ <string name="read_more_in_github_with_link"><![CDATA[Lue lisää <font color="#0088ff">GitHub-arkistostamme</font>.]]></string> <string name="onboarding_notifications_mode_periodic">Säännölliset</string> <string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages"><![CDATA[Vain asiakaslaitteet tallentavat käyttäjäprofiileja, yhteystietoja, ryhmiä ja viestejä, jotka on lähetetty <b>2-kerroksisella päästä päähän -salauksella</b>.]]></string> - <string name="read_more_in_github">Lue lisää GitHub-arkistostamme.</string> + <string name="read_more_in_github">Lue lisää GitHub-tietovarastostamme.</string> <string name="paste_the_link_you_received">Liitä vastaanotettu linkki</string> <string name="relay_server_protects_ip">Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa.</string> <string name="open_simplex_chat_to_accept_call">Avaa SimpleX Chat hyväksyäksesi puhelun</string> @@ -1007,7 +1007,7 @@ <string name="prohibit_message_reactions">Estä viestireaktiot.</string> <string name="prohibit_sending_disappearing_messages">Estä katoavien viestien lähettäminen.</string> <string name="accept_feature_set_1_day">Aseta 1 päivä</string> - <string name="only_your_contact_can_make_calls">Vain yhteyshenkilösi voi soittaa puheluita.</string> + <string name="only_your_contact_can_make_calls">Vain kontaktisi voi soittaa puheluita.</string> <string name="prohibit_calls">Estä ääni- ja videopuhelut.</string> <string name="prohibit_direct_messages">Estä suorien viestien lähettäminen jäsenille.</string> <string name="message_reactions_prohibited_in_this_chat">Viestireaktiot ovat kiellettyjä tässä keskustelussa.</string> @@ -1043,7 +1043,7 @@ <string name="xftp_servers">XFTP-palvelimet</string> <string name="smp_servers_your_server">Palvelimesi</string> <string name="smp_servers_your_server_address">Palvelimesi osoite</string> - <string name="enable_automatic_deletion_message">Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Se voi kestää useita minuutteja.</string> + <string name="enable_automatic_deletion_message">Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja.</string> <string name="whats_new">Uusimmat</string> <string name="you_will_still_receive_calls_and_ntfs">Saat edelleen puheluita ja ilmoituksia mykistetyiltä profiileilta, kun ne ovat aktiivisia.</string> <string name="network_disable_socks">Käytä suoraa Internet-yhteyttä\?</string> @@ -1199,7 +1199,7 @@ <string name="videos_limit_title">Liikaa videoita!</string> <string name="voice_message">Ääniviesti</string> <string name="waiting_for_video">Odottaa videota</string> - <string name="switch_receiving_address_desc">Tämä ominaisuus on kokeellinen! Se toimii vain, jos toisella on asennettuna versio 4.2. Sinun pitäisi nähdä viesti keskustelussa, kun osoitteenmuutos on valmis - tarkista, että voit edelleen vastaanottaa viestejä kyseiseltä kontaktilta (tai ryhmän jäseneltä).</string> + <string name="switch_receiving_address_desc">Vastaanotto-osoite vaihdetaan toiseen palvelimeen. Osoitteenmuutos tehdään sen jälkeen, kun lähettäjä tulee verkkoon.</string> <string name="you_need_to_allow_to_send_voice">Sinun on sallittava kontaktiesi lähettää ääniviestejä, jotta voit lähettää niitä.</string> <string name="you_can_connect_to_simplex_chat_founder"><![CDATA[Voit <font color="#0088ff">olla yhteydessä SimpleX Chatin -kehittäjiin kysyäksesi kysymyksiä ja saadaksesi päivityksiä</font>.]]></string> <string name="this_QR_code_is_not_a_link">Tämä QR-koodi ei ole linkki!</string> @@ -1260,4 +1260,118 @@ <string name="settings_restart_app">Käynnistä uudelleen</string> <string name="shutdown_alert_question">Sulje\?</string> <string name="la_mode_off">Pois</string> + <string name="v5_2_message_delivery_receipts">Viestien toimituskuittaukset!</string> + <string name="v5_2_more_things_descr">- vakaampi viestien toimitus. +\n- hieman paremmat ryhmät. +\n- ja paljon muuta!</string> + <string name="delivery_receipts_are_disabled">Toimituskuittaukset poissa käytöstä!</string> + <string name="you_can_enable_delivery_receipts_later">Voit ottaa käyttöön myöhemmin asetusten kautta</string> + <string name="you_can_enable_delivery_receipts_later_alert">Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys & Turvallisuus -asetuksista.</string> + <string name="error_aborting_address_change">Virhe osoitteenmuutoksen keskeytyksessä</string> + <string name="abort_switch_receiving_address">Keskeytä osoitteenvaihto</string> + <string name="network_option_protocol_timeout_per_kb">Protokollan aikakatkaisu per KB</string> + <string name="files_are_prohibited_in_group">Tiedostot ja media ovat tässä ryhmässä kiellettyjä.</string> + <string name="group_members_can_send_files">Ryhmän jäsenet voivat lähettää tiedostoja ja mediaa.</string> + <string name="connect_via_link_incognito">Yhdistä Incognito</string> + <string name="connect_use_current_profile">Käytä nykyistä profiilia</string> + <string name="connect_use_new_incognito_profile">Käytä uutta incognito-profiilia</string> + <string name="turn_off_battery_optimization_button">Salli</string> + <string name="disable_notifications_button">Poista ilmoitukset käytöstä</string> + <string name="turn_off_system_restriction_button">Avaa asetukset</string> + <string name="system_restricted_background_desc">SimpleX ei toimi tausta-ajossa. Saat ilmoitukset ainostaan, kun sovellus on käynnissä.</string> + <string name="system_restricted_background_warn"><![CDATA[Ilmoitusten sallimiseksi valitse <b> Sovelluksen akun käyttö </b> / <b> rajoittamaton </b> sovellusasetuksista.]]></string> + <string name="system_restricted_background_in_call_title">Ei taustapuheluita</string> + <string name="system_restricted_background_in_call_desc">Sovellus voi sulkeutua 1 minuutin jälkeen tausta-ajossa.</string> + <string name="system_restricted_background_in_call_warn"><![CDATA[Puheluiden soittamiseksi taustalla, valitse <b>Sovelluksen akun käyttö </b> / <b> rajoittamaton </b> sovellusasetuksista.]]></string> + <string name="in_reply_to">Vastauksena</string> + <string name="abort_switch_receiving_address_question">Keskeytä osoitteenvaihto\?</string> + <string name="favorite_chat">Suosikki</string> + <string name="unfavorite_chat">Epäsuosikki</string> + <string name="connect__a_new_random_profile_will_be_shared">Uusi satunnainen profiili jaetaan.</string> + <string name="paste_the_link_you_received_to_connect_with_your_contact">Liitä linkki, jonka sait yhteydenottoon kontaktisi kanssa…</string> + <string name="connect__your_profile_will_be_shared">Profiilisi %1$s jaetaan.</string> + <string name="receipts_groups_title_disable">Kuittaukset pois käytöstä ryhmiltä\?</string> + <string name="in_developing_title">Tulossa pian!</string> + <string name="receipts_groups_disable_for_all">Poista käytöstä kaikilta ryhmiltä</string> + <string name="files_and_media">Tiedostot ja media</string> + <string name="receipts_groups_enable_keep_overrides">Salli (pidä ryhmäohitukset)</string> + <string name="sending_delivery_receipts_will_be_enabled_all_profiles">"Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille näkyvissä keskusteluprofiileissa."</string> + <string name="receipts_groups_override_enabled">Kuittauksien lähettäminen on käytössä %d ryhmille</string> + <string name="abort_switch_receiving_address_confirm">Keskeytä</string> + <string name="sync_connection_force_confirm">Uudelleenneuvottele</string> + <string name="sync_connection_force_question">Uudelleenneuvottele salaus\?</string> + <string name="sync_connection_force_desc">Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin!</string> + <string name="receipts_section_description">Nämä asetukset koskevat nykyistä profiiliasi</string> + <string name="receipts_section_description_1">Ne voidaan ohittaa kontakti- ja ryhmäasetuksissa.</string> + <string name="conn_event_ratchet_sync_ok">salaus ok</string> + <string name="conn_event_ratchet_sync_allowed">salauksen uudelleenneuvottelu sallittu</string> + <string name="fix_connection_confirm">Korjaa</string> + <string name="fix_connection_not_supported_by_contact">Kontakti ei tue korjausta</string> + <string name="fix_connection_not_supported_by_group_member">Ryhmän jäsen ei tue korjausta</string> + <string name="renegotiate_encryption">Uudelleenneuvottele salaus</string> + <string name="in_developing_desc">Tätä ominaisuutta ei vielä tueta. Kokeile seuraavaa versiota.</string> + <string name="delivery">Toimitus</string> + <string name="no_info_on_delivery">Ei toimitustietoja</string> + <string name="no_filtered_chats">Ei suodatettuja keskusteluja</string> + <string name="no_selected_chat">Ei valittua keskustelua</string> + <string name="only_owners_can_enable_files_and_media">Vain ryhmän omistajat voivat sallia tiedostoja ja mediaa.</string> + <string name="files_and_media_prohibited">Tiedostot ja media kielletty!</string> + <string name="abort_switch_receiving_address_desc">Osoitteenmuutos keskeytetään. Käytetään vanhaa vastaanotto-osoitetta.</string> + <string name="choose_file_title">Valitse tiedosto</string> + <string name="receipts_section_groups">Pienryhmät (max 20)</string> + <string name="send_receipts_disabled_alert_title">Kuittaukset pois käytöstä</string> + <string name="send_receipts_disabled_alert_msg">Ryhmällä on yli %1$d jäsentä, toimituskuittauksia ei lähetetä.</string> + <string name="recipient_colon_delivery_status">%s: %s</string> + <string name="v5_2_more_things">Muutama asia lisää</string> + <string name="v5_2_fix_encryption">Pidä kontaktisi</string> + <string name="receipts_section_contacts">Kontaktit</string> + <string name="receipts_contacts_disable_for_all">Poista käytöstä kaikilta</string> + <string name="receipts_contacts_enable_for_all">Salli kaikille</string> + <string name="receipts_contacts_enable_keep_overrides">Salli (pidä ohitukset)</string> + <string name="receipts_contacts_disable_keep_overrides">Poista käytöstä (pidä ohitukset)</string> + <string name="receipts_contacts_title_disable">Kuittaukset pois käytöstä\?</string> + <string name="receipts_contacts_title_enable">Salli kuittaukset\?</string> + <string name="receipts_contacts_override_disabled">Kuittauksien lähettäminen on pois käytöstä %d kontakteilta</string> + <string name="receipts_contacts_override_enabled">Kuittauksien lähettäminen on käytössä %d kontakteille</string> + <string name="settings_section_title_delivery_receipts">LÄHETÄ TOIMITUSKUITTAUKSET VASTAANOTTAJALLE</string> + <string name="rcv_conn_event_verification_code_reset">turvakoodi on muuttunut</string> + <string name="conn_event_ratchet_sync_started">hyväksyy salausta…</string> + <string name="snd_conn_event_ratchet_sync_allowed">salauksen uudelleenneuvottelu sallittu %s:lle</string> + <string name="conn_event_ratchet_sync_required">tarvitaan salauksen uudelleenneuvottelua</string> + <string name="snd_conn_event_ratchet_sync_started">hyväksyy salausta %s:lle…</string> + <string name="conn_event_ratchet_sync_agreed">salaus sovittu</string> + <string name="snd_conn_event_ratchet_sync_agreed">salaus sovittu %s:lle</string> + <string name="snd_conn_event_ratchet_sync_ok">salaus ok %s:lle</string> + <string name="snd_conn_event_ratchet_sync_required">tarvitaan salauksen uudelleenneuvottelua %s:lle</string> + <string name="sender_at_ts">"%s klo %s"</string> + <string name="send_receipts">Lähetä kuittaukset</string> + <string name="fix_connection">Korjaa yhteys</string> + <string name="fix_connection_question">Korjaa yhteys\?</string> + <string name="allow_to_send_files">Salli tiedostojen ja median lähettäminen.</string> + <string name="prohibit_sending_files">Estä tiedostojen ja median lähettäminen.</string> + <string name="v5_2_disappear_one_message">Hävitä yksi viesti</string> + <string name="v5_2_fix_encryption_descr">Korjaa salaus varmuuskopioiden palauttamisen jälkeen.</string> + <string name="v5_2_disappear_one_message_descr">Jopa kun ei käytössä keskustelussa.</string> + <string name="receipts_groups_disable_keep_overrides">Poista käytöstä (pidä ryhmäohitukset)</string> + <string name="receipts_groups_enable_for_all">Salli kaikille ryhmille</string> + <string name="receipts_groups_title_enable">Salli kuittaukset ryhmille\?</string> + <string name="privacy_message_draft">Viestiluonnos</string> + <string name="receipts_groups_override_disabled">Kuittauksien lähettäminen on pois käytöstä %d ryhmiltä</string> + <string name="privacy_show_last_messages">Näytä viimeiset viestit</string> + <string name="send_receipts_disabled">ei käytössä</string> + <string name="rcv_group_event_2_members_connected">%s ja %s yhdistetty</string> + <string name="rcv_group_event_n_members_connected">%s, %s ja %d muut jäsenet yhdistetty</string> + <string name="rcv_group_event_3_members_connected">%s, %s ja %s yhdistetty</string> + <string name="dont_enable_receipts">Älä salli</string> + <string name="error_enabling_delivery_receipts">Virhe toimituskuittauksien sallimisessa!</string> + <string name="connect_via_member_address_alert_title">Yhdistä suoraan\?</string> + <string name="connect_via_member_address_alert_desc">Yhteyspyyntö lähetetään tälle ryhmän jäsenelle.</string> + <string name="delivery_receipts_title">Toimituskuittaukset!</string> + <string name="enable_receipts_all">Salli</string> + <string name="v5_2_favourites_filter_descr">Suodata lukemattomia- ja suosikkikeskusteluja.</string> + <string name="v5_2_favourites_filter">Löydä keskustelut nopeammin</string> + <string name="sending_delivery_receipts_will_be_enabled">Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille.</string> + <string name="v5_2_message_delivery_receipts_descr">Toinen kuittaus, joka uupui! ✅</string> + <string name="error_synchronizing_connection">Virhe yhteyden synkronoinnissa</string> + <string name="no_history">Ei historiaa</string> </resources> \ 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 562d51acf..1611b413f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1316,7 +1316,7 @@ <string name="receipts_contacts_override_enabled">L\'envoi d\'accusés de réception est activé pour les contacts de %d</string> <string name="conn_event_ratchet_sync_started">accord sur le chiffrement…</string> <string name="conn_event_ratchet_sync_agreed">chiffrement accepté</string> - <string name="conn_event_ratchet_sync_ok">chiffrement ok</string> + <string name="conn_event_ratchet_sync_ok">chiffrement OK</string> <string name="conn_event_ratchet_sync_allowed">renégociation de chiffrement autorisée</string> <string name="conn_event_ratchet_sync_required">renégociation de chiffrement requise</string> <string name="snd_conn_event_ratchet_sync_ok">chiffrement ok pour %s</string> 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 2367ae81a..8467199bf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -689,7 +689,7 @@ <string name="old_database_archive">ארכיון מסד נתונים ישן</string> <string name="chat_item_ttl_none">לעולם לא</string> <string name="no_received_app_files">לא התקבלו או נשלחו קבצים</string> - <string name="new_member_role">תפקיד חבר קבוצה</string> + <string name="new_member_role">תפקיד חבר קבוצה חדש</string> <string name="no_contacts_selected">לא נבחרו אנשי קשר</string> <string name="member_info_section_title_member">חבר קבוצה</string> <string name="member_will_be_removed_from_group_cannot_be_undone">חבר הקבוצה יוסר מהקבוצה – לא ניתן לבטל זאת!</string> @@ -1257,7 +1257,7 @@ <string name="your_preferences">ההעדפות שלך</string> <string name="you_will_still_receive_calls_and_ntfs">עדיין תקבלו שיחות והתראות מפרופילים מושתקים כאשר הם פעילים.</string> <string name="abort_switch_receiving_address_confirm">בטל</string> - <string name="abort_switch_receiving_address_question">בטל שינוי כתובת\?</string> + <string name="abort_switch_receiving_address_question">האם לבטל שינוי כתובת\?</string> <string name="abort_switch_receiving_address_desc">שינוי הכתובת יבוטל. ייעשה שימוש בכתובת הקבלה הישנה.</string> <string name="shutdown_alert_desc">ההתראות יפסיקו לפעול עד שתפעיל את האפליקציה מחדש</string> <string name="abort_switch_receiving_address">בטל שינוי כתובת</string> 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 ca0905652..b82f10b25 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -167,7 +167,6 @@ <string name="icon_descr_call_ended">通話が終了しました。</string> <string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link"><![CDATA[直接会えない時は、 <b>ビデオ通話中にQRコードを見せてもらうか</b>、招待リンクを送ってもらえば相手に繋がります。]]></string> <string name="member_will_be_removed_from_group_cannot_be_undone">メンバーをグループから除名する (※元に戻せません※)!</string> - <string name="message_delivery_error_title">メッセージ送信エラー</string> <string name="call_on_lock_screen">通話をロック画面に表示</string> <string name="icon_descr_cancel_link_preview">リンクのプレビューを中止</string> <string name="icon_descr_cancel_live_message">ライブメッセージを中止</string> @@ -485,7 +484,7 @@ <string name="smp_server_test_create_queue">サーバの待ち行列を作成する</string> <string name="smp_server_test_delete_queue">待ち行列を削除</string> <string name="smp_server_test_disconnect">切断</string> - <string name="turn_off_battery_optimization"><![CDATA[利用するには次の画面にてSimpleXに対する <b>電気省電力の設定をオフ</b> for SimpleX してください。そうしないと通知が無効になります。]]></string> + <string name="turn_off_battery_optimization"><![CDATA[利用するには次の画面にてSimpleXに対する <b>SimpleX のバックグラウンドでの実行を許可</b> してください。そうしないと通知が無効になります。]]></string> <string name="database_initialization_error_title">データベースを起動できません。</string> <string name="settings_notification_preview_title">通知のプレビュー</string> <string name="simplex_service_notification_text">メッセージ受信中…</string> @@ -588,7 +587,7 @@ <string name="error_removing_member">メンバー除名にエラー発生</string> <string name="conn_level_desc_indirect">関節 (%1$s)</string> <string name="incognito">シークレットモード</string> - <string name="incognito_info_protects">シークレットモードとは、メインのプロフィールとプロフィール画像を守るために、新しい連絡先を追加する時に、その連絡先に対してランダムなプロフィールが作成されるという対策です。</string> + <string name="incognito_info_protects">シークレット モードでは、連絡先ごとに新しいランダムなプロファイルを使用してプライバシーを保護します。</string> <string name="chat_preferences_no">いいえ</string> <string name="chat_preferences_on">オン</string> <string name="direct_messages">ダイレクトメッセージ</string> @@ -904,7 +903,7 @@ <string name="simplex_link_mode">SimpleXリンク</string> <string name="settings_section_title_support">SIMPLEX CHATを支援</string> <string name="smp_servers_test_servers">テストサーバ</string> - <string name="switch_receiving_address_desc">開発中の機能です!相手のクライアントが4.2でなければ機能しません。アドレス変更が完了すると、会話にメッセージが出ます。連絡相手 (またはグループのメンバー) からメッセージを受信できないかをご確認ください。</string> + <string name="switch_receiving_address_desc">受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。</string> <string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery"><![CDATA[あなたのプライバシーを守るために、このアプリはプッシュ通知の変わりに <b>SimpleX バックグラウンド・サービス</b> を使ってます。一日の電池使用量は約3%です。]]></string> <string name="to_protect_privacy_simplex_has_ids_for_queues">あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。</string> <string name="group_main_profile_sent">あなたのチャットプロフィールが他のグループメンバーに送られます。</string> @@ -1258,9 +1257,9 @@ <string name="choose_file_title">ファイルを選択</string> <string name="unfavorite_chat">お気に入りを解除</string> <string name="favorite_chat">お気に入り</string> - <string name="receipts_contacts_override_enabled">連絡先 %d に対して既読の通知が有効です</string> + <string name="receipts_contacts_override_enabled">Sending receipts is enabled for %d contacts</string> <string name="receipts_contacts_enable_for_all">すべて有効</string> - <string name="receipts_contacts_override_disabled">連絡先 %d に対して既読の通知が無効です</string> + <string name="receipts_contacts_override_disabled">Sending receipts is disabled for %d contacts</string> <string name="receipts_contacts_disable_for_all">すべて無効</string> <string name="settings_shutdown">終了</string> <string name="conn_event_ratchet_sync_started">暗号化に同意しています…</string> @@ -1289,7 +1288,7 @@ <string name="error_aborting_address_change">アドレス変更中止エラー</string> <string name="no_filtered_chats">フィルタされたチャットはありません</string> <string name="files_and_media_prohibited">ファイルとメディアは禁止されています!</string> - <string name="abort_switch_receiving_address_desc">住所変更は中止されます。古い受信アドレスが使用されます。</string> + <string name="abort_switch_receiving_address_desc">アドレス変更は中止されます。古い受信アドレスが使用されます。</string> <string name="abort_switch_receiving_address_question">アドレス変更を中止しますか?</string> <string name="settings_section_title_app">アプリ</string> <string name="non_fatal_errors_occured_during_import">インポート中に致命的でないエラーが発生しました - 詳細はチャットコンソールを参照してください。</string> @@ -1304,18 +1303,17 @@ <string name="v5_2_fix_encryption_descr">バックアップの復元後に暗号化を修正します。</string> <string name="snd_conn_event_ratchet_sync_started">暗号化に同意しています: %s</string> <string name="conn_event_ratchet_sync_agreed">暗号化に同意しました</string> - <string name="receipts_contacts_title_disable">既読通知を無効にしますか?</string> + <string name="receipts_contacts_title_disable">Disable receipts\?</string> <string name="fix_connection_not_supported_by_contact">連絡先による修正はサポートされていません</string> <string name="fix_connection_not_supported_by_group_member">グループメンバーによる修正はサポートされていません</string> <string name="receipts_section_contacts">連絡先</string> - <string name="receipts_section_description_1">これらは連絡先の設定が優先します</string> + <string name="receipts_section_description_1">これらは連絡先とグループの設定が優先されます。</string> <string name="receipts_section_description">これらの設定は現在のプロファイル用です</string> - <string name="receipts_contacts_title_enable">既読通知を有効にしますか?</string> + <string name="receipts_contacts_title_enable">Enable receipts\?</string> <string name="sender_at_ts">%s : %s</string> <string name="fix_connection">接続を修正</string> <string name="files_and_media">ファイルとメディア</string> <string name="you_can_enable_delivery_receipts_later_alert">あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。</string> - <string name="error_enabling_delivery_receipts">既読通知の有効化でエラーが発生しました!</string> <string name="item_info_no_text">テキストなし</string> <string name="error_synchronizing_connection">接続の同期エラー</string> <string name="no_history">履歴はありません</string> @@ -1326,4 +1324,56 @@ <string name="only_owners_can_enable_files_and_media">ファイルやメディアを有効にできるのは、グループオーナーだけです。</string> <string name="sync_connection_force_confirm">再ネゴシエート</string> <string name="settings_restart_app">再起動</string> + <string name="connect_via_link_incognito">シークレットモードで接続</string> + <string name="turn_off_battery_optimization_button">許可</string> + <string name="connect_via_member_address_alert_title">直接接続しますか\?</string> + <string name="connect__a_new_random_profile_will_be_shared">新しいランダムなプロファイルが共有されます。</string> + <string name="delivery">送信</string> + <string name="in_developing_title">近日公開!</string> + <string name="v5_2_disappear_one_message">メッセージを1つ消す</string> + <string name="connect_use_current_profile">現在のプロファイルを使用する</string> + <string name="connect_use_new_incognito_profile">新しいシークレットプロファイルを使用する</string> + <string name="turn_off_system_restriction_button">アプリの設定を開く</string> + <string name="disable_notifications_button">通知を無効にする</string> + <string name="system_restricted_background_warn"><![CDATA[通知を有効にするには、アプリの設定で<b>アプリのバッテリー使用量</b> / <b>制限なし</b> を選択してください。]]></string> + <string name="system_restricted_background_in_call_desc">アプリはバックグラウンドで1分経過すると終了します。</string> + <string name="system_restricted_background_in_call_warn"><![CDATA[バックグラウンドで通話を行うには、アプリの設定で<b>アプリのバッテリー使用量</b> / <b>制限なし</b> を選択してください。]]></string> + <string name="connect__your_profile_will_be_shared">あなたのプロフィール %1$s が共有されます。</string> + <string name="receipts_groups_title_enable">Enable receipts for groups\?</string> + <string name="receipts_groups_title_disable">Disable receipts for groups\?</string> + <string name="receipts_groups_override_enabled">Sending receipts is enabled for %d groups</string> + <string name="receipts_groups_override_disabled">Sending receipts is disabled for %d groups</string> + <string name="send_receipts_disabled">無効</string> + <string name="system_restricted_background_in_call_title">バックグラウンド通話なし</string> + <string name="paste_the_link_you_received_to_connect_with_your_contact">受信したリンクを貼り付け、連絡先に接続する。</string> + <string name="rcv_group_event_2_members_connected">%s と %s は接続中</string> + <string name="system_restricted_background_desc">SimpleXはバックグラウンドでは動作できません。アプリが起動している時のみ通知を受け取ることができます。</string> + <string name="no_info_on_delivery">送信情報なし</string> + <string name="no_selected_chat">チャットが選択されていません</string> + <string name="receipts_section_groups">小グループ(最大20名)</string> + <string name="receipts_groups_enable_for_all">すべてのグループで有効にする</string> + <string name="recipient_colon_delivery_status">%s: %s</string> + <string name="connect_via_member_address_alert_desc">接続リクエストはこのグループ メンバーに送信されます。</string> + <string name="receipts_groups_disable_for_all">すべてのグループで無効にする</string> + <string name="privacy_message_draft">メッセージの下書き</string> + <string name="privacy_show_last_messages">最新のメッセージを表示</string> + <string name="send_receipts">Send receipts</string> + <string name="rcv_group_event_n_members_connected">%s, %s および %d 人の他のメンバーが接続しています。</string> + <string name="rcv_group_event_3_members_connected">%s, %s と %s は接続中</string> + <string name="in_developing_desc">この機能はまだサポートされていません。次のリリースをお試しください。</string> + <string name="receipts_contacts_enable_keep_overrides">有効にする(設定の優先を維持)</string> + <string name="receipts_groups_disable_keep_overrides">無効にする(グループの設定の優先を維持)</string> + <string name="receipts_groups_enable_keep_overrides">有効にする(グループの設定の優先を維持)</string> + <string name="receipts_contacts_disable_keep_overrides">無効にする(設定の優先を維持)</string> + <string name="v5_2_disappear_one_message_descr">会話中に無効になっている場合でも。</string> + <string name="v5_2_message_delivery_receipts">Message delivery receipts!</string> + <string name="delivery_receipts_title">Delivery receipts!</string> + <string name="sending_delivery_receipts_will_be_enabled_all_profiles">Sending delivery receipts will be enabled for all contacts in all visible chat profiles.</string> + <string name="message_delivery_error_title">Message delivery error</string> + <string name="send_receipts_disabled_alert_title">Receipts are disabled</string> + <string name="settings_section_title_delivery_receipts">SEND DELIVERY RECEIPTS TO</string> + <string name="sending_delivery_receipts_will_be_enabled">Sending delivery receipts will be enabled for all contacts.</string> + <string name="send_receipts_disabled_alert_msg">This group has over %1$d members, delivery receipts are not sent.</string> + <string name="error_enabling_delivery_receipts">Error enabling delivery receipts!</string> + <string name="delivery_receipts_are_disabled">Delivery receipts are disabled!</string> </resources> \ 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 791a2ff64..e69ad19fb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1086,13 +1086,13 @@ <string name="decryption_error">Decodering fout</string> <string name="alert_text_msg_bad_hash">De hash van het vorige bericht is anders.</string> <string name="alert_text_decryption_error_too_many_skipped">%1$d berichten overgeslagen.</string> - <string name="alert_text_fragment_encryption_out_of_sync_old_database">Het kan gebeuren wanneer u of uw verbinding de oude databaseback-up gebruikte.</string> + <string name="alert_text_fragment_encryption_out_of_sync_old_database">Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt.</string> <string name="alert_text_fragment_please_report_to_developers">Meld het alsjeblieft aan de ontwikkelaars.</string> <string name="alert_title_msg_bad_hash">Onjuiste bericht hash</string> <string name="alert_title_msg_bad_id">Onjuiste bericht-ID</string> <string name="alert_text_msg_bad_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.</string> - <string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d-berichten konden niet worden ontsleuteld.</string> + <string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d berichten konden niet worden ontsleuteld.</string> <string name="no_spaces">Geen spaties!</string> <string name="stop_rcv_file__message">Het ontvangen van het bestand wordt gestopt.</string> <string name="revoke_file__confirm">Intrekken</string> @@ -1369,7 +1369,7 @@ <string name="connect_via_member_address_alert_desc">Verzoek voor het verbinden wordt naar dit groepslid verzonden.</string> <string name="paste_the_link_you_received_to_connect_with_your_contact">Plak de link die je hebt ontvangen om verbinding te maken met je contact…</string> <string name="system_restricted_background_warn"><![CDATA[Als u meldingen wilt inschakelen, kiest u <b> App-batterijgebruik</b> /<b> Onbeperkt</b> in de app-instellingen.]]></string> - <string name="connect_use_new_incognito_profile">Gebruik een nieuw incognito -profiel</string> + <string name="connect_use_new_incognito_profile">Gebruik een nieuw incognito profiel</string> <string name="privacy_message_draft">Concept bericht</string> <string name="rcv_group_event_n_members_connected">%s, %s en %d andere leden verbonden</string> <string name="privacy_show_last_messages">Laat laatste berichten zien</string> 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 2b735f594..07836dc98 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 @@ -414,7 +414,7 @@ <string name="initial_member_role">Função inicial</string> <string name="snd_group_event_group_profile_updated">perfil do grupo atualizado</string> <string name="group_member_status_group_deleted">Grupo excluído</string> - <string name="incognito_info_protects">O modo de navegação anônima protege a privacidade do nome e da imagem do seu perfil principal — para cada novo contato, um novo perfil aleatório é criado.</string> + <string name="incognito_info_protects">O modo Incognito protege sua privacidade usando um novo perfil aleatório para cada contato.</string> <string name="group_members_can_delete">Os membros do grupo podem excluir mensagens enviadas de forma irreversível.</string> <string name="ttl_w">%dsemana</string> <string name="v4_3_improved_server_configuration">Configuração de servidor aprimorada</string> @@ -631,7 +631,7 @@ <string name="images_limit_desc">Apenas 10 imagens podem ser enviadas ao mesmo tempo</string> <string name="notifications">Notificações</string> <string name="text_field_set_contact_placeholder">Definir nome do contato…</string> - <string name="switch_receiving_address_desc">Esse recurso é experimental! Ele só funcionará se o outro cliente tiver a versão 4.2 instalada. Você deve ver a mensagem na conversa assim que a alteração de endereço for concluída – verifique se você ainda pode receber mensagens desse contato (ou membro do grupo).</string> + <string name="switch_receiving_address_desc">O endereço de recebimento será alterado para um servidor diferente. A mudança de endereço terminará após o remetente entrar on-line.</string> <string name="send_verb">Enviar</string> <string name="reset_verb">Redefinir</string> <string name="live_message">Mensagem ao vivo!</string> @@ -1327,4 +1327,28 @@ <string name="enable_receipts_all">Ativar</string> <string name="sending_delivery_receipts_will_be_enabled">Enviar confirmações de entrega serão ativadas para todos os contatos.</string> <string name="error_enabling_delivery_receipts">Ocorreu um erro ao ativar as confirmações de entrega!</string> + <string name="choose_file_title">Escolher arquivo</string> + <string name="connect_via_link_incognito">Conectar incógnito</string> + <string name="turn_off_battery_optimization_button">Permitir</string> + <string name="disable_notifications_button">Desativar notificações</string> + <string name="system_restricted_background_in_call_title">Sem chamadas de fundo</string> + <string name="turn_off_system_restriction_button">Abrir configurações do aplicativo</string> + <string name="connect__a_new_random_profile_will_be_shared">Um novo perfil aleatório será compartilhado.</string> + <string name="paste_the_link_you_received_to_connect_with_your_contact">Cole o link que você recebeu para se conectar com seu contato..</string> + <string name="receipts_groups_title_disable">Desativar recibos para grupos\?</string> + <string name="receipts_groups_title_enable">Ativar recibos para grupos\?</string> + <string name="receipts_groups_override_disabled">Recibos de entrega estão desativados para %d grupos</string> + <string name="receipts_groups_enable_for_all">Ativar para todos os grupos</string> + <string name="receipts_groups_enable_keep_overrides">Ativar (manter sobreposições do grupo)</string> + <string name="receipts_groups_disable_keep_overrides">Desativar (manter sobreposições do grupo)</string> + <string name="receipts_groups_disable_for_all">Desativar para todos os grupos</string> + <string name="in_developing_title">Em breve!</string> + <string name="delivery">Entrega</string> + <string name="no_info_on_delivery">Nenhuma informação de entrega</string> + <string name="sending_delivery_receipts_will_be_enabled_all_profiles">Enviar recibos de entrega será ativado para todos os contatos em todos os perfis de chat visíveis.</string> + <string name="rcv_group_event_2_members_connected">%s e %s conectados</string> + <string name="connect_via_member_address_alert_title">Conectar diretamente\?</string> + <string name="no_selected_chat">Sem chat selecionado</string> + <string name="privacy_message_draft">Rascunho de mensagem</string> + <string name="send_receipts_disabled">desativado</string> </resources> \ No newline at end of file From 4d6283630a562c2e08053d1e173e16cfa3dab0fc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 5 Sep 2023 12:50:11 +0100 Subject: [PATCH 13/41] website: translations (#3017) * Translated using Weblate (Arabic) Currently translated at 100.0% (244 of 244 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ * Translated using Weblate (Polish) Currently translated at 100.0% (244 of 244 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pl/ * Added translation using Weblate (Hebrew) * Translated using Weblate (French) Currently translated at 100.0% (245 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (German) Currently translated at 100.0% (245 of 245 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% (245 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ * Translated using Weblate (Italian) Currently translated at 100.0% (245 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Spanish) Currently translated at 100.0% (245 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/ * Translated using Weblate (Japanese) Currently translated at 22.4% (55 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ja/ * Added translation using Weblate (Finnish) * Translated using Weblate (Arabic) Currently translated at 100.0% (244 of 244 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ * Translated using Weblate (Polish) Currently translated at 100.0% (244 of 244 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pl/ * Added translation using Weblate (Hebrew) * Translated using Weblate (French) Currently translated at 100.0% (245 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (German) Currently translated at 100.0% (245 of 245 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% (245 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ * Translated using Weblate (Italian) Currently translated at 100.0% (245 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Spanish) Currently translated at 100.0% (245 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/ * Translated using Weblate (Japanese) Currently translated at 22.4% (55 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ja/ * Added translation using Weblate (Finnish) * Translated using Weblate (Hebrew) Currently translated at 30.6% (75 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Finnish) Currently translated at 100.0% (245 of 245 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fi/ --------- Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org> Co-authored-by: B.O.S.S <BxOxSxS@protonmail.com> Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: mlanp <github@lang.xyz> Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org> Co-authored-by: No name <CertainBot@users.noreply.hosted.weblate.org> Co-authored-by: a4318 <dalse.077@gmail.com> Co-authored-by: ItaiShek <itaishek@gmail.com> Co-authored-by: petri <pkajander@gmail.com> --- website/langs/ar.json | 11 +- website/langs/de.json | 9 +- website/langs/es.json | 9 +- website/langs/fi.json | 247 ++++++++++++++++++++++++++++++++++++++++++ website/langs/fr.json | 11 +- website/langs/he.json | 78 +++++++++++++ website/langs/it.json | 9 +- website/langs/ja.json | 29 ++++- website/langs/pl.json | 12 +- 9 files changed, 392 insertions(+), 23 deletions(-) create mode 100644 website/langs/fi.json create mode 100644 website/langs/he.json diff --git a/website/langs/ar.json b/website/langs/ar.json index afa1076a6..bf44575f2 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -157,10 +157,10 @@ "comparison-section-list-point-7": "شبكات P2P إما لديها سلطة مركزية أو أن الشبكة كلها يمكن عرضة للخطر", "see-here": "اقرأ هنا", "no-secure": "لا - آمن", - "comparison-section-list-point-5": "لا يحمي المعلومات الوصفية للمستخدمين", + "comparison-section-list-point-5": "لا يحمي خصوصية البيانات الوصفية للمستخدمين", "comparison-section-list-point-6": "على الرغم من أن الـP2P موزعة، إلا أنها ليست فدرالية - يعملون كشبكة واحدة", "comparison-section-list-point-1": "عادة ما يكون مكوناً من رقم الهاتف، أو اسم المستخدم في بعض الأحيان", - "comparison-section-list-point-4": "إذا خوادم المشغّل مُخترقة", + "comparison-section-list-point-4": "إذا خوادم المشغّل مُخترقة. تحقق من رمز الأمان في Signal وبعض التطبيقات الأخرى للتخفيف منه", "simplex-unique-card-3-p-1": "يخزن SimpleX جميع بيانات المستخدم على الأجهزة العميلة<strong> بتنسيق قاعدة بيانات محمولة مشفرة — </strong>يمكن نقله إلى جهاز آخر.", "simplex-unique-card-4-p-1": "شبكة SimpleX لا مركزية بالكامل ومستقلة عن أي عملة مشفرة أو أي منصة أخرى، بخلاف الإنترنت.", "simplex-unique-card-4-p-2": "يمكنك<strong> استخدام SimpleX مع خوادمك الخاصة </strong> أو مع الخوادم التي نوفرها — ولا يزال الاتصال ممكن بأي مستخدم.", @@ -240,5 +240,8 @@ "signing-key-fingerprint": "توقيع مفتاح البصمة (SHA-256)", "f-droid-org-repo": "مستودع F-Droid.org", "stable-versions-built-by-f-droid-org": "الإصدارات الثابتة التي تم إنشاؤها بواسطة F-Droid.org", - "releases-to-this-repo-are-done-1-2-days-later": "يتم إصدار الإصدارات إلى هذا المستودع بعد يوم أو يومين" -} \ No newline at end of file + "releases-to-this-repo-are-done-1-2-days-later": "يتم إصدار الإصدارات إلى هذا المستودع بعد يوم أو يومين", + "f-droid-page-simplex-chat-repo-section-text": "لإضافته إلى عميل F-Droid، <span class='hide-on-mobile'>امسح رمز QR أو</span> استخدم عنوان URL هذا:", + "f-droid-page-f-droid-org-repo-section-text": "مستودعات SimpleX Chat و F-Droid.org مبنية على مفاتيح مختلفة. للتبديل، يرجى <a href='/docs/guide/chat-profiles.html#move-your-chat-profiles-to-another-device'>تصدير</a> قاعدة بيانات الدردشة وإعادة تثبيت التطبيق.", + "comparison-section-list-point-4a": "مُرحلات SimpleX لا يمكنها أن تتنازل عن تشفير بين الطرفين. تحقق من رمز الأمان للتخفيف من الهجوم على القناة خارج النطاق" +} diff --git a/website/langs/de.json b/website/langs/de.json index ced2abffd..6cb3f1d3f 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -170,7 +170,7 @@ "no-federated": "Nein - föderiert", "comparison-section-list-point-2": "DNS-basierte Adressen", "comparison-section-list-point-3": "Öffentlicher Schlüssel oder eine andere weltweit eindeutige ID", - "comparison-section-list-point-4": "Wenn die Server des Betreibers kompromittiert werden", + "comparison-section-list-point-4": "Wenn die Server des Betreibers kompromittiert werden. In Signal und weiteren Apps kann der Securitycode überprüft werden, um dies zu entschärfen", "comparison-section-list-point-6": "P2P sind zwar verteilt, aber nicht föderiert - sie arbeiten als ein einziges Netzwerk", "comparison-section-list-point-7": "P2P-Netzwerke haben entweder eine zentrale Verwaltung oder das gesamte Netzwerk kann kompromittiert werden", "see-here": "Siehe hier", @@ -194,7 +194,7 @@ "comparison-section-list-point-1": "Normalerweise auf der Grundlage einer Telefonnummer, in einigen Fällen auf der Grundlage von Benutzernamen", "comparison-point-5-text": "Zentrale Komponente oder andere Netzwerk-weite Angriffe", "no-decentralized": "Nein - dezentralisiert", - "comparison-section-list-point-5": "Metadaten des Nutzers werden nicht geschützt", + "comparison-section-list-point-5": "Die Privatsphäre-Metadaten des Nutzers werden nicht geschützt", "simplex-network-overlay-card-1-li-1": "P2P-Netzwerke vertrauen auf Varianten von <a href='https://en.wikipedia.org/wiki/Distributed_hash_table'>DHT</a>, um Nachrichten zu routen. DHT-Designs müssen zwischen Zustellungsgarantie und Latenz ausgleichen. Verglichen mit P2P bietet SimpleX sowohl eine bessere Zustellungsgarantie, als auch eine niedrigere Latenz, weil eine Nachricht redundant und parallel über mehrere Server gesendet werden kann, wobei die durch den Empfänger ausgewählten Server genutzt werden. In P2P-Netzwerken werden Nachrichten sequentiell über <em>O(log N)</em> Knoten gesendet, wobei die Knoten durch einen Algorithmus ausgewählt werden.", "simplex-unique-overlay-card-3-p-4": "Zwischen dem gesendeten und empfangenen Serververkehr gibt es keine gemeinsamen Kennungen oder Chiffriertexte — sodass ein Beobachter nicht ohne weiteres feststellen kann, wer mit wem kommuniziert, selbst wenn TLS kompromittiert wurde.", "simplex-unique-overlay-card-4-p-3": "Wenn Sie darüber nachdenken, für die SimpleX-Plattform entwickeln zu wollen, z.B. einen Chatbot für SimpleX-App-Nutzer oder die Integration der SimpleX-Chat-Bibliothek in Ihre mobilen Apps, <a href='https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D' target='_blank'>kontaktieren Sie uns bitte</a> für eine weitere Beratung und Unterstützung.", @@ -242,5 +242,6 @@ "simplex-chat-via-f-droid": "SimpleX Chat per F-Droid", "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 <span class='hide-on-mobile'>scannen Sie den QR-Code oder</span> nutzen Sie diese URL:" -} \ No newline at end of file + "f-droid-page-simplex-chat-repo-section-text": "Um es Ihrem F-Droid-Client hinzuzufügen <span class='hide-on-mobile'>scannen Sie den QR-Code oder</span> 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" +} diff --git a/website/langs/es.json b/website/langs/es.json index 4505d4b98..3f317ae4e 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -179,8 +179,8 @@ "no-federated": "No - federado", "comparison-section-list-point-1": "Generalmente basado en un número de teléfono, en algunos casos en nombres de usuario", "comparison-section-list-point-2": "Direcciones basadas en DNS", - "comparison-section-list-point-4": "Si los servidores del operador se ven comprometidos", - "comparison-section-list-point-5": "No protege los metadatos del usuario", + "comparison-section-list-point-4": "Si los servidores del operador se ven comprometidos. Verifique el código de seguridad en Signal y alguna otra aplicación para mitigarlo", + "comparison-section-list-point-5": "No protege la privacidad de los metadatos del usuario", "comparison-section-list-point-3": "Clave pública o algun otro ID único a nivel global", "comparison-section-list-point-6": "A pesar de que las redes P2P son distribuidas, no son federadas - funcionan como una única red", "comparison-section-list-point-7": "Las redes P2P o bien tienen una autoridad central o toda la red puede verse comprometida", @@ -242,5 +242,6 @@ "stable-versions-built-by-f-droid-org": "Versión estable compilada por F-Droid.org", "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 <a href='/docs/guide/chat-profiles.html#move-your-chat-profiles-to-another-device'>exportar</a> 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" -} \ No newline at end of file + "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" +} diff --git a/website/langs/fi.json b/website/langs/fi.json new file mode 100644 index 000000000..eaa774aa9 --- /dev/null +++ b/website/langs/fi.json @@ -0,0 +1,247 @@ +{ + "home": "Koti", + "developers": "Kehittäjät", + "reference": "Lisätietoja", + "blog": "Blogi", + "features": "Ominaisuudet", + "why-simplex": "Miksi SimpleX", + "simplex-privacy": "SimpleX yksityisyys", + "simplex-network": "SimpleX verkko", + "simplex-explained": "SimpleX selitetynä", + "simplex-explained-tab-1-text": "1. Mitä käyttäjät kokevat", + "simplex-explained-tab-2-text": "2. Miten se toimii", + "simplex-explained-tab-3-text": "3. Mitä palvelimet näkevät", + "simplex-explained-tab-1-p-2": "Kuinka se voi toimia yksisuuntaisten jonotusten kanssa ilman käyttäjäprofiilin tunnisteita?", + "simplex-explained-tab-2-p-1": "Jokaista yhteyttä varten käytetään kahta erillistä viestintäjonon mahdollistavaa palvelinta viestien lähettämiseen ja vastaanottamiseen.", + "simplex-explained-tab-2-p-2": "Palvelimet välittävät viestejä vain yhteen suuntaan eivätkä saa kokonaiskuvaa käyttäjän keskustelusta tai yhteyksistä.", + "simplex-explained-tab-3-p-2": "Käyttäjät voivat lisäksi parantaa metadata-yksityisyyttään käyttämällä Tor-verkkoa palvelimiin yhdistämiseen, mikä estää IP-osoitteen perusteella tapahtuvan yhteyksien tekemisen.", + "chat-bot-example": "Keskustelu­botti-esimerkki", + "smp-protocol": "SMP-protokolla", + "chat-protocol": "Keskustelu­protokolla", + "simplex-chat-protocol": "SimpleX Keskustelu­protokolla", + "terminal-cli": "Pääte CLI", + "terms-and-privacy-policy": "Käyttöehdot ja yksityisyys­käytäntö", + "hero-header": "Yksityisyys uudelleen määritelty", + "hero-subheader": "Ensimmäinen viestisovellus<br>ilman käyttäjätunnuksia", + "hero-overlay-1-textlink": "Miksi käyttäjätunnukset ovat huonoja yksityisyydelle?", + "hero-overlay-1-title": "Kuinka SimpleX toimii?", + "hero-overlay-2-title": "Miksi käyttäjätunnukset ovat huonoja yksityisyydelle?", + "feature-1-title": "Päästä päähän salattuja viestejä markdownin ja muokkaamisen kera", + "feature-2-title": "Päästä päähän salattuja<br>kuvia ja tiedostoja", + "feature-3-title": "Hajautetut salaiset ryhmät —<br>vain käyttäjät tietävät niiden olemassaolosta", + "feature-4-title": "Päästä päähän salattuja ääniviestejä", + "feature-5-title": "Katoavia viestejä", + "feature-8-title": "Incognito-tila —<br>ainutlaatuinen SimpleX Chatille", + "simplex-network-overlay-1-title": "Verrattuna P2P-viestintäprotokolliin", + "simplex-private-7-title": "Viestin eheys<br>vahvistus", + "simplex-private-9-title": "Yksisuuntaisia<br>viestijonoja", + "simplex-private-10-title": "Väliaikaiset nimettömät parittaiset tunnisteet", + "simplex-private-card-3-point-1": "Vain TLS 1.2/1.3 vahvoilla algoritmeilla käytetään asiakas-palvelin-yhteyksiin.", + "simplex-private-card-4-point-2": "Käyttääksesi SimpleX:ää Torin kautta, asenna <a href=\"https://guardianproject.info/apps/org.torproject.android/\" target=\"_blank\">Orbot-sovellus</a> ja ota käyttöön SOCKS5-välityspalvelin (tai VPN <a href=\"https://apps.apple.com/us/app/orbot/id1609461599?platform=iphone\" target=\"_blank\">iOS:lla</a>).", + "simplex-private-card-7-point-1": "Viestien eheyden varmistamiseksi ne numeroituvat peräkkäin ja sisältävät edellisen viestin tiivisteen.", + "simplex-private-card-7-point-2": "Jos viestiä lisätään, poistetaan tai muutetaan, vastaanottaja saa ilmoituksen.", + "simplex-private-card-8-point-1": "SimpleX-palvelimet toimivat matalan viiveen sekoitussolmuina — saapuvilla ja lähtevillä viesteillä on erilainen järjestys.", + "simplex-private-card-10-point-1": "SimpleX käyttää väliaikaisia nimettömiä parittaisia osoitteita ja tunnistetietoja jokaiselle käyttäjäkontaktille tai ryhmän jäsenelle.", + "privacy-matters-2-title": "Vaalien manipulointi", + "privacy-matters-2-overlay-1-title": "Yksityisyys antaa sinulle valtaa", + "privacy-matters-2-overlay-1-linkText": "Yksityisyys antaa sinulle valtaa", + "privacy-matters-3-title": "Syyte viattomasta yhteydestä", + "privacy-matters-3-overlay-1-title": "Yksityisyys suojaa vapauttasi", + "simplex-unique-3-title": "Sinä hallitset tietojasi", + "hero-overlay-card-1-p-4": "Tämä ratkaisu estää kaikkien käyttäjien metadatan vuotamisen sovellustason tasolla. Lisätäksesi yksityisyyttä ja suojataksesi IP-osoitteesi, voit yhdistää viestintäpalvelimiin Tor-verkon kautta.", + "hero-overlay-card-1-p-5": "Vain asiakaslaitteet tallentavat käyttäjäprofiilit, yhteystiedot ja ryhmät; viestit lähetetään 2-kerroksisella päästä päähän salauksella.", + "hero-overlay-card-2-p-1": "Kun käyttäjillä on pysyvät tunnisteet, vaikka ne olisivat vain satunnaisia numeroita, kuten istunnon tunniste, on riski, että palveluntarjoaja tai hyökkääjä voi havaita miten käyttäjät ovat yhteydessä toisiinsa ja kuinka monta viestiä he lähettävät.", + "simplex-network-overlay-card-1-p-1": "<a href='https://fi.wikipedia.org/wiki/Vertaisverkko'>P2P</a> viestintäprotokollilla ja sovelluksilla on erilaisia ongelmia, jotka tekevät niistä vähemmän luotettavia kuin SimpleX, monimutkaisempia analysoida ja alttiita useille hyökkäystyypeille.", + "privacy-matters-overlay-card-1-p-1": "Monet suuret yritykset käyttävät tietoa siitä, keiden kanssa olet yhteydessä, arvioidakseen tulojasi, myydäkseen sinulle tarpeettomia tuotteita ja määrittääkseen hinnat.", + "privacy-matters-overlay-card-1-p-2": "Verkkokauppiaat tietävät, että alhaisempiin tuloluokkiin kuuluvat ihmiset todennäköisemmin tekevät kiireellisiä ostoksia, joten he voivat veloittaa korkeampia hintoja tai poistaa alennukset.", + "simplex-unique-overlay-card-3-p-1": "SimpleX Chat tallentaa kaikki käyttäjätiedot vain asiakaslaitteille käyttäen <strong>siirrettävää salattua tietokantamuotoa</strong>, joka voidaan viedä ja siirtää mihin tahansa tuettuun laitteeseen.", + "simplex-unique-overlay-card-3-p-2": "Päästä päähän salatut viestit säilytetään väliaikaisesti SimpleX-releay-palvelimilla, kunnes ne vastaanotetaan, minkä jälkeen ne poistetaan pysyvästi.", + "simplex-unique-card-1-p-1": "SimpleX suojaa profiilisi, yhteystietosi ja metatietosi yksityisyyden, piilottaen ne SimpleX-alustan palvelimilta ja kaikilta havainnoijilta.", + "simplex-unique-card-1-p-2": "Toisin kuin millään muulla olemassa olevalla viestintäalustalla, SimpleX:llä ei ole tunnisteita käyttäjille — <strong>ei edes satunnaisia numeroita</strong>.", + "simplex-unique-card-4-p-1": "SimpleX-verkko on täysin hajautettu ja riippumaton mistään kryptovaluutasta tai mistään muusta alustasta paitsi Internetistä.", + "simplex-unique-card-4-p-2": "Voit <strong>käyttää SimpleX:ää omien palvelimiesi kanssa</strong> tai meidän tarjoamillamme palvelimilla — ja silti yhdistyä mihin tahansa käyttäjään.", + "join": "Liity", + "hide-info": "Piilota tiedot", + "contact-hero-header": "Sait osoitteen yhdistämistä varten SimpleX Chatissa", + "invitation-hero-header": "Sait kertalinkini yhdistämistä varten SimpleX Chatissa", + "contact-hero-p-2": "Et ole vielä ladannut SimpleX Chat -sovellusta?", + "privacy-matters-section-subheader": "Metatietojesi yksityisyyden säilyttäminen — <span class='text-active-blue'>keneen puhut</span> — suojaa sinua seuraavilta:", + "privacy-matters-section-label": "Varmista, ettei viestintäsovelluksesi pääse käsiksi tietoihisi!", + "simplex-private-section-header": "Mikä tekee SimpleX:stä <span class='gradient-text'>yksityisen</span>", + "simplex-network-1-header": "Toisin kuin P2P-verkot", + "simplex-network-1-overlay-linktext": "P2P-verkkojen ongelmia", + "protocol-1-text": "Signal, suuret alustat", + "protocol-2-text": "XMPP, Matrix", + "protocol-3-text": "P2P-protokollat", + "comparison-point-1-text": "Vaati globaalin identiteetin", + "comparison-point-2-text": "Mahdollisuus MITM-hyökkäykseen", + "comparison-point-3-text": "Riippuvuus DNS:stä", + "comparison-point-4-text": "Yksittäinen tai keskitetty verkko", + "yes": "Kyllä", + "no": "Ei", + "no-private": "Ei - yksityinen", + "no-secure": "Ei - turvallinen", + "no-resilient": "Ei - joustava", + "comparison-section-list-point-7": "P2P-verkoilla on joko keskitetty auktoriteetti tai koko verkko voidaan vaarantaa", + "see-here": "katso täältä", + "guide-dropdown-1": "Nopea aloitus", + "guide-dropdown-2": "Viestien lähettäminen", + "guide-dropdown-3": "Salaiset ryhmät", + "guide-dropdown-4": "Keskusteluprofiilit", + "guide-dropdown-7": "Yksityisyys ja turvallisuus", + "guide-dropdown-8": "Sovellusasetukset", + "guide-dropdown-9": "Yhteyksien luominen", + "docs-dropdown-5": "XFTP-palvelimen isännöiminen", + "docs-dropdown-6": "WebRTC-palvelimet", + "docs-dropdown-7": "Käännä SimpleX Chat", + "docs-dropdown-8": "SimpleX Hakupalvelu", + "on-this-page": "Tällä sivulla", + "back-to-top": "Takaisin ylös", + "glossary": "Sanasto", + "simplex-chat-repo": "SimpleX Chat -varasto", + "signing-key-fingerprint": "Allekirjoitusavaimen sormenjälki (SHA-256)", + "f-droid-org-repo": "F-Droid.org -varasto", + "stable-versions-built-by-f-droid-org": "Vakioversiot luotu F-Droid.org -varastoon", + "releases-to-this-repo-are-done-1-2-days-later": "Julkaisut tälle varastolle tehdään 1-2 päivää myöhemmin", + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat ja F-Droid.org -varastot allekirjoittavat buildit eri avaimilla. Vaihtaaksesi, <a href='/docs/guide/chat-profiles.html#move-your-chat-profiles-to-another-device'>vienti</a> keskustelutietokanta ja asenna sovellus uudelleen.", + "hero-overlay-2-textlink": "Kuinka SimpleX toimii?", + "hero-2-header": "Luo yksityinen yhteys", + "hero-2-header-desc": "Video näyttää, kuinka muodostat yhteyden ystävääsi heidän kertakäyttöiseen QR-koodiinsa, henkilökohtaisesti tai videolinkin kautta. Voit myös liittyä jakamalla kutsulinkin kautta.", + "feature-6-title": "Päästä päähän salattuja<br>puheluita ja videopuheluja", + "feature-7-title": "Siirrettävä salattu tietokanta — siirrä profiilisi toiselle laitteelle", + "simplex-explained-tab-1-p-1": "Voit luoda yhteyshenkilöitä ja ryhmiä sekä käydä kaksisuuntaisia keskusteluja kuten missä tahansa muussa viestisovelluksessa.", + "simplex-explained-tab-3-p-1": "Palvelimilla on erilliset anonyymit tunnistetiedot kullekin jonolle, eivätkä ne tiedä, mille käyttäjille ne kuuluvat.", + "donate": "Lahjoita", + "copyright-label": "© 2020-2023 SimpleX | Avoin projekti", + "hero-p-1": "Muissa sovelluksissa on käyttäjätunnuksia: Signal, Matrix, Session, Briar, Jami, Cwtch, jne.<br> SimpleX ei käytä niitä, <strong>ei edes satunnaisia numeroita</strong>.<br> Tämä parantaa yksityisyyttäsi radikaalisti.", + "simplex-private-1-title": "2 kerrosta päästä päähän salattua viestintää", + "simplex-private-2-title": "Lisäkerros palvelimen salaukselle", + "simplex-private-3-title": "Turvallinen tunnistettu<br>TLS-tiedonsiirto", + "simplex-private-4-title": "Vaihtoehtoinen pääsy Torin kautta", + "simplex-private-5-title": "Useita tasoja<br>sisältöpakkauksia", + "simplex-private-6-title": "Avainvaihto kanavan ulkopuolella<br> (out-of-band)", + "simplex-private-8-title": "Viestien sekoitus<br>korrelaation vähentämiseksi", + "simplex-private-card-1-point-1": "Kaksoisruuviprotokolla —<br>OTR-viestintä täydellisellä eteenpäinsalauksella ja murron palautuksella.", + "simplex-private-card-1-point-2": "NaCL-kryptolaatikko kussakin jonossa estämään liikenteen korrelaatiota viestijonojen välillä, jos TLS vaarantuu.", + "simplex-private-card-2-point-1": "Lisäkerros palvelimen salaukselle vastaanottajalle toimittaessa, jotta lähetetyn ja vastaanotetun palvelinliikenteen korrelaatio estyy, jos TLS vaarantuu.", + "simplex-private-card-3-point-2": "Palvelimen sormenjälki ja kanavan sitominen estävät välikäden hyökkäykset ja toistohyökkäykset.", + "simplex-private-card-3-point-3": "Yhteyden jatkaminen on estetty istuntohyökkäysten estämiseksi.", + "simplex-private-card-4-point-1": "Suojataksesi IP-osoitettasi, voit käyttää palvelimia Tor-verkon tai jonkin muun kuljetuskerroksen päällä.", + "simplex-private-card-5-point-1": "SimpleX käyttää sisältöpakkauksia jokaiselle salauskerrokselle estämään viestikoon hyökkäyksiä.", + "simplex-private-card-5-point-2": "Se saa erikokoiset viestit näyttämään samalta palvelimille ja verkon tarkkailijoille.", + "simplex-private-card-6-point-1": "Monet viestintäalustat ovat alttiita välikäden hyökkäyksille palvelimilta tai verkko-operaattoreilta.", + "simplex-private-card-6-point-2": "Estääkseen sen, SimpleX-sovellukset siirtävät yksittäiset avaimet kanavan ulkopuolella, kun jaat osoitteen linkkinä tai QR-koodina.", + "simplex-private-card-9-point-1": "Jokainen viestijono siirtää viestejä yhteen suuntaan, eri lähettävillä ja vastaanottavilla osoitteilla.", + "simplex-private-card-9-point-2": "Se vähentää hyökkäysvektoreita verrattuna perinteisiin viestivälittimiin ja saatavilla oleviin metatietoihin.", + "simplex-private-card-10-point-2": "Se mahdollistaa viestien toimittamisen ilman käyttäjäprofiilitunnisteita, tarjoten paremman metatietosuojan kuin vaihtoehdot.", + "privacy-matters-1-title": "Mainonnan ja hintasyrjinnän estäminen", + "privacy-matters-1-overlay-1-title": "Yksityisyys säästää rahaa", + "privacy-matters-1-overlay-1-linkText": "Yksityisyys säästää rahaa", + "privacy-matters-3-overlay-1-linkText": "Yksityisyys suojaa vapauttasi", + "simplex-unique-1-title": "Sinulla on täydellinen yksityisyys", + "simplex-unique-1-overlay-1-title": "Täysi yksityisyys identiteetistäsi, profiilistasi, kontakteistasi ja metatiedoista", + "simplex-unique-2-title": "Olet suojattu roskapostilta ja väärinkäytöksiltä", + "simplex-unique-2-overlay-1-title": "Paras suoja roskapostilta ja väärinkäytöksiltä", + "simplex-unique-3-overlay-1-title": "Omistusoikeus, hallinta ja tietojesi turvallisuus", + "simplex-unique-4-title": "Omistat SimpleX-verkon", + "simplex-unique-4-overlay-1-title": "Täysin hajautettu — käyttäjät omistavat SimpleX-verkon", + "hero-overlay-card-1-p-1": "Monet käyttäjät ovat kysyneet: <em>jos SimpleX ei käytä käyttäjätunnisteita, miten se tietää minne viestit toimitetaan?</em>", + "hero-overlay-card-1-p-3": "Määrität, minkä palvelimen(t) valitset viestien vastaanottamiseen, sekä kontaktisi — palvelimet, joita käytät viestien lähettämiseen heille. Jokainen keskustelu käyttää todennäköisesti kahta eri palvelinta.", + "hero-overlay-card-1-p-6": "Lue lisää <a href='https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md' target='_blank'>SimpleX whitepaperista</a>.", + "hero-overlay-card-1-p-2": "Viestien toimittamiseen SimpleX ei käytä muiden alustojen käyttäjätunnuksia, vaan sen sijaan se käyttää väliaikaisia nimettömiä parittaisia tunnisteita viestijonoille, jotka ovat erillisiä jokaiselle yhteydelle — pitkäaikaisia tunnisteita ei ole.", + "hero-overlay-card-2-p-3": "Jopa yksityisimmillä sovelluksilla, jotka käyttävät Tor v3 -palveluja, jos puhut kahdelle eri yhteyshenkilölle saman profiilin kautta, he voivat todistaa olevansa yhteydessä samaan henkilöön.", + "hero-overlay-card-2-p-4": "SimpleX suojaa näitä hyökkäyksiä vastaan, sillä siinä ei ole käyttäjätunnuksia toteutuksessaan. Ja jos käytät Incognito-tilaa, sinulla on eri näyttönimi jokaiselle yhteyshenkilölle, mikä estää yhteisten tietojen jakamisen heidän välillään.", + "hero-overlay-card-2-p-2": "Tämän tiedon avulla he voisivat yhdistää sen olemassa oleviin julkisiin sosiaalisiin verkostoihin ja määrittää joitakin todellisia identiteettejä.", + "simplex-network-overlay-card-1-li-1": "P2P-verkot luottavat jonkinlaiseen <a href='https://fi.wikipedia.org/wiki/Hajautettu_tietokanta'>DHT</a>-varianttiin viestien reitittämiseksi. DHT-suunnittelun on tasapainotettava toimituksen varmuutta ja latenssia. SimpleX:llä on parempi toimitusvarmuus ja pienempi latenssi kuin P2P-verkoilla, koska viesti voidaan toimittaa redundanssina useiden palvelimien kautta samanaikaisesti, käyttäen vastaanottajan valitsemia palvelimia. P2P-verkoissa viesti kulkee läpi <em>O(log N)</em> solmun sekvenssissä, käyttäen algoritmin valitsemia solmuja.", + "simplex-network-overlay-card-1-li-2": "SimpleX-toteutus, toisin kuin useimmat P2P-verkot, ei käytä globaaleja käyttäjätunnisteita millään tavalla, ei edes tilapäisiä, ja käyttää ainoastaan tilapäisiä parillisia tunnisteita, tarjoten paremman anonymiteetin ja metadatansuojan.", + "simplex-network-overlay-card-1-li-6": "P2P-verkot saattavat olla alttiita <a href='https://www.usenix.org/conference/woot15/workshop-program/presentation/p2p-file-sharing-hell-exploiting-bittorrent'>DRDoS-hyökkäyksille</a>, kun asiakkaat voivat lähettää uudelleen ja voimistaa liikennettä, mikä johtaa verkko-laajuiseen palvelunestohyökkäykseen. SimpleX-asiakkaat välittävät liikennettä vain tunnetuilta yhteyksiltä eivätkä voi olla käytettävissä hyökkääjänä liikenteen voimistamiseen koko verkossa.", + "simplex-network-overlay-card-1-li-3": "P2P ei ratkaise <a href='https://fi.wikipedia.org/wiki/V%C3%A4lik%C3%A4den_hy%C3%B6kk%C3%A4ys'>välikäden hyökkäys</a> -ongelmaa, ja useimmat olemassa olevat toteutukset eivät käytä kanavan ulkopuolisia viestejä alkuperäiseen avaimenvaihtoon. SimpleX käyttää kanavan ulkopuolisia viestejä tai, joissakin tapauksissa, jo valmiiksi turvallisia ja luotettuja yhteyksiä alkuperäiseen avaimenvaihtoon.", + "simplex-network-overlay-card-1-li-4": "P2P-toteutukset voivat estyä joidenkin Internet-palveluntarjoajien (kuten <a href='https://fi.wikipedia.org/wiki/BitTorrent'>BitTorrent</a>) toimesta. SimpleX on kuljetusprotokollasta riippumaton - se voi toimia yleisten verkkoprotokollien kautta, kuten esimerkiksi WebSockets.", + "simplex-network-overlay-card-1-li-5": "Kaikki tunnetut P2P-verkot saattavat olla alttiita <a href='https://fi.wikipedia.org/wiki/Sybil-hy%C3%B6kk%C3%A4ys'>Sybil-hyökkäykselle</a>, koska jokainen solmu on löydettävissä, ja verkko toimii kokonaisuutena. Tunnetut keinot sen lieventämiseksi vaativat joko keskitetyn komponentin tai kalliin <a href='https://fi.wikipedia.org/wiki/Proof_of_work'>työn todistuksen</a>. SimpleX-verkolla ei ole palvelimen löydettävyyttä, se on fragmentoitunut ja toimii useina eristettyinä aliverkkoina, mikä tekee verkko-laajuisista hyökkäyksistä mahdottomia.", + "privacy-matters-overlay-card-1-p-3": "Jotkut rahoitus- ja vakuutusyhtiöt käyttävät sosiaalisia verkostoja määrittääkseen korkoja ja vakuutusmaksuja. Se tekee usein alhaisempiin tuloihin kuuluvien ihmisten maksavan enemmän — sitä kutsutaan <a href='https://fairbydesign.com/povertypremium/' target='_blank'>'köyhyyslisäksi'</a>.", + "privacy-matters-overlay-card-1-p-4": "SimpleX-alusta suojaa yhteyksiesi yksityisyyttä paremmin kuin mikään vaihtoehto, estäen täysin yhteysverkkosi tulemisen saataville mille tahansa yrityksille tai organisaatioille. Vaikka ihmiset käyttävät SimpleX Chatin tarjoamia palvelimia, emme tiedä käyttäjien määrää tai heidän yhteyksiään.", + "privacy-matters-overlay-card-2-p-3": "SimpleX on ensimmäinen alusta, jolla ei ole mitään käyttäjätunnisteita suunnittelussaan, suojaten siten yhteyksesi verkkoa paremmin kuin mikään tunnettu vaihtoehto.", + "privacy-matters-overlay-card-2-p-1": "Ei niin kauan sitten huomasimme merkittävien vaalien olevan manipuloitavissa <a href='https://en.wikipedia.org/wiki/Facebook–Cambridge_Analytica_data_scandal' target='_blank'>kunnioitetun konsulttiyrityksen</a> toimesta, joka käytti sosiaalisia verkostoja vääristämään käsitystämme todellisesta maailmasta ja manipuloimaan ääniämme.", + "privacy-matters-overlay-card-3-p-1": "Kaikkien pitäisi välittää viestinnän yksityisyydestä ja turvallisuudesta — harmittomat keskustelut voivat asettaa sinut vaaraan, vaikka sinulla ei olisi mitään piilotettavaa.", + "privacy-matters-overlay-card-2-p-2": "Ollaksesi objektiivinen ja tehdäksesi itsenäisiä päätöksiä, sinun on hallittava tietotilaasi. Se on mahdollista vain, jos käytät yksityistä viestintäalustaa, jolla ei ole pääsyä sosiaaliseen verkostoosi.", + "privacy-matters-overlay-card-3-p-2": "Yksi järkyttävimmistä tarinoista on <a href='https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi' target='_blank'>Mohamedou Ould Salahi'n</a> kokemus, joka on kuvattu hänen muistelmissaan ja esitetty The Mauritanian -elokuvassa. Hänet laitettiin Guantanamo-leirille ilman oikeudenkäyntiä ja häntä kidutettiin siellä 15 vuotta puhelun jälkeen sukulaiselleen Afganistanissa, epäiltynä osallisuudesta 9/11-iskuihin, vaikka hän oli asunut Saksassa edelliset 10 vuotta.", + "privacy-matters-overlay-card-3-p-3": "Tavalliset ihmiset pidätetään siitä, mitä he jakavat verkossa, jopa 'anonyymien' tiliensä kautta, <a href='https://www.dailymail.co.uk/news/article-11282263/Moment-police-swoop-house-devout-catholic-mother-malicious-online-posts.html' target='_blank'>jopa demokraattisissa maissa</a>.", + "privacy-matters-overlay-card-3-p-4": "Ei riitä, että käytät päästä päähän salattua viestintäsovellusta, meidän kaikkien pitäisi käyttää viestintäsovelluksia, jotka suojelevat henkilökohtaisten verkostojemme yksityisyyttä — keiden kanssa olemme yhteydessä.", + "simplex-unique-overlay-card-1-p-1": "Toisin kuin muut viestintäalustat, SimpleX:llä ei ole <strong>mitään tunnisteita käyttäjille</strong>. Se ei luota puhelinnumeroihin, verkkotunnuksiin perustuviin osoitteisiin (kuten sähköposti tai XMPP), käyttäjänimiin, julkisiin avaimiin tai edes satunnaisiin numeroihin tunnistaakseen käyttäjänsä — emme tiedä kuinka monta ihmistä käyttää SimpleX-palvelimiamme.", + "simplex-unique-overlay-card-1-p-2": "Viestien toimittamiseksi SimpleX käyttää <a href='https://csrc.nist.gov/glossary/term/Pairwise_Pseudonymous_Identifier'>parittaisia nimettömiä osoitteita</a> kaksisuuntaisille viestijonoille, jotka ovat erilliset vastaanotetuille ja lähetetyille viesteille, yleensä eri palvelimien kautta. SimpleX:n käyttö on kuin <strong>eri “kertakäyttöinen” sähköposti tai puhelin jokaiselle yhteydelle</strong>, eikä sinun tarvitse vaivautua niiden hallitsemiseen.", + "simplex-unique-overlay-card-1-p-3": "Tämä suunnittelu suojaa sitä, kenen kanssa kommunikoit, piilottamalla sen SimpleX-alustan palvelimilta ja kaikilta havainnoijilta. Piilottaaksesi IP-osoitteesi palvelimilta, voit <strong>yhdistää SimpleX-palvelimiin Tor-verkon kautta</strong>.", + "simplex-unique-overlay-card-2-p-1": "Koska sinulla ei ole tunnistetta SimpleX-alustalla, kukaan ei voi ottaa sinuun yhteyttä, ellei jaa kertakäyttöistä tai väliaikaista käyttäjäosoitetta, kuten QR-koodia tai linkkiä.", + "simplex-unique-overlay-card-2-p-2": "Jopa valinnaisen käyttäjäosoitteen kanssa, vaikka sitä voitaisiin käyttää roskapostiyhteyspyyntöjen lähettämiseen, voit vaihtaa sen tai poistaa sen kokonaan menettämättä mitään yhteyksiäsi.", + "simplex-unique-overlay-card-3-p-3": "Toisin kuin liitettyjen verkkojen palvelimet (sähköposti, XMPP tai Matrix), SimpleX-palvelimet eivät tallenna käyttäjätilejä, ne vain välittävät viestejä, suojaten molempien osapuolien yksityisyyttä.", + "simplex-unique-overlay-card-3-p-4": "Lähetetyssä ja vastaanotetussa palvelimen liikenteessä ei ole yhteisiä tunnisteita tai salaustekstiä — jos joku havaitsee sen, he eivät voi helposti selvittää kuka kommunikoi kenen kanssa, vaikka TLS olisi vaarantunut.", + "simplex-unique-overlay-card-4-p-3": "Jos harkitset kehittämistä SimpleX-alustalle, esimerkiksi chat-botin luomista SimpleX-sovelluksen käyttäjille tai SimpleX Chat -kirjaston integrointia mobiilisovelluksiisi, ole hyvä ja <a href='https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D' target='_blank'>ota yhteyttä</a> saadaksesi neuvoja ja tukea.", + "simplex-unique-overlay-card-4-p-1": "Voit <strong>käyttää SimpleX:ää omien palvelimiesi kanssa</strong> ja silti kommunikoida ihmisten kanssa, jotka käyttävät meille tarjottuja valmiiksi määritettyjä palvelimia.", + "simplex-unique-overlay-card-4-p-2": "SimpleX-alusta käyttää <a href='https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md' target='_blank'>avoimeen protokollaan</a> ja tarjoaa <a href='https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-client/typescript' target='_blank'>SDK:n chatbotien luomiseen</a>, mahdollistaen palvelujen toteuttamisen, joiden kanssa käyttäjät voivat olla vuorovaikutuksessa SimpleX Chat -sovellusten kautta — odotamme innolla nähdä, millaisia SimpleX-palveluja voit rakentaa.", + "simplex-unique-card-3-p-1": "SimpleX tallentaa kaikki käyttäjätiedot asiakaslaitteille <strong>siirrettävässä salatussa tietokannan muodossa</strong> — se voidaan siirtää toiseen laitteeseen.", + "simplex-unique-card-3-p-2": "Päästä päähän salatut viestit säilytetään väliaikaisesti SimpleX-releay-palvelimilla, kunnes ne vastaanotetaan, minkä jälkeen ne poistetaan pysyvästi.", + "we-invite-you-to-join-the-conversation": "Kutsumme sinut mukaan keskusteluun", + "join-the-REDDIT-community": "Liity REDDIT-yhteisöön", + "simplex-unique-card-2-p-1": "Koska sinulla ei ole tunnistetta tai kiinteää osoitetta SimpleX-alustalla, kukaan ei voi ottaa sinuun yhteyttä, ellet jaa kertakäyttöistä tai väliaikaista käyttäjäosoitetta, kuten QR-koodia tai linkkiä.", + "join-us-on-GitHub": "Liity GitHubissa", + "donate-here-to-help-us": "Tue meitä täällä lahjoituksilla", + "sign-up-to-receive-our-updates": "Tilaa päivityksemme", + "enter-your-email-address": "Syötä sähköpostiosoitteesi", + "get-simplex": "Hanki SimpleX", + "why-simplex-is": "Miksi SimpleX on", + "unique": "ainutlaatuinen", + "learn-more": "Lue lisää", + "more-info": "Lisätietoja", + "contact-hero-subheader": "Skannaa QR-koodi SimpleX Chat -sovelluksella puhelimessasi tai tabletissasi.", + "contact-hero-p-1": "Tämän linkin julkiset avaimet ja viestijonon osoite eivät lähetä verkkoa pitkin, kun katsot tätä sivua — ne sisältyvät linkin URL:n hash-osaan.", + "contact-hero-p-3": "Käytä alla olevia linkkejä ladataksesi sovelluksen.", + "scan-qr-code-from-mobile-app": "Skannaa QR-koodi mobiilisovelluksesta", + "to-make-a-connection": "Yhteyden muodostaminen:", + "install-simplex-app": "Asenna SimpleX-sovellus", + "connect-in-app": "Yhdisty sovelluksessa", + "open-simplex-app": "Avaa Simplex-sovellus", + "tap-the-connect-button-in-the-app": "Napauta sovelluksessa olevaa <span class='text-active-blue'>‘yhdistä’</span>-painiketta", + "scan-the-qr-code-with-the-simplex-chat-app": "Skannaa QR-koodi SimpleX Chat -sovelluksella", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Tämän linkin julkiset avaimet ja viestijonon osoite eivät lähetä verkkoa pitkin, kun katsot tätä sivua —<br> ne sisältyvät linkin URL:n hash-osaan.", + "installing-simplex-chat-to-terminal": "Asentaminen SimpleX Chat terminaaliin", + "see-simplex-chat": "Katso SimpleX Chat", + "use-this-command": "Käytä tätä komentoa:", + "github-repository": "GitHub-varasto", + "the-instructions--source-code": "ohjeet, miten ladata tai kääntää se lähdekoodista.", + "if-you-already-installed-simplex-chat-for-the-terminal": "Jos olet jo asentanut SimpleX Chatin terminaaliin", + "if-you-already-installed": "Jos olet jo asentanut", + "simplex-chat-for-the-terminal": "SimpleX Chatin terminaaliin", + "copy-the-command-below-text": "kopioi alla oleva komento ja käytä sitä keskustelussa:", + "privacy-matters-section-header": "Miksi yksityisyys on <span class='gradient-text'>tärkeää</span>", + "tap-to-close": "Napauta sulkeaksesi", + "simplex-network-section-header": "SimpleX <span class='gradient-text'>Verkko</span>", + "simplex-network-section-desc": "Simplex Chat tarjoaa parhaan yksityisyyden yhdistämällä P2P-verkkojen ja liitettävien verkkojen edut.", + "simplex-network-1-desc": "Kaikki viestit lähetetään palvelimien kautta, mikä sekä parantaa metatietojen yksityisyyttä että mahdollistaa luotettavan asynkronisen viestien toimituksen, samalla välttäen monia", + "simplex-network-2-header": "Toisin kuin liitettävät verkot", + "simplex-network-3-desc": "palvelimet tarjoavat <span class='text-active-blue'>yksisuuntaisia jonopalveluja</span> yhdistääkseen käyttäjät, mutta niillä ei ole näkyvyyttä verkon yhteyskarttaan — ainoastaan käyttäjillä on.", + "comparison-section-header": "Vertailu muihin protokolliin", + "simplex-network-2-desc": "SimpleXin relea-palvelimet EIVÄT tallenna käyttäjäprofiileja, yhteystietoja ja toimitettuja viestejä, ne EIVÄT yhdisty toisiinsa, eikä ole OLE olemassa palvelinluetteloa.", + "simplex-network-3-header": "SimpleX-verkko", + "comparison-point-5-text": "Keskuskomponentti tai muu verkkoa koskeva hyökkäys", + "no-decentralized": "Ei - hajautettu", + "no-federated": "Ei - liitetty", + "comparison-section-list-point-1": "Yleensä pohjautuu puhelinnumeroon, joissain tapauksissa käyttäjänimiin", + "comparison-section-list-point-2": "DNS-pohjaiset osoitteet", + "comparison-section-list-point-3": "Julkinen avain tai jokin muu maailmanlaajuisesti uniikki tunniste", + "comparison-section-list-point-4a": "SimpleXin releapalvelimet eivät voi vaarantaa päästä päähän -salausta. Tarkista turvakoodi hyödyntääksesi hyökkäyssuojaa ylitys-kanavalla", + "comparison-section-list-point-5": "Ei suojaa käyttäjien metatietojen yksityisyyttä", + "comparison-section-list-point-6": "Vaikka P2P-verkot ovat hajautettuja, ne eivät ole liitettäviä - ne toimivat yhtenä verkostona", + "guide-dropdown-5": "Datan hallinta", + "guide-dropdown-6": "Ääni- ja videopuhelut", + "comparison-section-list-point-4": "Jos operaattorin palvelimet ovat vaarantuneet. Tarkista turvakoodi Signalissa ja joissakin muissa sovelluksissa suojautuaksesi hyökkäykseltä", + "guide": "Opas", + "docs-dropdown-1": "SimpleX-alusta", + "docs-dropdown-2": "Android-tiedostoihin pääseminen", + "docs-dropdown-3": "Keskustelutietokantaan pääseminen", + "docs-dropdown-4": "SMP-palvelimen isännöiminen", + "newer-version-of-eng-msg": "Tästä sivusta on uudempi versio englanniksi.", + "click-to-see": "Näytä napsauttamalla", + "menu": "Valikko", + "simplex-chat-via-f-droid": "SimpleX Chat F-Droidin kautta", + "stable-and-beta-versions-built-by-developers": "Kehittäjien luomat vakaat ja beta-versiot", + "f-droid-page-simplex-chat-repo-section-text": "Lisätäksesi sen F-Droid-asiakkaaseesi, <span class='hide-on-mobile'>skannaa QR-koodi tai</span> käytä tätä URL-osoitetta:" +} diff --git a/website/langs/fr.json b/website/langs/fr.json index ab29ca7c6..5d261d69a 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -205,8 +205,8 @@ "comparison-section-list-point-1": "Généralement basé sur un numéro de téléphone, dans certains cas sur des noms d'utilisateur", "comparison-section-list-point-2": "Adresses basées sur le DNS", "comparison-section-list-point-3": "Clé publique ou tout autre identifiant global unique", - "comparison-section-list-point-4": "Si les serveurs de l'opérateur sont compromis", - "comparison-section-list-point-5": "Ne protège pas les métadonnées des utilisateurs", + "comparison-section-list-point-4": "Si les serveurs de l'opérateur sont compromis. Vérifier les codes de sécurités sur Signal et d'autres applications pour limiter les risques", + "comparison-section-list-point-5": "Ne protège pas la confidentialité des métadonnées des utilisateurs", "comparison-section-list-point-6": "Bien que les P2P soient distribués, ils ne sont pas fédérés - ils fonctionnent comme un seul réseau", "comparison-section-list-point-7": "Les réseaux P2P ont soit une autorité centrale, soit l'ensemble du réseau peut être compromis", "voir-ici": "voir ici", @@ -240,8 +240,9 @@ "simplex-chat-via-f-droid": "SimpleX Chat via F-Droid", "simplex-chat-repo": "Dépot SimpleX Chat", "stable-and-beta-versions-built-by-developers": "Versions stables et bêta crées par les développeurs", - "f-droid-page-simplex-chat-repo-section-text": "Pour l'ajouter à votre client F-Droid <span class='hide-on-mobile'>scannez le code QR ou</span> utilisez cette URL:", + "f-droid-page-simplex-chat-repo-section-text": "Pour l'ajouter à votre client F-Droid <span class='hide-on-mobile'>scannez le code QR ou</span> utilisez cette URL :", "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" -} \ No newline at end of file + "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" +} diff --git a/website/langs/he.json b/website/langs/he.json new file mode 100644 index 000000000..c814e405c --- /dev/null +++ b/website/langs/he.json @@ -0,0 +1,78 @@ +{ + "home": "מסך הבית", + "developers": "מפתחים", + "reference": "הפניה", + "blog": "בלוג", + "features": "מאפיינים", + "why-simplex": "למה SimpleX", + "simplex-privacy": "פרטיות SimpleX", + "simplex-network": "רשת SimpleX", + "simplex-explained": "תיאור SimpleX", + "simplex-explained-tab-1-text": "1. חוויית משתמש", + "simplex-explained-tab-2-text": "2. איך זה עובד", + "simplex-chat-protocol": "פרוטוקול SimpleX Chat", + "terminal-cli": "ממשק שורת פקודה", + "terms-and-privacy-policy": "תנאים ומדיניות פרטיות", + "hero-header": "פרטיות מוגדרת מחדש", + "hero-subheader": "מערכת העברת ההודעות הראשונה<br>ללא מזהי שתמש", + "hero-overlay-1-textlink": "מדוע מזהי משתמש מזיקים לפרטיות?", + "hero-overlay-2-textlink": "איך SimpleX עובד?", + "hero-2-header": "יצירת חיבור פרטי", + "hero-2-header-desc": "הסרטון מראה כיצד אתם יוצרים קשר עם חברכם באמצעות קוד QR חד פעמי, באופן אישי או באמצעות קישור וידאו. באפשרותכם גם להתחבר על-ידי שיתוף קישור ההזמנה.", + "hero-overlay-1-title": "איך SimpleX עובד?", + "feature-1-title": "הודעות מוצפנות מקצה לקצה עם סימונים ואפשרויות עריכה", + "feature-2-title": "תמונות וקבצים<br>מוצפנים מקצה לקצה", + "feature-3-title": "קבוצות סודיות מבוזרות —<br>רק המשתמשים יודעים שהן קיימות", + "simplex-private-3-title": "תעבורת TLS<br>מאובטחת ומאומתת", + "simplex-private-card-1-point-2": "תיבת הצפנה NaCL בכל תור כדי למנוע קורלציית תעבורה בין תורי הודעות במקרה שאבטחת TLS נפגעה.", + "simplex-private-6-title": "החלפת מפתחות<br>מחוץ לרשת", + "simplex-private-7-title": "בדיקת תקינות<br>ההודעה", + "simplex-private-8-title": "ערבוב הודעות<br>לשם הפחתת קורלציה", + "simplex-private-9-title": "תורי הודעות<br>חד-כיווניים", + "simplex-private-10-title": "מזהים זמניים אנונימיים בזוגות", + "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-2": "זה גורם להודעות בגדלים שונים להיראות זהים לשרתים ולמשקיפים ברשת.", + "simplex-private-card-6-point-1": "פלטפורמות תקשורת רבות חשופות להתקפות אדם בתווך (MITM) על ידי שרתים או ספקי רשת.", + "simplex-private-card-9-point-2": "זה מפחית את וקטורי ההתקפה, בהשוואה למתווכי הודעות מסורתיים, ואת המטא-נתונים הזמינים.", + "simplex-private-card-10-point-2": "זה מאפשר להעביר הודעות ללא מזהי פרופיל משתמש, ומספק פרטיות מטא-נתונים טובה יותר מאשר חלופות אחרות.", + "privacy-matters-1-title": "פרסום ואפליית מחירים", + "privacy-matters-1-overlay-1-linkText": "פרטיות חוסכת לכם כסף", + "privacy-matters-2-title": "מניפולציה בבחירות", + "privacy-matters-2-overlay-1-title": "פרטיות מעניקה לכם עוצמה", + "privacy-matters-3-overlay-1-title": "פרטיות מגנה על החופש שלכם", + "simplex-explained-tab-3-text": "3. מה השרתים רואים", + "simplex-explained-tab-2-p-1": "עבור כל חיבור, שני תורי העברת הודעות נפרדים משמשים לשליחה וקבלה של הודעות דרך שרתים שונים.", + "simplex-explained-tab-2-p-2": "שרתים מעבירים הודעות רק בכיוון אחד, מבלי לקבל את התמונה המלאה של השיחה או החיבורים של המשתמש.", + "simplex-explained-tab-3-p-1": "לשרתים יש אישורים אנונימיים נפרדים לכל תור, ואינם יודעים לאילו משתמשים הם שייכים.", + "simplex-explained-tab-1-p-2": "איך זה יכול לעבוד עם תורים חד-כיווניים וללא מזהי פרופיל משתמש?", + "simplex-explained-tab-3-p-2": "משתמשים יכולים לשפר עוד יותר את פרטיות המטא-נתונים על ידי שימוש ב- Tor כדי לגשת לשרתים, ולמנוע קורלציה לפי כתובת IP.", + "chat-bot-example": "דוגמה לצ'אט בוט", + "smp-protocol": "פרוטוקול SMP", + "chat-protocol": "פרוטוקול צ'אט", + "donate": "תרומה", + "copyright-label": "© 2020-2023 SimpleX | פרויקט קוד פתוח", + "hero-p-1": "לאפליקציות אחרות יש מזהי משתמש: Signal, Matrix, Session, Briar, Jami, Cwtch וכו'.<br> ל-SimpleX אין, <strong>אפילו לא מספרים אקראיים</strong>.<br> זה משפר באופן קיצוני את הפרטיות שלך.", + "hero-overlay-2-title": "מדוע מזהי משתמש מזיקים לפרטיות?", + "feature-6-title": "שיחות שמע ווידאו<br>מוצפנות מקצה לקצה", + "feature-4-title": "הודעות קוליות מוצפנות מקצה לקצה", + "feature-5-title": "הודעות נעלמות", + "feature-7-title": "מסד נתונים מוצפן נייד — העברת הפרופיל שלכם למכשיר אחר", + "feature-8-title": "מצב זהות נסתרת —<br>ייחודי ל-SimpleX Chat", + "simplex-private-4-title": "אופציונלי<br>גישה דרך Tor", + "simplex-network-overlay-1-title": "השוואה לפרוטוקולי העברת הודעות P2P", + "simplex-private-2-title": "שכבה נוספת של<br>הצפנת שרת", + "simplex-private-1-title": "2 שכבות של<br>הצפנה מקצה לקצה", + "simplex-private-5-title": "שכבות מרובות של<br>ריפוד תוכן", + "simplex-private-card-3-point-3": "חידוש החיבור מושבת כדי למנוע התקפות הפעלה.", + "simplex-private-card-4-point-1": "כדי להגן על כתובת ה-IP שלכם, אתם יכולים לגשת לשרתים דרך Tor או רשת שכבת-על אחרת של תעבורה.", + "simplex-private-card-4-point-2": "כדי להשתמש ב-SimpleX דרך Tor, התקן את <a href=\"https://guardianproject.info/apps/org.torproject.android/\" target=\"_blank\">אפליקציית Orbot</a> והפעל את SOCKS5 proxy (או VPN <a href=\"https://apps.apple.com/us/app/orbot/id1609461599?platform=iphone\" target=\"_blank\">ב-iOS</a>).", + "simplex-private-card-7-point-2": "אם הודעה כלשהי תתווסף, תוסר או תשתנה, הנמען יקבל התראה.", + "simplex-private-card-9-point-1": "כל תור הודעות מעביר הודעות בכיוון אחד, עם כתובות השליחה והקבלה השונות.", + "privacy-matters-1-overlay-1-title": "פרטיות חוסכת לכם כסף", + "privacy-matters-2-overlay-1-linkText": "פרטיות מעניקה לכם עוצמה", + "privacy-matters-3-overlay-1-linkText": "פרטיות מגנה על החופש שלכם", + "simplex-explained-tab-1-p-1": "אתם יכולים ליצור אנשי קשר וקבוצות, ולנהל שיחות דו-כיווניות, כמו בכל תוכנה אחרת לשליחת הודעות." +} diff --git a/website/langs/it.json b/website/langs/it.json index 41ea654b2..d7ca0d9a3 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -207,9 +207,9 @@ "simplex-network-1-desc": "Tutti i messaggi vengono inviati tramite i server, garantendo una migliore privacy dei metadati e una consegna asincrona dei messaggi affidabile, evitando molti", "simplex-network-3-desc": "i server forniscono <span class='text-active-blue'>code unidirezionali</span> per connettere gli utenti, ma non hanno visibilità del grafo delle connessioni di rete — solo gli utenti.", "comparison-point-5-text": "Componente centrale o altro attacco a livello di rete", - "comparison-section-list-point-5": "Non protegge i metadati degli utenti", + "comparison-section-list-point-5": "Non protegge la privacy dei metadati degli utenti", "comparison-section-list-point-3": "Chiave pubblica o altro ID univoco globale", - "comparison-section-list-point-4": "Se i server dell'operatore sono compromessi", + "comparison-section-list-point-4": "Se i server dell'operatore sono compromessi. Verifica il codice di sicurezza in Signal e alcune altre app per mitigarlo", "guide-dropdown-1": "Avvio rapido", "guide-dropdown-2": "Inviare messaggi", "guide-dropdown-3": "Gruppi segreti", @@ -242,5 +242,6 @@ "simplex-chat-via-f-droid": "SimpleX Chat via F-Droid", "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 <span class='hide-on-mobile'>scansiona il codice QR o</span> usa questo URL:" -} \ No newline at end of file + "f-droid-page-simplex-chat-repo-section-text": "Per aggiungerlo al tuo client F-Droid <span class='hide-on-mobile'>scansiona il codice QR o</span> 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" +} diff --git a/website/langs/ja.json b/website/langs/ja.json index 9fc795659..f77f0619c 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -26,5 +26,32 @@ "simplex-unique-card-1-p-1": "SimpleXは、SimpleXプラットフォームのサーバやその他の観察者から隠すことで、あなたのプロフィール、連絡先やメタデータのプライバシーを守ります。", "simplex-unique-overlay-card-4-p-3": "例えば、SimpleXアプリユーザへのチャットボットやSimpleX Chatライブラリーの携帯アプリへの統合など、SimpleXプラットフォームに関する開発を検討してくださっているようでしたら、どのようなアドバイスや支援のことでも<a href='https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D' target='_blank'>ご連絡ください</a> 。", "simplex-unique-overlay-card-4-p-2": "SimpleXプラットフォームは、SimpleX Chatアプリを介してユーザが交流するサービスを実装させつつ<a href='https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md' target='_blank'>オープンプロトコル</a>を使い、<a href='https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-client/typescript' target='_blank'>チャットボットを作成するためにSDK</a>を提供します—私たちはあなた達がどのようなSimpleXのサービスを築くか本当に楽しみです。", - "simplex-unique-overlay-card-4-p-1": "あなたが、<strong>自分自身のサーバでSimpleXを使っても</strong>、私たちが提供する事前に構築されたサーバを使う方々と連絡を取ることができます。" + "simplex-unique-overlay-card-4-p-1": "あなたが、<strong>自分自身のサーバでSimpleXを使っても</strong>、私たちが提供する事前に構築されたサーバを使う方々と連絡を取ることができます。", + "reference": "参考文献", + "simplex-explained-tab-1-text": "1. ユーザーが経験すること", + "simplex-explained-tab-1-p-2": "ユーザー プロファイル識別子なしで単方向キューをどのように処理できるのでしょうか?", + "simplex-chat-protocol": "SimpleX チャットプロトコル", + "terminal-cli": "ターミナル CLI", + "terms-and-privacy-policy": "利用規約とプライバシーポリシー", + "hero-header": "プライバシーの基準を新境地に", + "hero-subheader": "<br>ユーザーIDを持たない最初のメッセンジャー", + "hero-overlay-1-textlink": "ユーザー ID がプライバシーに悪影響を与えるのはなぜですか?", + "hero-overlay-2-textlink": "SimpleXの仕組みは?", + "hero-2-header": "プライベートな接続をする", + "hero-2-header-desc": "このビデオでは、1回限りのQRコード、対面、またはビデオリンクを通じて友人と接続する方法を紹介しています。招待リンクを共有することでも接続できます。", + "simplex-network": "SimpleXネットワーク", + "simplex-explained": "SimpleXの説明", + "simplex-explained-tab-1-p-1": "他のメッセンジャーと同様に、連絡先やグループを作成し、双方向の会話を行うことができます。", + "simplex-explained-tab-2-text": "2. 仕組み", + "simplex-explained-tab-3-text": "3. サーバーが認識するもの", + "smp-protocol": "SMPプロトコル", + "simplex-explained-tab-2-p-1": "接続ごとに 2 つの個別のメッセージング キューを使用して、異なるサーバー経由でメッセージを送受信します。", + "simplex-explained-tab-2-p-2": "サーバーは、ユーザーの会話や接続の全体像を把握することなく、メッセージを一方向に渡すだけです。", + "simplex-explained-tab-3-p-1": "サーバーはキューごとに個別の匿名認証情報を持っており、どのユーザーに属しているかはわかりません。", + "simplex-explained-tab-3-p-2": "ユーザーは、Tor を使用してサーバーにアクセスし、IP アドレスによる相関を防ぐことで、メタデータのプライバシーをさらに向上させることができます。", + "chat-protocol": "チャットプロトコル", + "chat-bot-example": "チャットボットの例", + "donate": "寄付", + "copyright-label": "© 2020-2023 SimpleX | Open-Source Project", + "hero-p-1": "他のアプリにはユーザー ID があります: Signal、Matrix、Session、Briar、Jami、Cwtch など。<br> SimpleX にはありません。<strong>乱数さえもありません</strong>。<br> これにより、プライバシーが大幅に向上します。" } diff --git a/website/langs/pl.json b/website/langs/pl.json index 3c3dd26e1..0ca0363ae 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -232,5 +232,15 @@ "menu": "Menu", "on-this-page": "Na tej stronie", "back-to-top": "Powrót do góry", - "glossary": "Słowniczek" + "glossary": "Słowniczek", + "f-droid-page-simplex-chat-repo-section-text": "Aby dodać do Twojego klienta F-Droid, <span class='hide-on-mobile'>zeskanuj kod QR lub</span> użyj tego URL:", + "f-droid-page-f-droid-org-repo-section-text": "Repozytoria SimpleX Chat i F-Droid.org mają podpisane budowy z innymi kluczami. Aby zmienić, proszę <a href='/docs/guide/chat-profiles.html#move-your-chat-profiles-to-another-device'>wyeksportuj</a> bazę czatu i przeinstaluj aplikację.", + "docs-dropdown-8": "Serwis katalogowy SimpleX", + "simplex-chat-via-f-droid": "SimpleX Chat na F-Droid", + "simplex-chat-repo": "Repo SimpleX", + "stable-and-beta-versions-built-by-developers": "Wersje stabilne i beta zbudowane przez deweloperów", + "signing-key-fingerprint": "Odcisk klucza podpisu (SHA-256)", + "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" } From 83b939d215fa77d4853845d15ee62ee5d419b7ff Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 5 Sep 2023 15:07:37 +0100 Subject: [PATCH 14/41] android: add Arabic, Finnish, Hebrew (#3018) * android: add Arabic, Finnish, Hebrew * update localization lib to support Hebrew --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- apps/multiplatform/android/build.gradle.kts | 3 +++ apps/multiplatform/build.gradle.kts | 2 +- apps/multiplatform/common/build.gradle.kts | 6 +++--- .../chat/simplex/common/views/usersettings/Appearance.kt | 3 +++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index e896047ca..bd45ee125 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -83,12 +83,15 @@ android { // Comma separated list of languages that will be included in the apk android.defaultConfig.resConfigs( "en", + "ar", "bg", "cs", "de", "es", + "fi", "fr", "it", + "iw", "ja", "nl", "pl", diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index f277da4bd..3a6fbcbf9 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -46,7 +46,7 @@ buildscript { classpath("com.android.tools.build:gradle:${rootProject.extra["gradle.plugin.version"]}") classpath(kotlin("gradle-plugin", version = rootProject.extra["kotlin.version"] as String)) classpath("org.jetbrains.kotlin:kotlin-serialization:1.3.2") - classpath("dev.icerock.moko:resources-generator:0.22.3") + classpath("dev.icerock.moko:resources-generator:0.23.0") // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 14caaa252..5b9560b07 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -39,7 +39,7 @@ kotlin { api("org.jetbrains.kotlinx:kotlinx-datetime:0.3.2") api("com.russhwolf:multiplatform-settings:1.0.0") api("com.charleskorn.kaml:kaml:0.43.0") - api("dev.icerock.moko:resources-compose:0.22.3") + api("dev.icerock.moko:resources-compose:0.23.0") api("org.jetbrains.compose.ui:ui-text:${rootProject.extra["compose.version"] as String}") implementation("org.jetbrains.compose.components:components-animatedimage:${rootProject.extra["compose.version"] as String}") //Barcode @@ -48,7 +48,7 @@ kotlin { // Link Previews implementation("org.jsoup:jsoup:1.13.1") // Resources - implementation("dev.icerock.moko:resources:0.22.3") + implementation("dev.icerock.moko:resources:0.23.0") } } val commonTest by getting { @@ -62,7 +62,7 @@ kotlin { val work_version = "2.7.1" implementation("androidx.work:work-runtime-ktx:$work_version") implementation("com.google.accompanist:accompanist-insets:0.23.0") - implementation("dev.icerock.moko:resources:0.22.3") + implementation("dev.icerock.moko:resources:0.23.0") // Video support implementation("com.google.android.exoplayer:exoplayer:2.17.1") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index d79b7b782..75e7d7201 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -201,12 +201,15 @@ object AppearanceScope { val supportedLanguages = mapOf( "system" to generalGetString(MR.strings.language_system), "en" to "English", + "ar" to "العربية", "bg" to "Български", "cs" to "Čeština", "de" to "Deutsch", "es" to "Español", + "fi" to "Suomi", "fr" to "Français", "it" to "Italiano", + "iw" to "עִברִית", "ja" to "日本語", "nl" to "Nederlands", "pl" to "Polski", From 43e233f0eb9100d9610e05ac50e855c47c8dc86c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 5 Sep 2023 20:15:50 +0400 Subject: [PATCH 15/41] core: don't create direct connections in group (#2996) --- docs/protocol/diagrams/group.mmd | 31 +- docs/protocol/diagrams/group.svg | 2 +- src/Simplex/Chat.hs | 101 +++--- src/Simplex/Chat/Protocol.hs | 16 +- src/Simplex/Chat/Store/Direct.hs | 8 +- src/Simplex/Chat/Store/Groups.hs | 200 ++++++------ src/Simplex/Chat/Store/Shared.hs | 2 + src/Simplex/Chat/Types.hs | 40 ++- tests/Bots/DirectoryTests.hs | 112 +++---- tests/ChatClient.hs | 18 +- tests/ChatTests/Files.hs | 11 +- tests/ChatTests/Groups.hs | 152 +++++++-- tests/ChatTests/Profiles.hs | 528 ++++++++++++++++--------------- tests/ChatTests/Utils.hs | 6 +- tests/ProtocolTests.hs | 20 +- 15 files changed, 723 insertions(+), 524 deletions(-) diff --git a/docs/protocol/diagrams/group.mmd b/docs/protocol/diagrams/group.mmd index c331b4610..18d392caa 100644 --- a/docs/protocol/diagrams/group.mmd +++ b/docs/protocol/diagrams/group.mmd @@ -3,24 +3,31 @@ sequenceDiagram participant A as Alice participant B as Bob participant C as Existing<br>contact - + note over A, B: 1. send and accept group invitation A ->> B: x.grp.inv<br>invite Bob to group<br>(via contact connection) - B ->> A: x.grp.acpt<br>accept invitation<br>(via member connection) - B ->> A: establish group member connection + B ->> A: x.grp.acpt<br>accept invitation<br>(via member connection)<br>establish group member connection note over M, B: 2. introduce new member Bob to all existing members A ->> M: x.grp.mem.new<br>"announce" Bob<br>to existing members<br>(via member connections) - A ->> B: x.grp.mem.intro * N<br>"introduce" members<br>(via member connection) - B ->> A: x.grp.mem.inv * N<br>"invitations" to connect<br>for all members<br>(via member connection) - A ->> M: x.grp.mem.fwd<br>forward "invitations"<br>to all members<br>(via member connections) + loop batched + A ->> B: x.grp.mem.intro * N<br>"introduce" members and<br>their chat protocol versions<br>(via member connection) + note over B: prepare group member connections + opt chat protocol compatible version < 2 + note over B: prepare direct connections + end + B ->> A: x.grp.mem.inv * N<br>"invitations" to connect<br>for all members<br>(via member connection) + end + A ->> M: x.grp.mem.fwd<br>forward "invitations" and<br>Bob's chat protocol version<br>to all members<br>(via member connections) note over M, B: 3. establish direct and group member connections M ->> B: establish group member connection - M ->> B: establish direct connection - note over M, C: 4. deduplicate new contact - B ->> M: x.info.probe<br>"probe" is sent to all new members - B ->> C: x.info.probe.check<br>"probe" hash,<br>in case contact and<br>member profiles match - C ->> B: x.info.probe.ok<br> original "probe",<br> in case contact and member<br>are the same user - note over B: merge existing and new contacts if received and sent probe hashes match + opt chat protocol compatible version < 2 + M ->> B: establish direct connection + note over M, C: 4. deduplicate new contact + B ->> M: x.info.probe<br>"probe" is sent to all new members + B ->> C: x.info.probe.check<br>"probe" hash,<br>in case contact and<br>member profiles match + C ->> B: x.info.probe.ok<br> original "probe",<br> in case contact and member<br>are the same user + note over B: merge existing and new contacts if received and sent probe hashes match + end diff --git a/docs/protocol/diagrams/group.svg b/docs/protocol/diagrams/group.svg index d66b560b2..8c1b65dee 100644 --- a/docs/protocol/diagrams/group.svg +++ b/docs/protocol/diagrams/group.svg @@ -1 +1 @@ -<svg aria-labelledby="chart-title-graph-div chart-desc-graph-div" role="img" viewBox="-50 -10 1125.5 1328" style="max-width: 1125.5px;" height="1328" xmlns="http://www.w3.org/2000/svg" width="100%" id="graph-div" xmlns:xlink="http://www.w3.org/1999/xlink"><style>@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css");'</style><title id="chart-title-graph-div">N existingmembersAliceBobExistingcontact1. send and accept group invitation2. introduce new member Bob to all existing members3. establish direct and group member connections4. deduplicate new contactmerge existing and new contacts if received and sent probe hashes matchx.grp.invinvite Bob to group(via contact connection)x.grp.acptestablish group member connectionx.grp.mem.new"announce" Bobto existing members(via member connections)x.grp.mem.intro * N"introduce" members(via member connection)x.grp.mem.inv * N"invitations" to connectfor all members(via member connection)x.grp.mem.fwdforward "invitations"to all members(via member connections)establish group member connectionestablish direct connectionx.info.probe"probe" is sent to all new membersx.info.probe.check"probe" hash,in case contact andmember profiles matchx.info.probe.ok original "probe", in case contact and memberare the same userN existingmembersAliceBobExistingcontact \ No newline at end of file +ExistingcontactBobAliceN existingmembersExistingcontactBobAliceN existingmembers1. send and accept group invitation2. introduce new member Bob to all existing membersprepare group member connectionsprepare direct connectionsopt[chat protocolcompatible version< 2]loop[batched]3. establish direct and group member connections4. deduplicate new contactmerge existing and new contacts if received and sent probe hashes matchopt[chat protocol compatible version < 2]x.grp.invinvite Bob to group(via contact connection)x.grp.acptaccept invitation(via member connection)establish group member connectionx.grp.mem.new"announce" Bobto existing members(via member connections)x.grp.mem.intro * N"introduce" members andtheir chat protocol versions(via member connection)x.grp.mem.inv * N"invitations" to connectfor all members(via member connection)x.grp.mem.fwdforward "invitations" andBob's chat protocol versionto all members(via member connections)establish group member connectionestablish direct connectionx.info.probe"probe" is sent to all new membersx.info.probe.check"probe" hash,in case contact andmember profiles matchx.info.probe.ok original "probe", in case contact and memberare the same user \ No newline at end of file diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 1c353f7e8..1165a2947 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -94,6 +94,7 @@ import qualified Simplex.Messaging.Protocol as SMP 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.FilePath (combine, splitExtensions, takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) @@ -104,7 +105,6 @@ import UnliftIO.Concurrent (forkFinally, forkIO, mkWeakThreadId, threadDelay) import UnliftIO.Directory import UnliftIO.IO (hClose, hSeek, hTell, openFile) import UnliftIO.STM -import Simplex.Messaging.Version defaultChatConfig :: ChatConfig defaultChatConfig = @@ -1431,12 +1431,16 @@ processChatCommand = \case Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName APIJoinGroup groupId -> withUser $ \user@User {userId} -> do - ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId + (invitation, ct) <- withStore $ \db -> do + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId + (inv,) <$> getContactViaMember db user fromMember + let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation + Contact {activeConn = Connection {connChatVRange}} = ct withChatLock "joinGroup" . procCmd $ do dm <- directMessage $ XGrpAcpt (memberId (membership :: GroupMember)) agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm withStore' $ \db -> do - createMemberConnection db userId fromMember agentConnId + createMemberConnection db userId fromMember agentConnId connChatVRange updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted updateCIGroupInvitationStatus user @@ -1878,11 +1882,11 @@ processChatCommand = \case mergedProfile' = userProfileToSend user' Nothing $ Just ct' if mergedProfile' == mergedProfile then pure s {notChanged = notChanged + 1} - else - let cts' = if mergedPreferences ct == mergedPreferences ct' then cts else ct' : cts + else + let cts' = if mergedPreferences ct == mergedPreferences ct' then cts else ct' : cts in (notifyContact mergedProfile' ct' $> s {updateSuccesses = updateSuccesses + 1, changedContacts = cts'}) `catchChatError` \e -> when (ll <= CLLInfo) (toView $ CRChatError (Just user) e) $> s {updateFailures = updateFailures + 1, changedContacts = cts'} - where + where notifyContact mergedProfile' ct' = do void $ sendDirectContactMessage ct' (XInfo mergedProfile') when (directOrUsed ct') $ createSndFeatureItems user' ct ct' @@ -2825,7 +2829,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> Nothing processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg connEntity conn@Connection {connId, viaUserContactLink, groupLinkId, customUserProfileId, connectionCode} = \case + processDirectMessage agentMsg connEntity conn@Connection {connId, connChatVRange, viaUserContactLink, groupLinkId, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of CONF confId _ connInfo -> do -- [incognito] send saved profile @@ -2866,7 +2870,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do setConnConnReqInv db user connId cReq getXGrpMemIntroContDirect db user ct forM_ contData $ \(hostConnId, xGrpMemIntroCont) -> - sendXGrpMemInv hostConnId directConnReq xGrpMemIntroCont + sendXGrpMemInv hostConnId (Just directConnReq) xGrpMemIntroCont CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn @@ -2948,7 +2952,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ groupId_ $ \groupId -> do gVar <- asks idsDrg groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation - withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds + withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds connChatVRange _ -> pure () Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do @@ -3015,22 +3019,32 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do case cReq of groupConnReq@(CRInvitationUri _ _) -> case cmdFunction of -- [async agent commands] XGrpMemIntro continuation on receiving INV - CFCreateConnGrpMemInv -> do - contData <- withStore' $ \db -> do - setConnConnReqInv db user connId cReq - getXGrpMemIntroContGroup db user m - forM_ contData $ \(hostConnId, directConnReq) -> do - let GroupMember {groupMemberId, memberId} = m - sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} + CFCreateConnGrpMemInv -> + ifM + (featureVersionSupported (connChatVRange conn) groupNoDirectVersion) + sendWithoutDirectCReq + sendWithDirectCReq + where + sendWithoutDirectCReq = do + let GroupMember {groupMemberId, memberId} = m + hostConnId <- withStore $ \db -> do + liftIO $ setConnConnReqInv db user connId cReq + getHostConnId db user groupId + sendXGrpMemInv hostConnId Nothing XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} + sendWithDirectCReq = do + let GroupMember {groupMemberId, memberId} = m + contData <- withStore' $ \db -> do + setConnConnReqInv db user connId cReq + getXGrpMemIntroContGroup db user m + forM_ contData $ \(hostConnId, directConnReq) -> + sendXGrpMemInv hostConnId (Just directConnReq) XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} -- [async agent commands] group link auto-accept continuation on receiving INV - CFCreateConnGrpInv -> - withStore' (\db -> getContactViaMember db user m) >>= \case - Nothing -> messageError "implementation error: invitee does not have contact" - Just ct -> do - withStore' $ \db -> setNewContactMemberConnRequest db user m cReq - groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo - sendGrpInvitation ct m groupLinkId - toView $ CRSentGroupInvitation user gInfo ct m + CFCreateConnGrpInv -> do + ct <- withStore $ \db -> getContactViaMember db user m + withStore' $ \db -> setNewContactMemberConnRequest db user m cReq + groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo + sendGrpInvitation ct m groupLinkId + toView $ CRSentGroupInvitation user gInfo ct m where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> m () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do @@ -3106,7 +3120,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processIntro intro `catchChatError` (toView . CRChatError (Just user)) where processIntro intro@GroupMemberIntro {introId} = do - void $ sendDirectMessage conn (XGrpMemIntro . memberInfo $ reMember intro) (GroupId groupId) + void $ sendDirectMessage conn (XGrpMemIntro $ memberInfo (reMember intro)) (GroupId groupId) withStore' $ \db -> updateIntroStatus db introId GMIntroSent _ -> do -- TODO send probe and decide whether to use existing contact connection or the new contact connection @@ -4006,7 +4020,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () processGroupInvitation ct inv msg msgMeta = do - let Contact {localDisplayName = c, activeConn = Connection {customUserProfileId, groupLinkId = groupLinkId'}} = ct + let Contact {localDisplayName = c, activeConn = Connection {connChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv checkIntegrityCreateItem (CDDirectRcv ct) msgMeta when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) @@ -4017,7 +4031,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do then do connIds <- joinAgentConnectionAsync user True connRequest =<< directMessage (XGrpAcpt memberId) withStore' $ \db -> do - createMemberConnectionAsync db user hostId connIds + createMemberConnectionAsync db user hostId connIds connChatVRange updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) @@ -4230,7 +4244,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> pure conn' xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> MsgMeta -> m () - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole memberProfile) msg msgMeta = do + xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg msgMeta = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo unless (sameMemberId memId $ membership gInfo) $ @@ -4243,7 +4257,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRJoinedGroupMemberConnecting user gInfo m newMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () - xGrpMemIntro gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ _) = do + xGrpMemIntro gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memberChatVRange _) = do case memberCategory m of GCHostMember -> do members <- withStore' $ \db -> getGroupMembers db user gInfo @@ -4252,14 +4266,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do else do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second - groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation - directConnIds <- createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation - -- [incognito] direct connection with member has to be established using the same incognito profile [that was known to host and used for group membership] + groupConnIds <- createConn + directConnIds <- case memberChatVRange of + Nothing -> Just <$> createConn + Just mcvr -> + ifM + (featureVersionSupported (fromChatVRange mcvr) groupNoDirectVersion) + (pure Nothing) + (Just <$> createConn) let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId _ -> messageError "x.grp.mem.intro can be only sent by host member" + where + createConn = createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation - sendXGrpMemInv :: Int64 -> ConnReqInvitation -> XGrpMemIntroCont -> m () + sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> m () sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do hostConn <- withStore $ \db -> getConnectionById db user hostConnId let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} @@ -4280,7 +4301,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 = ChatSettings {enableNtfs}} m memInfo@(MemberInfo memId memRole _) introInv@IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m memInfo@(MemberInfo memId memRole memberChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo toMember <- case find (sameMemberId memId) members of @@ -4295,9 +4316,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do dm <- directMessage $ XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership) -- [async agent commands] no continuation needed, but commands should be asynchronous for stability groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq dm - directConnIds <- joinAgentConnectionAsync user enableNtfs directConnReq dm + directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user enableNtfs dcr dm let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing - withStore' $ \db -> createIntroToMemberContact db user m toMember groupConnIds directConnIds customUserProfileId + mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange + withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> MsgMeta -> m () xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg msgMeta @@ -4444,6 +4466,13 @@ updateConnChatVRange conn@Connection {connId, connChatVRange} msgChatVRange pure conn {connChatVRange = msgChatVRange} | otherwise = pure conn +featureVersionSupported :: ChatMonad' m => VersionRange -> Version -> m Bool +featureVersionSupported peerVRange v = do + ChatConfig {chatVRange} <- asks config + case chatVRange `compatibleVersion` peerVRange of + Just (Compatible v') -> pure $ v' >= v + Nothing -> pure False + parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 5c33eb06c..2f93ab13d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -54,6 +54,10 @@ currentChatVersion = 2 supportedChatVRange :: VersionRange supportedChatVRange = mkVersionRange 1 currentChatVersion +-- version that starts support for skipping establishing direct connections in a group +groupNoDirectVersion :: Version +groupNoDirectVersion = 2 + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -107,18 +111,6 @@ data AppMessage (e :: MsgEncoding) where AMJson :: AppMessageJson -> AppMessage 'Json AMBinary :: AppMessageBinary -> AppMessage 'Binary -newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRange} deriving (Eq, Show) - -chatInitialVRange :: VersionRange -chatInitialVRange = versionToRange 1 - -instance FromJSON ChatVersionRange where - parseJSON v = ChatVersionRange <$> strParseJSON "ChatVersionRange" v - -instance ToJSON ChatVersionRange where - toJSON (ChatVersionRange vr) = strToJSON vr - toEncoding (ChatVersionRange vr) = strToJEncoding vr - -- chat message is sent as JSON with these properties data AppMessageJson = AppMessageJson { v :: Maybe ChatVersionRange, diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 00d3d55c9..3b56e57b7 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -498,10 +498,10 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers <$> DB.execute db [sql| - UPDATE contact_requests - SET agent_invitation_id = ?, chat_vrange_min_version = ?, chat_vrange_max_version = ?, updated_at = ? - WHERE user_id = ? AND contact_request_id = ? - |] + UPDATE contact_requests + SET agent_invitation_id = ?, chat_vrange_min_version = ?, chat_vrange_max_version = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + |] (invId, minV, maxV, currentTs, userId, cReqId) else withLocalDisplayName db userId displayName $ \ldn -> Right <$> do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 6c2f32f76..40c21d616 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -83,6 +83,7 @@ module Simplex.Chat.Store.Groups updateGroupSettings, getXGrpMemIntroContDirect, getXGrpMemIntroContGroup, + getHostConnId, ) where @@ -98,7 +99,6 @@ import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared -import Simplex.Chat.Protocol (chatInitialVRange) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId, UserId) @@ -106,6 +106,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.Util (eitherToMaybe) +import Simplex.Messaging.Version import UnliftIO.STM type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime) :. GroupMemberRow @@ -481,20 +482,21 @@ getUserGroupsWithSummary db user _contactId_ search_ = -- the statuses on non-current members should match memberCurrent' function getGroupSummary :: DB.Connection -> User -> GroupId -> IO GroupSummary getGroupSummary db User {userId} groupId = do - currentMembers_ <- maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT count (m.group_member_id) - FROM groups g - JOIN group_members m USING (group_id) - WHERE g.user_id = ? - AND g.group_id = ? - AND m.member_status != ? - AND m.member_status != ? - AND m.member_status != ? - |] - (userId, groupId, GSMemRemoved, GSMemLeft, GSMemInvited) + currentMembers_ <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT count (m.group_member_id) + FROM groups g + JOIN group_members m USING (group_id) + WHERE g.user_id = ? + AND g.group_id = ? + AND m.member_status != ? + AND m.member_status != ? + AND m.member_status != ? + |] + (userId, groupId, GSMemRemoved, GSMemLeft, GSMemInvited) pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [FullGroupPreferences] @@ -613,11 +615,11 @@ getGroupInvitation db user 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 -> ExceptT StoreError IO GroupMember -createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole agentConnId connRequest = +createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Connection {connChatVRange}} memberRole agentConnId connRequest = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt - void $ createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 createdAt + void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVRange Nothing 0 createdAt pure member where createMember_ memberId createdAt = do @@ -652,13 +654,13 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) ) -createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> ExceptT StoreError IO () -createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) = +createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> ExceptT StoreError IO () +createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) connChatVRange = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt groupMemberId <- liftIO $ insertedRowId db - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 createdAt + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId connChatVRange Nothing 0 createdAt setCommandConnId db user cmdId connId where insertMember_ memberId createdAt = @@ -674,31 +676,32 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) ) -getContactViaMember :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact) +getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact getContactViaMember db user@User {userId} GroupMember {groupMemberId} = - maybeFirstRow (toContact user) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version - FROM contacts ct - JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.contact_id = ct.contact_id - ) - JOIN group_members m ON m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - |] - (userId, groupMemberId) + ExceptT $ + firstRow (toContact user) (SEContactNotFoundByMemberId groupMemberId) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version + FROM contacts ct + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.contact_id = ct.contact_id + ) + JOIN group_members m ON m.contact_id = ct.contact_id + WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + |] + (userId, groupMemberId) setNewContactMemberConnRequest :: DB.Connection -> User -> GroupMember -> ConnReqInvitation -> IO () setNewContactMemberConnRequest db User {userId} GroupMember {groupMemberId} connRequest = do @@ -710,15 +713,15 @@ getMemberInvitation db User {userId} groupMemberId = fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) -createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> IO () -createMemberConnection db userId GroupMember {groupMemberId} agentConnId = do +createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRange -> IO () +createMemberConnection db userId GroupMember {groupMemberId} agentConnId connChatVRange = do currentTs <- getCurrentTime - void $ createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 currentTs + void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVRange Nothing 0 currentTs -createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> IO () -createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) = do +createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRange -> IO () +createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) connChatVRange = do currentTs <- getCurrentTime - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 currentTs + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId connChatVRange Nothing 0 currentTs setCommandConnId db user cmdId connId updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () @@ -738,25 +741,30 @@ updateGroupMemberStatusById db userId groupMemberId memStatus = do -- | add new member with profile createNewGroupMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName, image, contactLink, preferences}) memCategory memStatus = - ExceptT . withLocalDisplayName db userId displayName $ \localDisplayName -> do - currentTs <- getCurrentTime +createNewGroupMember db user gInfo memInfo memCategory memStatus = do + currentTs <- liftIO getCurrentTime + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memInfo currentTs + let newMember = + NewGroupMember + { memInfo, + memCategory, + memStatus, + memInvitedBy = IBUnknown, + localDisplayName, + memContactId = Nothing, + memProfileId + } + liftIO $ createNewMember_ db user gInfo newMember currentTs + +createNewMemberProfile_ :: DB.Connection -> User -> MemberInfo -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) +createNewMemberProfile_ db User {userId} (MemberInfo _ _ _ Profile {displayName, fullName, image, contactLink, preferences}) createdAt = + ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db "INSERT INTO contact_profiles (display_name, full_name, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, image, contactLink, userId, preferences, currentTs, currentTs) - memProfileId <- insertedRowId db - let newMember = - NewGroupMember - { memInfo, - memCategory, - memStatus, - memInvitedBy = IBUnknown, - localDisplayName, - memContactId = Nothing, - memProfileId - } - Right <$> createNewMember_ db user gInfo newMember currentTs + (displayName, fullName, image, contactLink, userId, preferences, createdAt, createdAt) + profileId <- insertedRowId db + pure $ Right (ldn, profileId) createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> IO GroupMember createNewMember_ @@ -764,7 +772,7 @@ createNewMember_ User {userId, userContactId} GroupInfo {groupId} NewGroupMember - { memInfo = MemberInfo memberId memberRole memberProfile, + { memInfo = MemberInfo memberId memberRole _ memberProfile, memCategory = memberCategory, memStatus = memberStatus, memInvitedBy = invitedBy, @@ -908,43 +916,41 @@ getIntroduction_ db reMember toMember = ExceptT $ do where toIntro :: [(Int64, Maybe ConnReqInvitation, Maybe ConnReqInvitation, GroupMemberIntroStatus)] -> Either StoreError GroupMemberIntro toIntro [(introId, groupConnReq, directConnReq, introStatus)] = - let introInvitation = IntroInvitation <$> groupConnReq <*> directConnReq + let introInvitation = IntroInvitation <$> groupConnReq <*> pure directConnReq in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> (CommandId, ConnId) -> (CommandId, ConnId) -> Maybe ProfileId -> ExceptT StoreError IO GroupMember -createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) (groupCmdId, groupAgentConnId) (directCmdId, directAgentConnId) customUserProfileId = do - let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn +createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> ExceptT StoreError IO GroupMember +createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId = do + let mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange + cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn currentTs <- liftIO getCurrentTime - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId chatInitialVRange memberContactId Nothing customUserProfileId cLevel currentTs - liftIO $ setCommandConnId db user directCmdId directConnId - (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing + newMember <- case directConnIds of + Just (directCmdId, directAgentConnId) -> do + Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs + liftIO $ setCommandConnId db user directCmdId directConnId + (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing + pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId} + Nothing -> do + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memInfo currentTs + pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do - let newMember = - NewGroupMember - { memInfo, - memCategory = GCPreMember, - memStatus = GSMemIntroduced, - memInvitedBy = IBUnknown, - localDisplayName, - memContactId = Just contactId, - memProfileId - } member <- createNewMember_ db user gInfo newMember currentTs - conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId memberContactId cLevel currentTs + conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId mcvr memberContactId cLevel currentTs liftIO $ setCommandConnId db user groupCmdId groupConnId pure (member :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> (CommandId, ConnId) -> (CommandId, ConnId) -> Maybe ProfileId -> IO () -createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} (groupCmdId, groupAgentConnId) (directCmdId, directAgentConnId) customUserProfileId = do +createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionRange -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> IO () +createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId = do let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn currentTs <- getCurrentTime - Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId viaContactId cLevel currentTs + Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId mcvr viaContactId cLevel currentTs setCommandConnId db user groupCmdId groupConnId - Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId chatInitialVRange viaContactId Nothing customUserProfileId cLevel currentTs - setCommandConnId db user directCmdId directConnId - contactId <- createMemberContact_ directConnId currentTs - updateMember_ contactId currentTs + forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do + Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs + setCommandConnId db user directCmdId directConnId + contactId <- createMemberContact_ directConnId currentTs + updateMember_ contactId currentTs where createMemberContact_ :: Int64 -> UTCTime -> IO Int64 createMemberContact_ connId ts = do @@ -971,8 +977,8 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = |] [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] -createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createMemberConnection_ db userId groupMemberId agentConnId viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId chatInitialVRange viaContact Nothing Nothing +createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRange -> Maybe Int64 -> Int -> UTCTime -> IO Connection +createMemberConnection_ db userId groupMemberId agentConnId connChatVRange viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId connChatVRange viaContact Nothing Nothing getViaGroupMember :: DB.Connection -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db User {userId, userContactId} Contact {contactId} = @@ -1343,3 +1349,9 @@ getXGrpMemIntroContGroup db User {userId} GroupMember {groupMemberId} = do toCont (hostConnId, connReq_) = case connReq_ of Just connReq -> Just (hostConnId, connReq) _ -> Nothing + +getHostConnId :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId +getHostConnId db user@User {userId} groupId = do + hostMemberId <- getHostMemberId_ db user groupId + ExceptT . firstRow fromOnly (SEConnectionNotFoundByMemberId hostMemberId) $ + DB.query db "SELECT connection_id FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, hostMemberId) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 5bae79d80..48e2e5692 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -51,6 +51,7 @@ data StoreError | SEUserNotFoundByContactRequestId {contactRequestId :: Int64} | SEContactNotFound {contactId :: ContactId} | SEContactNotFoundByName {contactName :: ContactName} + | SEContactNotFoundByMemberId {groupMemberId :: GroupMemberId} | SEContactNotReady {contactName :: ContactName} | SEDuplicateContactLink | SEUserContactLinkNotFound @@ -78,6 +79,7 @@ data StoreError | SERcvFileNotFoundXFTP {agentRcvFileId :: AgentRcvFileId} | SEConnectionNotFound {agentConnId :: AgentConnId} | SEConnectionNotFoundById {connId :: Int64} + | SEConnectionNotFoundByMemberId {groupMemberId :: GroupMemberId} | SEPendingConnectionNotFound {connId :: Int64} | SEIntroNotFound | SEUniqueID diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index ac19cbc36..4981b225b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -347,11 +347,12 @@ data ChatSettings = ChatSettings instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOptions defaultChatSettings :: ChatSettings -defaultChatSettings = ChatSettings - { enableNtfs = True, - sendRcpts = Nothing, - favorite = False - } +defaultChatSettings = + ChatSettings + { enableNtfs = True, + sendRcpts = Nothing, + favorite = False + } pattern DisableNtfs :: ChatSettings pattern DisableNtfs <- ChatSettings {enableNtfs = False} @@ -538,24 +539,31 @@ instance ToJSON MemberIdRole where toEncoding = J.genericToEncoding J.defaultOpt data IntroInvitation = IntroInvitation { groupConnReq :: ConnReqInvitation, - directConnReq :: ConnReqInvitation + directConnReq :: Maybe ConnReqInvitation } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON IntroInvitation where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON IntroInvitation where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data MemberInfo = MemberInfo { memberId :: MemberId, memberRole :: GroupMemberRole, + v :: Maybe ChatVersionRange, profile :: Profile } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON MemberInfo where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON MemberInfo where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} memberInfo :: GroupMember -> MemberInfo -memberInfo GroupMember {memberId, memberRole, memberProfile} = - MemberInfo memberId memberRole (fromLocalProfile memberProfile) +memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = + MemberInfo memberId memberRole memberChatVRange (fromLocalProfile memberProfile) + where + memberChatVRange = ChatVersionRange . connChatVRange <$> activeConn data ReceivedGroupInvitation = ReceivedGroupInvitation { fromMember :: GroupMember, @@ -1467,3 +1475,15 @@ instance ProtocolTypeI p => ToJSON (ServerCfg p) where instance ProtocolTypeI p => FromJSON (ServerCfg p) where parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRange} deriving (Eq, Show) + +chatInitialVRange :: VersionRange +chatInitialVRange = versionToRange 1 + +instance FromJSON ChatVersionRange where + parseJSON v = ChatVersionRange <$> strParseJSON "ChatVersionRange" v + +instance ToJSON ChatVersionRange where + toJSON (ChatVersionRange vr) = strToJSON vr + toEncoding (ChatVersionRange vr) = strToJEncoding vr diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 21bdb6577..a0d69d195 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -13,13 +13,14 @@ import Control.Monad (forM_) import Directory.Options import Directory.Service import Directory.Store +import GHC.IO.Handle (hClose) import Simplex.Chat.Bot.KnownContacts +import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Core import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Types (GroupMemberRole (..), Profile (..)) import System.FilePath (()) import Test.Hspec -import GHC.IO.Handle (hClose) directoryServiceTests :: SpecWith FilePath directoryServiceTests = do @@ -232,10 +233,10 @@ testJoinGroup tmp = dan <## "bob (Bob): contact is connected" dan <## "#privacy: you joined the group" dan <# ("#privacy bob> " <> welcomeMsg) - dan <### - [ "#privacy: member SimpleX-Directory is connected", - "#privacy: member cath (Catherine) is connected" - ], + dan + <### [ "#privacy: member SimpleX-Directory is connected", + "#privacy: member cath (Catherine) is connected" + ], do cath <## "#privacy: bob added dan (Daniel) to the group (connecting...)" cath <## "#privacy: new member dan is connected" @@ -243,9 +244,9 @@ testJoinGroup tmp = testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () testDelistedOwnerLeaves tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -259,9 +260,9 @@ testDelistedOwnerLeaves tmp = testDelistedOwnerRemoved :: HasCallStack => FilePath -> IO () testDelistedOwnerRemoved tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -274,9 +275,9 @@ testDelistedOwnerRemoved tmp = testNotDelistedMemberLeaves :: HasCallStack => FilePath -> IO () testNotDelistedMemberLeaves tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -286,10 +287,10 @@ testNotDelistedMemberLeaves tmp = groupFound cath "privacy" testNotDelistedMemberRemoved :: HasCallStack => FilePath -> IO () -testNotDelistedMemberRemoved tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testNotDelistedMemberRemoved tmp = + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -299,9 +300,9 @@ testNotDelistedMemberRemoved tmp = testDelistedServiceRemoved :: HasCallStack => FilePath -> IO () testDelistedServiceRemoved tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -316,9 +317,9 @@ testDelistedServiceRemoved tmp = testDelistedRoleChanges :: HasCallStack => FilePath -> IO () testDelistedRoleChanges tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -362,9 +363,9 @@ testDelistedRoleChanges tmp = testNotDelistedMemberRoleChanged :: HasCallStack => FilePath -> IO () testNotDelistedMemberRoleChanged tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -426,9 +427,9 @@ testNotApprovedBadRoles tmp = testRegOwnerChangedProfile :: HasCallStack => FilePath -> IO () testRegOwnerChangedProfile tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -445,9 +446,9 @@ testRegOwnerChangedProfile tmp = testAnotherOwnerChangedProfile :: HasCallStack => FilePath -> IO () testAnotherOwnerChangedProfile tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -464,9 +465,9 @@ testAnotherOwnerChangedProfile tmp = testRegOwnerRemovedLink :: HasCallStack => FilePath -> IO () testRegOwnerRemovedLink tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -497,9 +498,9 @@ testRegOwnerRemovedLink tmp = testAnotherOwnerRemovedLink :: HasCallStack => FilePath -> IO () testAnotherOwnerRemovedLink tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -646,9 +647,9 @@ testDuplicateProhibitApproval tmp = testListUserGroups :: HasCallStack => FilePath -> IO () testListUserGroups tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" @@ -686,15 +687,15 @@ testRestoreDirectory tmp = do withTestChat tmp "bob" $ \bob -> withTestChat tmp "cath" $ \cath -> do bob <## "2 contacts connected (use /cs for the list)" - bob <### - [ "#privacy (Privacy): connected to server(s)", - "#security (Security): connected to server(s)" - ] + bob + <### [ "#privacy (Privacy): connected to server(s)", + "#security (Security): connected to server(s)" + ] cath <## "2 contacts connected (use /cs for the list)" - cath <### - [ "#privacy (Privacy): connected to server(s)", - "#anonymity (Anonymity): connected to server(s)" - ] + cath + <### [ "#privacy (Privacy): connected to server(s)", + "#anonymity (Anonymity): connected to server(s)" + ] listGroups superUser bob cath groupFoundN 3 bob "privacy" groupFound bob "security" @@ -784,10 +785,13 @@ addCathAsOwner bob cath = do cath <## "#privacy: member SimpleX-Directory is connected" withDirectoryService :: HasCallStack => FilePath -> (TestCC -> String -> IO ()) -> IO () -withDirectoryService tmp test = do +withDirectoryService tmp = withDirectoryServiceCfg tmp testCfg + +withDirectoryServiceCfg :: HasCallStack => FilePath -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfg tmp cfg test = do dsLink <- - withNewTestChat tmp serviceDbPrefix directoryProfile $ \ds -> - withNewTestChat tmp "super_user" aliceProfile $ \superUser -> do + withNewTestChatCfg tmp cfg serviceDbPrefix directoryProfile $ \ds -> + withNewTestChatCfg tmp cfg "super_user" aliceProfile $ \superUser -> do connectUsers ds superUser ds ##> "/ad" getContactLink ds True @@ -800,7 +804,7 @@ restoreDirectoryService tmp ctCount grCount test = do ds <## (show ctCount <> " contacts connected (use /cs for the list)") ds <## "Your address is active! To show: /sa" ds <## (show grCount <> " group links active") - forM_ [1..grCount] $ \_ -> ds <##. "#" + forM_ [1 .. grCount] $ \_ -> ds <##. "#" ds ##> "/sa" dsLink <- getContactLink ds False ds <## "auto_accept on" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index e612f3d09..43760c99b 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -22,6 +22,7 @@ import Simplex.Chat import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..)) import Simplex.Chat.Core import Simplex.Chat.Options +import Simplex.Chat.Protocol (groupNoDirectVersion) import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Terminal @@ -133,6 +134,16 @@ testAgentCfgV1 = testCfgV1 :: ChatConfig testCfgV1 = testCfg {agentConfig = testAgentCfgV1} +testCfgCreateGroupDirect :: ChatConfig +testCfgCreateGroupDirect = + mkCfgCreateGroupDirect testCfg + +mkCfgCreateGroupDirect :: ChatConfig -> ChatConfig +mkCfgCreateGroupDirect cfg = cfg {chatVRange = groupCreateDirectVRange} + +groupCreateDirectVRange :: VersionRange +groupCreateDirectVRange = mkVersionRange 1 (groupNoDirectVersion - 1) + createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey MCError @@ -249,7 +260,7 @@ getTermLine cc = Just s -> do -- remove condition to always echo virtual terminal when (printOutput cc) $ do - -- when True $ do + -- when True $ do name <- userName cc putStrLn $ name <> ": " <> s pure s @@ -288,7 +299,10 @@ testChatCfgOpts3 cfg opts p1 p2 p3 test = testChatN cfg opts [p1, p2, p3] test_ test_ _ = error "expected 3 chat clients" testChat4 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () -testChat4 p1 p2 p3 p4 test = testChatN testCfg testOpts [p1, p2, p3, p4] test_ +testChat4 = testChatCfg4 testCfg + +testChatCfg4 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfg4 cfg p1 p2 p3 p4 test = testChatN cfg testOpts [p1, p2, p3, p4] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2, tc3, tc4] = test tc1 tc2 tc3 tc4 diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 4343b547c..9c277e00e 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -46,7 +46,7 @@ chatFileTests = do it "files folder: sender deleted file during transfer" testFilesFoldersImageSndDelete it "files folder: recipient deleted file during transfer" testFilesFoldersImageRcvDelete it "send and receive image with text and quote" testSendImageWithTextAndQuote - describe "send and receive image to group" testGroupSendImage + it "send and receive image to group" testGroupSendImage it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote describe "async sending and receiving files" $ do -- fails on CI @@ -724,11 +724,10 @@ testSendImageWithTextAndQuote = (alice <## "completed sending file 3 (test.jpg) to bob") B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src -testGroupSendImage :: SpecWith FilePath -testGroupSendImage = versionTestMatrix3 runTestGroupSendImage - where - runTestGroupSendImage :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () - runTestGroupSendImage alice bob cath = do +testGroupSendImage :: HasCallStack => FilePath -> IO () +testGroupSendImage = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do createGroup3 "team" alice bob cath threadDelay 1000000 alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 7a4bb2061..e533a55da 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -10,8 +10,10 @@ import Control.Concurrent.Async (concurrently_) import Control.Monad (when) import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (GroupMemberRole (..)) +import Simplex.Messaging.Version import System.Directory (copyFile) import System.FilePath (()) import Test.Hspec @@ -19,7 +21,7 @@ import Test.Hspec chatGroupTests :: SpecWith FilePath chatGroupTests = do describe "chat groups" $ do - describe "add contacts, create group and send/receive messages" testGroup + it "add contacts, create group and send/receive messages" testGroup it "add contacts, create group and send/receive messages, check messages" testGroupCheckMessages it "create and join group with 4 members" testGroup2 it "create and delete group" testGroupDelete @@ -64,15 +66,54 @@ chatGroupTests = do describe "group delivery receipts" $ do it "should send delivery receipts in group" testSendGroupDeliveryReceipts it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts - -testGroup :: HasCallStack => SpecWith FilePath -testGroup = versionTestMatrix3 runTestGroup + describe "direct connections in group are not established based on chat protocol version" $ do + describe "3 members group" $ do + testNoDirect _0 _0 True + testNoDirect _0 _1 False + testNoDirect _1 _0 False + testNoDirect _1 _1 False + describe "4 members group" $ do + testNoDirect4 _0 _0 _0 True True True + testNoDirect4 _0 _0 _1 True False False + testNoDirect4 _0 _1 _0 False True False + testNoDirect4 _0 _1 _1 False False False + testNoDirect4 _1 _0 _0 False False True + testNoDirect4 _1 _0 _1 False False False + testNoDirect4 _1 _1 _0 False False False + testNoDirect4 _1 _1 _1 False False False where - runTestGroup alice bob cath = testGroupShared alice bob cath False + _0 = supportedChatVRange -- don't create direct connections + _1 = groupCreateDirectVRange + -- having host configured with older version doesn't have effect in tests + -- because host uses current code and sends version in MemberInfo + testNoDirect vrMem2 vrMem3 noConns = + it + ( "host " <> vRangeStr supportedChatVRange + <> (", 2nd mem " <> vRangeStr vrMem2) + <> (", 3rd mem " <> vRangeStr vrMem3) + <> (if noConns then " : 2 3" else " : 2 <##> 3") + ) + $ testNoGroupDirectConns supportedChatVRange vrMem2 vrMem3 noConns + testNoDirect4 vrMem2 vrMem3 vrMem4 noConns23 noConns24 noConns34 = + it + ( "host " <> vRangeStr supportedChatVRange + <> (", 2nd mem " <> vRangeStr vrMem2) + <> (", 3rd mem " <> vRangeStr vrMem3) + <> (", 4th mem " <> vRangeStr vrMem4) + <> (if noConns23 then " : 2 3" else " : 2 <##> 3") + <> (if noConns24 then " : 2 4" else " : 2 <##> 4") + <> (if noConns34 then " : 3 4" else " : 3 <##> 4") + ) + $ testNoGroupDirectConns4Members supportedChatVRange vrMem2 vrMem3 vrMem4 noConns23 noConns24 noConns34 + +testGroup :: HasCallStack => FilePath -> IO () +testGroup = + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + \alice bob cath -> testGroupShared alice bob cath False testGroupCheckMessages :: HasCallStack => FilePath -> IO () testGroupCheckMessages = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> testGroupShared alice bob cath True testGroupShared :: HasCallStack => TestCC -> TestCC -> TestCC -> Bool -> IO () @@ -233,7 +274,7 @@ testGroupShared alice bob cath checkMessages = do testGroup2 :: HasCallStack => FilePath -> IO () testGroup2 = - testChat4 aliceProfile bobProfile cathProfile danProfile $ + testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob connectUsers alice cath @@ -679,7 +720,7 @@ testDeleteGroupMemberProfileKept = testGroupRemoveAdd :: HasCallStack => FilePath -> IO () testGroupRemoveAdd = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath -- remove member @@ -754,7 +795,7 @@ testGroupList = testGroupMessageQuotedReply :: HasCallStack => FilePath -> IO () testGroupMessageQuotedReply = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath threadDelay 1000000 @@ -1232,7 +1273,7 @@ testGroupDeleteUnusedContacts = cath <## "alice (Alice)" cath `hasContactProfiles` ["alice", "cath"] where - cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} + cfg = mkCfgCreateGroupDirect $ testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} deleteGroup :: HasCallStack => TestCC -> TestCC -> TestCC -> String -> IO () deleteGroup alice bob cath group = do alice ##> ("/d #" <> group) @@ -1321,7 +1362,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile testGroupModerate :: HasCallStack => FilePath -> IO () testGroupModerate = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath alice ##> "/mr team cath member" @@ -1352,7 +1393,7 @@ testGroupModerate = testGroupModerateFullDelete :: HasCallStack => FilePath -> IO () testGroupModerateFullDelete = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath alice ##> "/mr team cath member" @@ -1390,10 +1431,10 @@ testGroupModerateFullDelete = testGroupDelayedModeration :: HasCallStack => FilePath -> IO () testGroupDelayedModeration tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1407,11 +1448,11 @@ testGroupDelayedModeration tmp = do alice ##> "\\\\ #team @cath hi" alice <## "message marked deleted by you" cath <# "#team cath> [marked deleted by alice] hi" - withTestChat tmp "bob" $ \bob -> do + withTestChatCfg tmp cfg "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - withTestChat tmp "cath" $ \cath -> do + withTestChatCfg tmp cfg "cath" $ \cath -> do cath <## "2 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" @@ -1424,13 +1465,15 @@ testGroupDelayedModeration tmp = do bob ##> "/_get chat #1 count=2" r <- chat <$> getTermLine bob r `shouldMatchList` [(0, "connected"), (0, "hi [marked deleted by alice]")] + where + cfg = testCfgCreateGroupDirect testGroupDelayedModerationFullDelete :: HasCallStack => FilePath -> IO () testGroupDelayedModerationFullDelete tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1452,14 +1495,14 @@ testGroupDelayedModerationFullDelete tmp = do cath <## "alice updated group #team:" cath <## "updated group preferences:" cath <## "Full deletion: on" - withTestChat tmp "bob" $ \bob -> do + withTestChatCfg tmp cfg "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" - withTestChat tmp "cath" $ \cath -> do + withTestChatCfg tmp cfg "cath" $ \cath -> do cath <## "2 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" @@ -1472,6 +1515,8 @@ testGroupDelayedModerationFullDelete tmp = do bob ##> "/_get chat #1 count=3" r <- chat <$> getTermLine bob r `shouldMatchList` [(0, "Full deletion: on"), (0, "connected"), (0, "moderated [deleted by alice]")] + where + cfg = testCfgCreateGroupDirect testGroupAsync :: HasCallStack => FilePath -> IO () testGroupAsync tmp = do @@ -2127,7 +2172,7 @@ testGroupLinkMemberRole = testGroupLinkLeaveDelete :: HasCallStack => FilePath -> IO () testGroupLinkLeaveDelete = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob connectUsers cath bob @@ -2562,7 +2607,7 @@ testConfigureGroupDeliveryReceipts tmp = receipt bob alice cath "team" "25" noReceipt bob alice cath "club" "26" where - cfg = testCfg {showReceipts = True} + cfg = mkCfgCreateGroupDirect $ testCfg {showReceipts = True} receipt cc1 cc2 cc3 gName msg = do name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) @@ -2582,3 +2627,62 @@ testConfigureGroupDeliveryReceipts tmp = cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) cc1 VersionRange -> VersionRange -> VersionRange -> Bool -> FilePath -> IO () +testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns tmp = + withNewTestChatCfg tmp testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + if noDirectConns + then contactsDontExist bob cath + else bob <##> cath + where + contactsDontExist bob cath = do + bob ##> "@cath hi" + bob <## "no contact cath" + cath ##> "@bob hi" + cath <## "no contact bob" + +testNoGroupDirectConns4Members :: HasCallStack => VersionRange -> VersionRange -> VersionRange -> VersionRange -> Bool -> Bool -> Bool -> FilePath -> IO () +testNoGroupDirectConns4Members hostVRange mem2VRange mem3VRange mem4VRange noConns23 noConns24 noConns34 tmp = + withNewTestChatCfg tmp testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do + withNewTestChatCfg tmp testCfg {chatVRange = mem4VRange} "dan" danProfile $ \dan -> do + createGroup3 "team" alice bob cath + connectUsers alice dan + addMember "team" alice dan GRMember + dan ##> "/j team" + concurrentlyN_ + [ alice <## "#team: dan joined the group", + do + dan <## "#team: you joined the group" + dan + <### [ "#team: member bob (Bob) is connected", + "#team: member cath (Catherine) is connected" + ], + aliceAddedDan bob, + aliceAddedDan cath + ] + if noConns23 + then contactsDontExist bob cath + else bob <##> cath + if noConns24 + then contactsDontExist bob dan + else bob <##> dan + if noConns34 + then contactsDontExist cath dan + else cath <##> dan + where + aliceAddedDan :: HasCallStack => TestCC -> IO () + aliceAddedDan cc = do + cc <## "#team: alice added dan (Daniel) to the group (connecting...)" + cc <## "#team: new member dan is connected" + contactsDontExist cc1 cc2 = do + name1 <- userName cc1 + name2 <- userName cc2 + cc1 ##> ("@" <> name2 <> " hi") + cc1 <## ("no contact " <> name2) + cc2 ##> ("@" <> name1 <> " hi") + cc2 <## ("no contact " <> name1) diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index c51202340..1a2b74f76 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -18,7 +18,7 @@ chatProfileTests = do it "update user profile and notify contacts" testUpdateProfile it "update user profile with image" testUpdateProfileImage describe "user contact link" $ do - describe "create and connect via contact link" testUserContactLink + it "create and connect via contact link" testUserContactLink it "add contact link to profile" testProfileLink it "auto accept contact requests" testUserContactLinkAutoAccept it "deduplicate contact requests" testDeduplicateContactRequests @@ -57,7 +57,7 @@ chatProfileTests = do testUpdateProfile :: HasCallStack => FilePath -> IO () testUpdateProfile = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath alice ##> "/p" @@ -117,33 +117,35 @@ testUpdateProfileImage = bob <## "use @alice2 to send messages" (bob do - alice ##> "/ad" - cLink <- getContactLink alice True - bob ##> ("/c " <> cLink) - alice <#? bob - alice @@@ [("<@bob", "")] - alice ##> "/ac bob" - alice <## "bob (Bob): accepting contact request..." - concurrently_ - (bob <## "alice (Alice): contact is connected") - (alice <## "bob (Bob): contact is connected") - threadDelay 100000 - alice @@@ [("@bob", lastChatFeature)] - alice <##> bob +testUserContactLink :: HasCallStack => FilePath -> IO () +testUserContactLink = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + threadDelay 100000 + alice @@@ [("@bob", lastChatFeature)] + alice <##> bob - cath ##> ("/c " <> cLink) - alice <#? cath - alice @@@ [("<@cath", ""), ("@bob", "hey")] - alice ##> "/ac cath" - alice <## "cath (Catherine): accepting contact request..." - concurrently_ - (cath <## "alice (Alice): contact is connected") - (alice <## "cath (Catherine): contact is connected") - threadDelay 100000 - alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] - alice <##> cath + cath ##> ("/c " <> cLink) + alice <#? cath + alice @@@ [("<@cath", ""), ("@bob", "hey")] + alice ##> "/ac cath" + alice <## "cath (Catherine): accepting contact request..." + concurrently_ + (cath <## "alice (Alice): contact is connected") + (alice <## "cath (Catherine): contact is connected") + threadDelay 100000 + alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] + alice <##> cath testProfileLink :: HasCallStack => FilePath -> IO () testProfileLink = @@ -762,192 +764,193 @@ testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ bob `hasContactProfiles` ["bob", T.pack aliceIncognito] testJoinGroupIncognito :: HasCallStack => FilePath -> IO () -testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfile $ - \alice bob cath dan -> do - -- non incognito connections - connectUsers alice bob - connectUsers alice dan - connectUsers bob cath - connectUsers bob dan - connectUsers cath dan - -- cath connected incognito to alice - alice ##> "/c" - inv <- getInvitation alice - cath ##> ("/c i " <> inv) - cath <## "confirmation sent!" - cathIncognito <- getTermLine cath - concurrentlyN_ - [ do - cath <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> cathIncognito) - cath <## "use /i alice to print out this incognito profile again", - alice <## (cathIncognito <> ": contact is connected") - ] - -- alice creates group - alice ##> "/g secret_club" - alice <## "group #secret_club is created" - alice <## "to add members use /a secret_club or /create link #secret_club" - -- alice invites bob - alice ##> "/a secret_club bob admin" - concurrentlyN_ - [ alice <## "invitation to join the group #secret_club sent to bob", - do - bob <## "#secret_club: alice invites you to join the group as admin" - bob <## "use /j secret_club to accept" - ] - bob ##> "/j secret_club" - concurrently_ - (alice <## "#secret_club: bob joined the group") - (bob <## "#secret_club: you joined the group") - -- alice invites cath - alice ##> ("/a secret_club " <> cathIncognito <> " admin") - concurrentlyN_ - [ alice <## ("invitation to join the group #secret_club sent to " <> cathIncognito), - do - cath <## "#secret_club: alice invites you to join the group as admin" - cath <## ("use /j secret_club to join incognito as " <> cathIncognito) - ] - -- cath uses the same incognito profile when joining group, cath and bob don't merge contacts - cath ##> "/j secret_club" - concurrentlyN_ - [ alice <## ("#secret_club: " <> cathIncognito <> " joined the group"), - do - cath <## ("#secret_club: you joined the group incognito as " <> cathIncognito) - cath <## "#secret_club: member bob_1 (Bob) is connected", - do - bob <## ("#secret_club: alice added " <> cathIncognito <> " to the group (connecting...)") - bob <## ("#secret_club: new member " <> cathIncognito <> " is connected") - ] - -- cath cannot invite to the group because her membership is incognito - cath ##> "/a secret_club dan" - cath <## "you've connected to this group using an incognito profile - prohibited to invite contacts" - -- alice invites dan - alice ##> "/a secret_club dan admin" - concurrentlyN_ - [ alice <## "invitation to join the group #secret_club sent to dan", - do - dan <## "#secret_club: alice invites you to join the group as admin" - dan <## "use /j secret_club to accept" - ] - dan ##> "/j secret_club" - -- cath and dan don't merge contacts - concurrentlyN_ - [ alice <## "#secret_club: dan joined the group", - do - dan <## "#secret_club: you joined the group" - dan - <### [ ConsoleString $ "#secret_club: member " <> cathIncognito <> " is connected", - "#secret_club: member bob_1 (Bob) is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], - do - bob <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" - bob <## "#secret_club: new member dan_1 is connected" - bob <## "contact dan_1 is merged into dan" - bob <## "use @dan to send messages", - do - cath <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" - cath <## "#secret_club: new member dan_1 is connected" - ] - -- send messages - group is incognito for cath - alice #> "#secret_club hello" - concurrentlyN_ - [ bob <# "#secret_club alice> hello", - cath ?<# "#secret_club alice> hello", - dan <# "#secret_club alice> hello" - ] - bob #> "#secret_club hi there" - concurrentlyN_ - [ alice <# "#secret_club bob> hi there", - cath ?<# "#secret_club bob_1> hi there", - dan <# "#secret_club bob> hi there" - ] - cath ?#> "#secret_club hey" - concurrentlyN_ - [ alice <# ("#secret_club " <> cathIncognito <> "> hey"), - bob <# ("#secret_club " <> cathIncognito <> "> hey"), - dan <# ("#secret_club " <> cathIncognito <> "> hey") - ] - dan #> "#secret_club how is it going?" - concurrentlyN_ - [ alice <# "#secret_club dan> how is it going?", - bob <# "#secret_club dan> how is it going?", - cath ?<# "#secret_club dan_1> how is it going?" - ] - -- cath and bob can send messages via new direct connection, cath is incognito - bob #> ("@" <> cathIncognito <> " hi, I'm bob") - cath ?<# "bob_1> hi, I'm bob" - cath ?#> "@bob_1 hey, I'm incognito" - bob <# (cathIncognito <> "> hey, I'm incognito") - -- cath and dan can send messages via new direct connection, cath is incognito - dan #> ("@" <> cathIncognito <> " hi, I'm dan") - cath ?<# "dan_1> hi, I'm dan" - cath ?#> "@dan_1 hey, I'm incognito" - dan <# (cathIncognito <> "> hey, I'm incognito") - -- non incognito connections are separate - bob <##> cath - dan <##> cath - -- list groups - cath ##> "/gs" - cath <## "i #secret_club (4 members)" - -- list group members - alice ##> "/ms secret_club" - alice - <### [ "alice (Alice): owner, you, created group", - "bob (Bob): admin, invited, connected", - ConsoleString $ cathIncognito <> ": admin, invited, connected", - "dan (Daniel): admin, invited, connected" - ] - bob ##> "/ms secret_club" - bob - <### [ "alice (Alice): owner, host, connected", - "bob (Bob): admin, you, connected", - ConsoleString $ cathIncognito <> ": admin, connected", - "dan (Daniel): admin, connected" - ] - cath ##> "/ms secret_club" - cath - <### [ "alice (Alice): owner, host, connected", - "bob_1 (Bob): admin, connected", - ConsoleString $ "i " <> cathIncognito <> ": admin, you, connected", - "dan_1 (Daniel): admin, connected" - ] - dan ##> "/ms secret_club" - dan - <### [ "alice (Alice): owner, host, connected", - "bob (Bob): admin, connected", - ConsoleString $ cathIncognito <> ": admin, connected", - "dan (Daniel): admin, you, connected" - ] - -- remove member - bob ##> ("/rm secret_club " <> cathIncognito) - concurrentlyN_ - [ bob <## ("#secret_club: you removed " <> cathIncognito <> " from the group"), - alice <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), - dan <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), - do - cath <## "#secret_club: bob_1 removed you from the group" - cath <## "use /d #secret_club to delete the group" - ] - bob #> "#secret_club hi" - concurrentlyN_ - [ alice <# "#secret_club bob> hi", - dan <# "#secret_club bob> hi", - (cath "#secret_club hello" - concurrentlyN_ - [ bob <# "#secret_club alice> hello", - dan <# "#secret_club alice> hello", - (cath "#secret_club hello" - cath <## "you are no longer a member of the group" - -- cath can still message members directly - bob #> ("@" <> cathIncognito <> " I removed you from group") - cath ?<# "bob_1> I removed you from group" - cath ?#> "@bob_1 ok" - bob <# (cathIncognito <> "> ok") +testJoinGroupIncognito = + testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + -- non incognito connections + connectUsers alice bob + connectUsers alice dan + connectUsers bob cath + connectUsers bob dan + connectUsers cath dan + -- cath connected incognito to alice + alice ##> "/c" + inv <- getInvitation alice + cath ##> ("/c i " <> inv) + cath <## "confirmation sent!" + cathIncognito <- getTermLine cath + concurrentlyN_ + [ do + cath <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> cathIncognito) + cath <## "use /i alice to print out this incognito profile again", + alice <## (cathIncognito <> ": contact is connected") + ] + -- alice creates group + alice ##> "/g secret_club" + alice <## "group #secret_club is created" + alice <## "to add members use /a secret_club or /create link #secret_club" + -- alice invites bob + alice ##> "/a secret_club bob admin" + concurrentlyN_ + [ alice <## "invitation to join the group #secret_club sent to bob", + do + bob <## "#secret_club: alice invites you to join the group as admin" + bob <## "use /j secret_club to accept" + ] + bob ##> "/j secret_club" + concurrently_ + (alice <## "#secret_club: bob joined the group") + (bob <## "#secret_club: you joined the group") + -- alice invites cath + alice ##> ("/a secret_club " <> cathIncognito <> " admin") + concurrentlyN_ + [ alice <## ("invitation to join the group #secret_club sent to " <> cathIncognito), + do + cath <## "#secret_club: alice invites you to join the group as admin" + cath <## ("use /j secret_club to join incognito as " <> cathIncognito) + ] + -- cath uses the same incognito profile when joining group, cath and bob don't merge contacts + cath ##> "/j secret_club" + concurrentlyN_ + [ alice <## ("#secret_club: " <> cathIncognito <> " joined the group"), + do + cath <## ("#secret_club: you joined the group incognito as " <> cathIncognito) + cath <## "#secret_club: member bob_1 (Bob) is connected", + do + bob <## ("#secret_club: alice added " <> cathIncognito <> " to the group (connecting...)") + bob <## ("#secret_club: new member " <> cathIncognito <> " is connected") + ] + -- cath cannot invite to the group because her membership is incognito + cath ##> "/a secret_club dan" + cath <## "you've connected to this group using an incognito profile - prohibited to invite contacts" + -- alice invites dan + alice ##> "/a secret_club dan admin" + concurrentlyN_ + [ alice <## "invitation to join the group #secret_club sent to dan", + do + dan <## "#secret_club: alice invites you to join the group as admin" + dan <## "use /j secret_club to accept" + ] + dan ##> "/j secret_club" + -- cath and dan don't merge contacts + concurrentlyN_ + [ alice <## "#secret_club: dan joined the group", + do + dan <## "#secret_club: you joined the group" + dan + <### [ ConsoleString $ "#secret_club: member " <> cathIncognito <> " is connected", + "#secret_club: member bob_1 (Bob) is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + do + bob <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" + bob <## "#secret_club: new member dan_1 is connected" + bob <## "contact dan_1 is merged into dan" + bob <## "use @dan to send messages", + do + cath <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" + cath <## "#secret_club: new member dan_1 is connected" + ] + -- send messages - group is incognito for cath + alice #> "#secret_club hello" + concurrentlyN_ + [ bob <# "#secret_club alice> hello", + cath ?<# "#secret_club alice> hello", + dan <# "#secret_club alice> hello" + ] + bob #> "#secret_club hi there" + concurrentlyN_ + [ alice <# "#secret_club bob> hi there", + cath ?<# "#secret_club bob_1> hi there", + dan <# "#secret_club bob> hi there" + ] + cath ?#> "#secret_club hey" + concurrentlyN_ + [ alice <# ("#secret_club " <> cathIncognito <> "> hey"), + bob <# ("#secret_club " <> cathIncognito <> "> hey"), + dan <# ("#secret_club " <> cathIncognito <> "> hey") + ] + dan #> "#secret_club how is it going?" + concurrentlyN_ + [ alice <# "#secret_club dan> how is it going?", + bob <# "#secret_club dan> how is it going?", + cath ?<# "#secret_club dan_1> how is it going?" + ] + -- cath and bob can send messages via new direct connection, cath is incognito + bob #> ("@" <> cathIncognito <> " hi, I'm bob") + cath ?<# "bob_1> hi, I'm bob" + cath ?#> "@bob_1 hey, I'm incognito" + bob <# (cathIncognito <> "> hey, I'm incognito") + -- cath and dan can send messages via new direct connection, cath is incognito + dan #> ("@" <> cathIncognito <> " hi, I'm dan") + cath ?<# "dan_1> hi, I'm dan" + cath ?#> "@dan_1 hey, I'm incognito" + dan <# (cathIncognito <> "> hey, I'm incognito") + -- non incognito connections are separate + bob <##> cath + dan <##> cath + -- list groups + cath ##> "/gs" + cath <## "i #secret_club (4 members)" + -- list group members + alice ##> "/ms secret_club" + alice + <### [ "alice (Alice): owner, you, created group", + "bob (Bob): admin, invited, connected", + ConsoleString $ cathIncognito <> ": admin, invited, connected", + "dan (Daniel): admin, invited, connected" + ] + bob ##> "/ms secret_club" + bob + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): admin, you, connected", + ConsoleString $ cathIncognito <> ": admin, connected", + "dan (Daniel): admin, connected" + ] + cath ##> "/ms secret_club" + cath + <### [ "alice (Alice): owner, host, connected", + "bob_1 (Bob): admin, connected", + ConsoleString $ "i " <> cathIncognito <> ": admin, you, connected", + "dan_1 (Daniel): admin, connected" + ] + dan ##> "/ms secret_club" + dan + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): admin, connected", + ConsoleString $ cathIncognito <> ": admin, connected", + "dan (Daniel): admin, you, connected" + ] + -- remove member + bob ##> ("/rm secret_club " <> cathIncognito) + concurrentlyN_ + [ bob <## ("#secret_club: you removed " <> cathIncognito <> " from the group"), + alice <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), + dan <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), + do + cath <## "#secret_club: bob_1 removed you from the group" + cath <## "use /d #secret_club to delete the group" + ] + bob #> "#secret_club hi" + concurrentlyN_ + [ alice <# "#secret_club bob> hi", + dan <# "#secret_club bob> hi", + (cath "#secret_club hello" + concurrentlyN_ + [ bob <# "#secret_club alice> hello", + dan <# "#secret_club alice> hello", + (cath "#secret_club hello" + cath <## "you are no longer a member of the group" + -- cath can still message members directly + bob #> ("@" <> cathIncognito <> " I removed you from group") + cath ?<# "bob_1> I removed you from group" + cath ?#> "@bob_1 ok" + bob <# (cathIncognito <> "> ok") testCantInviteContactIncognito :: HasCallStack => FilePath -> IO () testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ @@ -1356,54 +1359,55 @@ testAllowFullDeletionGroup = testProhibitDirectMessages :: HasCallStack => FilePath -> IO () testProhibitDirectMessages = - testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do - createGroup3 "team" alice bob cath - threadDelay 1000000 - alice ##> "/set direct #team off" - alice <## "updated group preferences:" - alice <## "Direct messages: off" - directProhibited bob - directProhibited cath - threadDelay 1000000 - -- still can send direct messages to direct contacts - alice #> "@bob hello again" - bob <# "alice> hello again" - alice #> "@cath hello again" - cath <# "alice> hello again" - bob ##> "@cath hello again" - bob <## "direct messages to indirect contact cath are prohibited" - (cath "/j #team" - concurrentlyN_ - [ cath <## "#team: dan joined the group", - do - dan <## "#team: you joined the group" - dan - <### [ "#team: member alice (Alice) is connected", - "#team: member bob (Bob) is connected" - ], - do - alice <## "#team: cath added dan (Daniel) to the group (connecting...)" - alice <## "#team: new member dan is connected", - do - bob <## "#team: cath added dan (Daniel) to the group (connecting...)" - bob <## "#team: new member dan is connected" - ] - alice ##> "@dan hi" - alice <## "direct messages to indirect contact dan are prohibited" - bob ##> "@dan hi" - bob <## "direct messages to indirect contact dan are prohibited" - (dan "@alice hi" - dan <## "direct messages to indirect contact alice are prohibited" - dan ##> "@bob hi" - dan <## "direct messages to indirect contact bob are prohibited" - dan #> "@cath hi" - cath <# "dan> hi" - cath #> "@dan hi" - dan <# "cath> hi" + testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "team" alice bob cath + threadDelay 1000000 + alice ##> "/set direct #team off" + alice <## "updated group preferences:" + alice <## "Direct messages: off" + directProhibited bob + directProhibited cath + threadDelay 1000000 + -- still can send direct messages to direct contacts + alice #> "@bob hello again" + bob <# "alice> hello again" + alice #> "@cath hello again" + cath <# "alice> hello again" + bob ##> "@cath hello again" + bob <## "direct messages to indirect contact cath are prohibited" + (cath "/j #team" + concurrentlyN_ + [ cath <## "#team: dan joined the group", + do + dan <## "#team: you joined the group" + dan + <### [ "#team: member alice (Alice) is connected", + "#team: member bob (Bob) is connected" + ], + do + alice <## "#team: cath added dan (Daniel) to the group (connecting...)" + alice <## "#team: new member dan is connected", + do + bob <## "#team: cath added dan (Daniel) to the group (connecting...)" + bob <## "#team: new member dan is connected" + ] + alice ##> "@dan hi" + alice <## "direct messages to indirect contact dan are prohibited" + bob ##> "@dan hi" + bob <## "direct messages to indirect contact dan are prohibited" + (dan "@alice hi" + dan <## "direct messages to indirect contact alice are prohibited" + dan ##> "@bob hi" + dan <## "direct messages to indirect contact bob are prohibited" + dan #> "@cath hi" + cath <# "dan> hi" + cath #> "@dan hi" + dan <# "cath> hi" where directProhibited :: HasCallStack => TestCC -> IO () directProhibited cc = do diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index b525bf333..72cb99c97 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -67,9 +67,9 @@ versionTestMatrix2 runTest = do it "v1 to v2" $ runTestCfg2 testCfg testCfgV1 runTest it "v2 to v1" $ runTestCfg2 testCfgV1 testCfg runTest -versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath -versionTestMatrix3 runTest = do - it "v2" $ testChat3 aliceProfile bobProfile cathProfile runTest +-- versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath +-- versionTestMatrix3 runTest = do +-- it "v2" $ testChat3 aliceProfile bobProfile cathProfile runTest -- it "v1" $ testChatCfg3 testCfgV1 aliceProfile bobProfile cathProfile runTest -- it "v1 to v2" $ runTestCfg3 testCfg testCfgV1 testCfgV1 runTest diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 98c592fa7..3acc78e7d 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -230,16 +230,28 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpAcpt (MemberId "\1\2\3\4") it "x.grp.mem.new" $ "{\"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, profile = testProfile} + #==# 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-2\",\"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, profile = testProfile} + #==# 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-2\",\"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\":\"https://simplex.chat/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\":\"https://simplex.chat/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\"}}}" - #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq} + #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} + it "x.grp.mem.inv w/t directConnReq" $ + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"https://simplex.chat/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\"}}}" + #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/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\":\"https://simplex.chat/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, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq} + #==# 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\":\"https://simplex.chat/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-2\",\"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\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile From 67d5b6eace72149fd1aa827813130c95060b25bb Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 5 Sep 2023 20:24:39 +0300 Subject: [PATCH 16/41] desktop: open database dir (#3019) * desktop: open database dir * alert * move button --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../simplex/common/platform/Files.android.kt | 2 ++ .../kotlin/chat/simplex/common/platform/Files.kt | 2 ++ .../common/views/database/DatabaseView.kt | 11 +++++++++++ .../src/commonMain/resources/MR/base/strings.xml | 1 + .../simplex/common/platform/Files.desktop.kt | 16 ++++++++++++++++ 5 files changed, 32 insertions(+) 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 35c29371e..161bc51e6 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 fun desktopOpenDatabaseDir() {} + @Composable actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher { val launcher = rememberLauncherForActivityResult( 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 9bc26d445..9c702df54 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 @@ -23,6 +23,8 @@ expect val agentDatabaseFileName: String * */ expect val databaseExportDir: File +expect fun desktopOpenDatabaseDir() + fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { try { to.outputStream().use { stream -> 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 bd29cb7ae..4cce02887 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 @@ -75,6 +75,7 @@ fun DatabaseView( m.chatDbEncrypted.value, m.controller.appPrefs.storeDBPassphrase.state.value, m.controller.appPrefs.initialRandomDBPassphrase, + m.controller.appPrefs.developerTools.state.value, importArchiveLauncher, chatArchiveName, chatArchiveTime, @@ -125,6 +126,7 @@ fun DatabaseLayout( chatDbEncrypted: Boolean?, passphraseSaved: Boolean, initialRandomDBPassphrase: SharedPreference, + developerTools: Boolean, importArchiveLauncher: FileChooserLauncher, chatArchiveName: MutableState, chatArchiveTime: MutableState, @@ -187,6 +189,14 @@ fun DatabaseLayout( iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, disabled = operationsDisabled ) + if (appPlatform.isDesktop && developerTools) { + SettingsActionItem( + painterResource(MR.images.ic_folder_open), + stringResource(MR.strings.open_database_folder), + ::desktopOpenDatabaseDir, + disabled = operationsDisabled + ) + } SettingsActionItem( painterResource(MR.images.ic_ios_share), stringResource(MR.strings.export_database), @@ -661,6 +671,7 @@ fun PreviewDatabaseLayout() { chatDbEncrypted = false, passphraseSaved = false, initialRandomDBPassphrase = SharedPreference({ true }, {}), + developerTools = true, importArchiveLauncher = rememberFileChooserLauncher(true) {}, chatArchiveName = remember { mutableStateOf("dummy_archive") }, chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, 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 52449eaa9..2b83ff869 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -944,6 +944,7 @@ Import database New database archive Old database archive + Open database folder Delete database Error starting chat Stop chat? 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 b26023951..9042a6283 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 @@ -2,8 +2,10 @@ package chat.simplex.common.platform import androidx.compose.runtime.* import chat.simplex.common.* +import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR +import java.awt.Desktop import java.io.* import java.net.URI @@ -19,6 +21,20 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir +actual fun desktopOpenDatabaseDir() { + if (Desktop.isDesktopSupported()) { + try { + Desktop.getDesktop().open(dataDir); + } catch (e: IOException) { + Log.e(TAG, e.stackTraceToString()) + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.unknown_error), + text = e.stackTraceToString() + ) + } + } +} + @Composable actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher = remember(rememberedValue) { FileChooserLauncher(getContent, onResult) } From e60dbf6add88309c2c33a9970768dd1c1abbed1d Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 5 Sep 2023 20:54:31 +0300 Subject: [PATCH 17/41] multiplatform: reduce variables that is controlling running chat (#3021) * multiplatform: reduce variables that is controlling running chat * removed second variable --- .../common/views/database/DatabaseView.kt | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) 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 4cce02887..9ecd7dae3 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 @@ -39,7 +39,6 @@ fun DatabaseView( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) ) { val progressIndicator = remember { mutableStateOf(false) } - val runChat = remember { m.chatRunning } val prefs = m.controller.appPrefs val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) } @@ -60,16 +59,13 @@ fun DatabaseView( importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) } } - LaunchedEffect(m.chatRunning) { - runChat.value = m.chatRunning.value ?: true - } val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) } Box( Modifier.fillMaxSize(), ) { DatabaseLayout( progressIndicator.value, - runChat.value != false, + remember { m.chatRunning }.value != false, m.chatDbChanged.value, useKeychain.value, m.chatDbEncrypted.value, @@ -84,8 +80,8 @@ fun DatabaseView( chatItemTTL, m.currentUser.value, m.users, - startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) }, - stopChatAlert = { stopChatAlert(m, runChat) }, + startChat = { startChat(m, chatLastStart, m.chatDbChanged) }, + stopChatAlert = { stopChatAlert(m) }, exportArchive = { exportArchive(m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) }, deleteChatAlert = { deleteChatAlert(m, progressIndicator) }, deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(appFilesCountAndSize) }, @@ -339,7 +335,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String { return stringResource(if (chatArchiveTime < chatLastStart) MR.strings.old_database_archive else MR.strings.new_database_archive) } -private fun startChat(m: ChatModel, runChat: MutableState, chatLastStart: MutableState, chatDbChanged: MutableState) { +private fun startChat(m: ChatModel, chatLastStart: MutableState, chatDbChanged: MutableState) { withApi { try { if (chatDbChanged.value) { @@ -356,7 +352,6 @@ private fun startChat(m: ChatModel, runChat: MutableState, chatLastSta return@withApi } else { m.controller.apiStartChat() - runChat.value = true m.chatRunning.value = true } val ts = Clock.System.now() @@ -364,19 +359,19 @@ private fun startChat(m: ChatModel, runChat: MutableState, chatLastSta chatLastStart.value = ts platform.androidChatStartedAfterBeingOff() } catch (e: Error) { - runChat.value = false + m.chatRunning.value = false AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.toString()) } } } -private fun stopChatAlert(m: ChatModel, runChat: MutableState) { +private fun stopChatAlert(m: ChatModel) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.stop_chat_question), text = generalGetString(MR.strings.stop_chat_to_export_import_or_delete_chat_database), confirmText = generalGetString(MR.strings.stop_chat_confirmation), - onConfirm = { authStopChat(m, runChat) }, - onDismiss = { runChat.value = true } + onConfirm = { authStopChat(m) }, + onDismiss = { m.chatRunning.value = true } ) } @@ -387,7 +382,7 @@ private fun exportProhibitedAlert() { ) } -private fun authStopChat(m: ChatModel, runChat: MutableState) { +private fun authStopChat(m: ChatModel) { if (m.controller.appPrefs.performLA.get()) { authenticate( generalGetString(MR.strings.auth_stop_chat), @@ -395,30 +390,29 @@ private fun authStopChat(m: ChatModel, runChat: MutableState) { completed = { laResult -> when (laResult) { LAResult.Success, is LAResult.Unavailable -> { - stopChat(m, runChat) + stopChat(m) } is LAResult.Error -> { - runChat.value = true + m.chatRunning.value = true } is LAResult.Failed -> { - runChat.value = true + m.chatRunning.value = true } } } ) } else { - stopChat(m, runChat) + stopChat(m) } } -private fun stopChat(m: ChatModel, runChat: MutableState) { +private fun stopChat(m: ChatModel) { withApi { try { - runChat.value = false stopChatAsync(m) platform.androidChatStopped() } catch (e: Error) { - runChat.value = true + m.chatRunning.value = true AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_stopping_chat), e.toString()) } } From 68f359c90475205ffeb506532a0cb3ecf564ace8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 6 Sep 2023 10:15:32 +0400 Subject: [PATCH 18/41] core: enable creation of direct connections in group for chat protocol v2 (for beta; to be reverted for full release) (#3022) --- src/Simplex/Chat/Protocol.hs | 2 +- tests/ChatTests/Groups.hs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 2f93ab13d..d104e002f 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -56,7 +56,7 @@ supportedChatVRange = mkVersionRange 1 currentChatVersion -- version that starts support for skipping establishing direct connections in a group groupNoDirectVersion :: Version -groupNoDirectVersion = 2 +groupNoDirectVersion = 3 -- 2 data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e533a55da..82481e9df 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -68,16 +68,16 @@ chatGroupTests = do it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts describe "direct connections in group are not established based on chat protocol version" $ do describe "3 members group" $ do - testNoDirect _0 _0 True + testNoDirect _0 _0 False -- True testNoDirect _0 _1 False testNoDirect _1 _0 False testNoDirect _1 _1 False describe "4 members group" $ do - testNoDirect4 _0 _0 _0 True True True - testNoDirect4 _0 _0 _1 True False False - testNoDirect4 _0 _1 _0 False True False + testNoDirect4 _0 _0 _0 False False False -- True True True + testNoDirect4 _0 _0 _1 False False False -- True False False + testNoDirect4 _0 _1 _0 False False False -- False True False testNoDirect4 _0 _1 _1 False False False - testNoDirect4 _1 _0 _0 False False True + testNoDirect4 _1 _0 _0 False False False -- False False True testNoDirect4 _1 _0 _1 False False False testNoDirect4 _1 _1 _0 False False False testNoDirect4 _1 _1 _1 False False False From e6baca561080270b92d697939e8c40acd17b869d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 6 Sep 2023 11:41:23 +0400 Subject: [PATCH 19/41] core: rename conn vrange into peer vrange (#3023) --- src/Simplex/Chat.hs | 40 +++++++++---------- .../M20230829_connections_chat_vrange.hs | 16 ++++---- src/Simplex/Chat/Migrations/chat_schema.sql | 8 ++-- src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Direct.hs | 20 +++++----- src/Simplex/Chat/Store/Groups.hs | 30 +++++++------- src/Simplex/Chat/Store/Messages.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 4 +- src/Simplex/Chat/Store/Shared.hs | 16 ++++---- src/Simplex/Chat/Types.hs | 4 +- src/Simplex/Chat/View.hs | 8 ++-- tests/ChatTests/Direct.hs | 14 +++---- tests/ChatTests/Utils.hs | 2 +- 13 files changed, 84 insertions(+), 84 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 6fc2f855e..b3ba94c8e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1441,12 +1441,12 @@ processChatCommand = \case inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId (inv,) <$> getContactViaMember db user fromMember let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation - Contact {activeConn = Connection {connChatVRange}} = ct + Contact {activeConn = Connection {peerChatVRange}} = ct withChatLock "joinGroup" . procCmd $ do dm <- directMessage $ XGrpAcpt (memberId (membership :: GroupMember)) agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm withStore' $ \db -> do - createMemberConnection db userId fromMember agentConnId connChatVRange + createMemberConnection db userId fromMember agentConnId peerChatVRange updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted updateCIGroupInvitationStatus user @@ -2850,7 +2850,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> Nothing processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg connEntity conn@Connection {connId, connChatVRange, viaUserContactLink, groupLinkId, customUserProfileId, connectionCode} = \case + processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, groupLinkId, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of CONF confId _ connInfo -> do -- [incognito] send saved profile @@ -2931,7 +2931,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do CONF confId _ connInfo -> do -- confirming direct connection with a member ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - conn' <- updateConnChatVRange conn chatVRange + conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of XGrpMemInfo _memId _memProfile -> do -- TODO check member ID @@ -2941,7 +2941,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "CONF from member must have x.grp.mem.info" INFO connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - _conn' <- updateConnChatVRange conn chatVRange + _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of XGrpMemInfo _memId _memProfile -> do -- TODO check member ID @@ -2973,7 +2973,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ groupId_ $ \groupId -> do gVar <- asks idsDrg groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation - withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds connChatVRange + withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds peerChatVRange _ -> pure () Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do @@ -3042,7 +3042,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- [async agent commands] XGrpMemIntro continuation on receiving INV CFCreateConnGrpMemInv -> ifM - (featureVersionSupported (connChatVRange conn) groupNoDirectVersion) + (featureVersionSupported (peerChatVRange conn) groupNoDirectVersion) sendWithoutDirectCReq sendWithDirectCReq where @@ -3078,7 +3078,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" CONF confId _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - conn' <- updateConnChatVRange conn chatVRange + conn' <- updatePeerChatVRange conn chatVRange case memberCategory m of GCInviteeMember -> case chatMsgEvent of @@ -3100,7 +3100,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "CONF from member must have x.grp.mem.info" INFO connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - _conn' <- updateConnChatVRange conn chatVRange + _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of XGrpMemInfo memId _memProfile | sameMemberId memId m -> do @@ -3277,7 +3277,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- when recipient of the file "joins" connection created by the sender CONF confId _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - conn' <- updateConnChatVRange conn chatVRange + conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of -- TODO save XFileAcpt message XFileAcpt name @@ -3346,7 +3346,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- (sender doesn't create connections for all group members) CONF confId _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - conn' <- updateConnChatVRange conn chatVRange + conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of XOk -> allowAgentConnectionAsync user conn' confId XOk -- [async agent commands] no continuation needed, but command should be asynchronous for stability _ -> pure () @@ -4042,7 +4042,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () processGroupInvitation ct inv msg msgMeta = do - let Contact {localDisplayName = c, activeConn = Connection {connChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct + let Contact {localDisplayName = c, activeConn = Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv checkIntegrityCreateItem (CDDirectRcv ct) msgMeta when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) @@ -4053,7 +4053,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do then do connIds <- joinAgentConnectionAsync user True connRequest =<< directMessage (XGrpAcpt memberId) withStore' $ \db -> do - createMemberConnectionAsync db user hostId connIds connChatVRange + createMemberConnectionAsync db user hostId connIds peerChatVRange updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) @@ -4256,7 +4256,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do saveConnInfo :: Connection -> ConnInfo -> m Connection saveConnInfo activeConn connInfo = do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage activeConn connInfo - conn' <- updateConnChatVRange activeConn chatVRange + conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of XInfo p -> do ct <- withStore $ \db -> createDirectContact db user conn' p @@ -4481,11 +4481,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () -updateConnChatVRange :: ChatMonad m => Connection -> VersionRange -> m Connection -updateConnChatVRange conn@Connection {connId, connChatVRange} msgChatVRange - | msgChatVRange /= connChatVRange = do - withStore' $ \db -> setConnChatVRange db connId msgChatVRange - pure conn {connChatVRange = msgChatVRange} +updatePeerChatVRange :: ChatMonad m => Connection -> VersionRange -> m Connection +updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange + | msgChatVRange /= peerChatVRange = do + withStore' $ \db -> setPeerChatVRange db connId msgChatVRange + pure conn {peerChatVRange = msgChatVRange} | otherwise = pure conn featureVersionSupported :: ChatMonad' m => VersionRange -> Version -> m Bool @@ -4759,7 +4759,7 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn saveRcvMSG :: ChatMonad m => Connection -> ConnOrGroupId -> MsgMeta -> MsgBody -> CommandId -> m (Connection, RcvMessage) saveRcvMSG conn@Connection {connId} connOrGroupId agentMsgMeta msgBody agentAckCmdId = do ACMsg _ ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} <- parseAChatMessage conn agentMsgMeta msgBody - conn' <- updateConnChatVRange conn chatVRange + conn' <- updatePeerChatVRange conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta newMsg = NewMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} diff --git a/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs b/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs index b657cc648..2588553a9 100644 --- a/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs +++ b/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs @@ -8,19 +8,19 @@ import Database.SQLite.Simple.QQ (sql) m20230829_connections_chat_vrange :: Query m20230829_connections_chat_vrange = [sql| -ALTER TABLE connections ADD COLUMN chat_vrange_min_version INTEGER NOT NULL DEFAULT 1; -ALTER TABLE connections ADD COLUMN chat_vrange_max_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE connections ADD COLUMN peer_chat_min_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE connections ADD COLUMN peer_chat_max_version INTEGER NOT NULL DEFAULT 1; -ALTER TABLE contact_requests ADD COLUMN chat_vrange_min_version INTEGER NOT NULL DEFAULT 1; -ALTER TABLE contact_requests ADD COLUMN chat_vrange_max_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE contact_requests ADD COLUMN peer_chat_min_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE contact_requests ADD COLUMN peer_chat_max_version INTEGER NOT NULL DEFAULT 1; |] down_m20230829_connections_chat_vrange :: Query down_m20230829_connections_chat_vrange = [sql| -ALTER TABLE contact_requests DROP COLUMN chat_vrange_max_version; -ALTER TABLE contact_requests DROP COLUMN chat_vrange_min_version; +ALTER TABLE contact_requests DROP COLUMN peer_chat_max_version; +ALTER TABLE contact_requests DROP COLUMN peer_chat_min_version; -ALTER TABLE connections DROP COLUMN chat_vrange_max_version; -ALTER TABLE connections DROP COLUMN chat_vrange_min_version; +ALTER TABLE connections DROP COLUMN peer_chat_max_version; +ALTER TABLE connections DROP COLUMN peer_chat_min_version; |] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index eafdc85d1..f0731b6ef 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -285,8 +285,8 @@ CREATE TABLE connections( security_code TEXT NULL, security_code_verified_at TEXT NULL, auth_err_counter INTEGER DEFAULT 0 CHECK(auth_err_counter NOT NULL), - chat_vrange_min_version INTEGER NOT NULL DEFAULT 1, - chat_vrange_max_version INTEGER NOT NULL DEFAULT 1, + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -320,8 +320,8 @@ CREATE TABLE contact_requests( user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK(updated_at NOT NULL), xcontact_id BLOB, - chat_vrange_min_version INTEGER NOT NULL DEFAULT 1, - chat_vrange_max_version INTEGER NOT NULL DEFAULT 1, + 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 UPDATE CASCADE diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index a7c8fd6c3..4bd092b7b 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -50,7 +50,7 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, - chat_vrange_min_version, chat_vrange_max_version + peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? |] diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 3b56e57b7..7df2858e9 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -145,7 +145,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id @@ -443,7 +443,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers db [sql| INSERT INTO contact_requests - (user_contact_link_id, agent_invitation_id, chat_vrange_min_version, chat_vrange_max_version, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id) + (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?,?,?,?,?) |] (userContactLinkId, invId, minV, maxV, profileId, ldn, userId, currentTs, currentTs, xContactId_) @@ -461,7 +461,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id @@ -479,7 +479,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, - cr.chat_vrange_min_version, cr.chat_vrange_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -499,7 +499,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers db [sql| UPDATE contact_requests - SET agent_invitation_id = ?, chat_vrange_min_version = ?, chat_vrange_max_version = ?, updated_at = ? + SET agent_invitation_id = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] (invId, minV, maxV, currentTs, userId, cReqId) @@ -509,7 +509,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers db [sql| UPDATE contact_requests - SET agent_invitation_id = ?, chat_vrange_min_version = ?, chat_vrange_max_version = ?, local_display_name = ?, updated_at = ? + SET agent_invitation_id = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] (invId, minV, maxV, ldn, currentTs, userId, cReqId) @@ -548,7 +548,7 @@ getContactRequest db User {userId} contactRequestId = SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, - cr.chat_vrange_min_version, cr.chat_vrange_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -625,7 +625,7 @@ getContact_ db user@User {userId} contactId deleted = -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id @@ -674,7 +674,7 @@ getContactConnections db userId Contact {contactId} = [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? @@ -691,7 +691,7 @@ getConnectionById db User {userId} connId = ExceptT $ do [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, - chat_vrange_min_version, chat_vrange_max_version + peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? |] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 40c21d616..59f1b6090 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -154,7 +154,7 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.group_id = ? @@ -236,7 +236,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = 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, 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.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -530,7 +530,7 @@ groupMemberQuery = 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, 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.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.connection_id = ( @@ -615,11 +615,11 @@ getGroupInvitation db user 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 -> ExceptT StoreError IO GroupMember -createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Connection {connChatVRange}} memberRole agentConnId connRequest = +createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Connection {peerChatVRange}} memberRole agentConnId connRequest = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt - void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVRange Nothing 0 createdAt + void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt pure member where createMember_ memberId createdAt = do @@ -655,12 +655,12 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con ) createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> ExceptT StoreError IO () -createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) connChatVRange = +createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt groupMemberId <- liftIO $ insertedRowId db - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId connChatVRange Nothing 0 createdAt + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt setCommandConnId db user cmdId connId where insertMember_ memberId createdAt = @@ -690,7 +690,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} = -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id JOIN connections c ON c.connection_id = ( @@ -714,14 +714,14 @@ getMemberInvitation db User {userId} groupMemberId = DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRange -> IO () -createMemberConnection db userId GroupMember {groupMemberId} agentConnId connChatVRange = do +createMemberConnection db userId GroupMember {groupMemberId} agentConnId peerChatVRange = do currentTs <- getCurrentTime - void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVRange Nothing 0 currentTs + void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRange -> IO () -createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) connChatVRange = do +createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) peerChatVRange = do currentTs <- getCurrentTime - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId connChatVRange Nothing 0 currentTs + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs setCommandConnId db user cmdId connId updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () @@ -978,7 +978,7 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRange -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createMemberConnection_ db userId groupMemberId agentConnId connChatVRange viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId connChatVRange viaContact Nothing Nothing +createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId peerChatVRange viaContact Nothing Nothing getViaGroupMember :: DB.Connection -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db User {userId, userContactId} Contact {contactId} = @@ -999,7 +999,7 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = 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, 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.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -1033,7 +1033,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, 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.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id JOIN connections c ON c.connection_id = ( diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 12d1b6525..ddd59319d 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -479,7 +479,7 @@ getDirectChatPreviews_ db user@User {userId} = do -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version, + c.peer_chat_min_version, c.peer_chat_max_version, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), ct.unread_chat, -- ChatItem @@ -611,7 +611,7 @@ getContactRequestChatPreviews_ db User {userId} = SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, - cr.chat_vrange_min_version, cr.chat_vrange_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 1d305ffd6..0c2f1f636 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -317,7 +317,7 @@ getUserAddressConnections db User {userId} = do [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version + c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL @@ -332,7 +332,7 @@ getUserContactLinks db User {userId} = [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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.chat_vrange_min_version, c.chat_vrange_max_version, + c.peer_chat_min_version, c.peer_chat_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 48e2e5692..7ec307ab4 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -143,8 +143,8 @@ toConnection :: ConnectionRow -> Connection toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = let entityId = entityId_ connType connectionCode = SecurityCode <$> code_ <*> verifiedAt_ - connChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - in Connection {connId, agentConnId = AgentConnId acId, connChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, connectionCode, authErrCounter, createdAt} + peerChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + in Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, connectionCode, authErrCounter, createdAt} where entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId @@ -159,7 +159,7 @@ toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, v toMaybeConnection _ = Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> IO Connection -createConnection_ db userId connType entityId acId connChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs = do +createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs = do viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId -> maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId) let viaGroupLink = isJust viaLinkGroupId @@ -169,7 +169,7 @@ createConnection_ db userId connType entityId acId connChatVRange@(VersionRange INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, - chat_vrange_min_version, chat_vrange_max_version + peer_chat_min_version, peer_chat_max_version ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, ConnNew, connType) @@ -177,17 +177,17 @@ createConnection_ db userId connType entityId acId connChatVRange@(VersionRange :. (minV, maxV) ) connId <- insertedRowId db - pure Connection {connId, agentConnId = AgentConnId acId, connChatVRange, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} where ent ct = if connType == ct then entityId else Nothing -setConnChatVRange :: DB.Connection -> Int64 -> VersionRange -> IO () -setConnChatVRange db connId (VersionRange minVer maxVer) = +setPeerChatVRange :: DB.Connection -> Int64 -> VersionRange -> IO () +setPeerChatVRange db connId (VersionRange minVer maxVer) = DB.execute db [sql| UPDATE connections - SET chat_vrange_min_version = ?, chat_vrange_max_version = ? + SET peer_chat_min_version = ?, peer_chat_max_version = ? WHERE connection_id = ? |] (minVer, maxVer, connId) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index ee5899db2..2d77cbe77 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -564,7 +564,7 @@ memberInfo :: GroupMember -> MemberInfo memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = MemberInfo memberId memberRole memberChatVRange (fromLocalProfile memberProfile) where - memberChatVRange = ChatVersionRange . connChatVRange <$> activeConn + memberChatVRange = ChatVersionRange . peerChatVRange <$> activeConn data ReceivedGroupInvitation = ReceivedGroupInvitation { fromMember :: GroupMember, @@ -1167,7 +1167,7 @@ type ConnReqContact = ConnectionRequestUri 'CMContact data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, - connChatVRange :: VersionRange, + peerChatVRange :: VersionRange, connLevel :: Int, viaContact :: Maybe Int64, -- group member contact ID, if not direct connection viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 57f508c71..1a740bef5 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -963,7 +963,7 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta incognitoProfile <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (contactSecurityCode ct)] - <> [viewConnChatVRange (connChatVRange activeConn)] + <> [viewPeerChatVRange (peerChatVRange activeConn)] viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] viewGroupInfo GroupInfo {groupId} s = @@ -979,14 +979,14 @@ viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProf <> maybe ["member not connected"] viewConnectionStats stats <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (memberSecurityCode m) | isJust stats] - <> maybe [] (\ac -> [viewConnChatVRange (connChatVRange ac)]) activeConn + <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn viewConnectionVerified :: Maybe SecurityCode -> StyledString viewConnectionVerified (Just _) = "connection verified" -- TODO show verification time? viewConnectionVerified _ = "connection not verified, use " <> highlight' "/code" <> " command to see security code" -viewConnChatVRange :: VersionRange -> StyledString -viewConnChatVRange (VersionRange minVer maxVer) = "chat protocol version range: (" <> sShow minVer <> ", " <> sShow maxVer <> ")" +viewPeerChatVRange :: VersionRange -> StyledString +viewPeerChatVRange (VersionRange minVer maxVer) = "peer chat protocol version range: (" <> sShow minVer <> ", " <> sShow maxVer <> ")" viewConnectionStats :: ConnectionStats -> [StyledString] viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} = diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index e7442d90a..4bb87b1e9 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -96,18 +96,18 @@ chatDirectTests = do describe "delivery receipts" $ do it "should send delivery receipts" testSendDeliveryReceipts it "should send delivery receipts depending on configuration" testConfigureDeliveryReceipts - describe "negotiate connection chat protocol version range" $ do - describe "version range correctly set for new connection via invitation" $ do + describe "negotiate connection peer chat protocol version range" $ do + describe "peer version range correctly set for new connection via invitation" $ do testInvVRange supportedChatVRange supportedChatVRange testInvVRange supportedChatVRange vr11 testInvVRange vr11 supportedChatVRange testInvVRange vr11 vr11 - describe "version range correctly set for new connection via contact request" $ do + describe "peer version range correctly set for new connection via contact request" $ do testReqVRange supportedChatVRange supportedChatVRange testReqVRange supportedChatVRange vr11 testReqVRange vr11 supportedChatVRange testReqVRange vr11 vr11 - it "update connection version range on received messages" testUpdateConnChatVRange + it "update peer version range on received messages" testUpdatePeerChatVRange where testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 @@ -2330,8 +2330,8 @@ testConnReqChatVRange ct1VRange ct2VRange tmp = bob ##> "/i alice" contactInfoChatVRange bob ct1VRange -testUpdateConnChatVRange :: HasCallStack => FilePath -> IO () -testUpdateConnChatVRange tmp = +testUpdatePeerChatVRange :: HasCallStack => FilePath -> IO () +testUpdatePeerChatVRange tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do withNewTestChatCfg tmp cfg11 "bob" bobProfile $ \bob -> do connectUsers alice bob @@ -2378,4 +2378,4 @@ contactInfoChatVRange cc (VersionRange minVer maxVer) = do cc <## "sending messages via: localhost" cc <## "you've shared main profile with this contact" cc <## "connection not verified, use /code command to see security code" - cc <## ("chat protocol version range: (" <> show minVer <> ", " <> show maxVer <> ")") + cc <## ("peer chat protocol version range: (" <> show minVer <> ", " <> show maxVer <> ")") diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 72cb99c97..c120d661f 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -528,7 +528,7 @@ startFileTransferWithDest' cc1 cc2 fileName fileSize fileDest_ = do currentChatVRangeInfo :: String currentChatVRangeInfo = - "chat protocol version range: " <> vRangeStr supportedChatVRange + "peer chat protocol version range: " <> vRangeStr supportedChatVRange vRangeStr :: VersionRange -> String vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxVer <> ")" From b6c23b59caedb3e0bf160d891045a4c71dbb9fdc Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 6 Sep 2023 17:48:37 +0400 Subject: [PATCH 20/41] core: change logic of checking if peer supports feature (check if peer version is compatible) (#3024) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 27 ++++++++------------------- src/Simplex/Chat/Protocol.hs | 6 +++--- stack.yaml | 2 +- tests/Bots/DirectoryTests.hs | 18 +++++++++--------- tests/ChatClient.hs | 3 +-- tests/ChatTests/Groups.hs | 16 ++++++++-------- 8 files changed, 32 insertions(+), 44 deletions(-) diff --git a/cabal.project b/cabal.project index b40b009b3..ec72b72fc 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: 980e5c4d1ec15f44290542fd2a5d1c08456f00d1 + tag: 351f42650c57f310fc1ea858ff9b7178823f1fd4 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 09ac41e49..dbbc7475c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."980e5c4d1ec15f44290542fd2a5d1c08456f00d1" = "1lqciyy215dvmbhykyp80bwipqmxybv39p6jff6vjgd5r34958nh"; + "https://github.com/simplex-chat/simplexmq.git"."351f42650c57f310fc1ea858ff9b7178823f1fd4" = "12r13yc0qk9dkii58808862wraqrk66rzmkrgyp6lg1xrazrd0d2"; "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"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b3ba94c8e..84f6645f9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1866,7 +1866,7 @@ processChatCommand = \case xftpCfg <- readTVarIO =<< asks userXFTPFileConfig fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs when (fromInteger fileSize > maxFileSize) $ throwChatError $ CEFileSize f - let chunks = -((-fileSize) `div` fileChunkSize) + let chunks = - ((- fileSize) `div` fileChunkSize) fileInline = inlineFileMode mc inlineFiles chunks n fileMode = case xftpCfg of Just cfg @@ -2566,7 +2566,7 @@ cleanupManager = do `catchChatError` (toView . CRChatError (Just user)) cleanupMessages = do ts <- liftIO getCurrentTime - let cutoffTs = addUTCTime (-(30 * nominalDay)) ts + let cutoffTs = addUTCTime (- (30 * nominalDay)) ts withStoreCtx' (Just "cleanupManager, deleteOldMessages") (`deleteOldMessages` cutoffTs) startProximateTimedItemThread :: ChatMonad m => User -> (ChatRef, ChatItemId) -> UTCTime -> m () @@ -3040,11 +3040,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do case cReq of groupConnReq@(CRInvitationUri _ _) -> case cmdFunction of -- [async agent commands] XGrpMemIntro continuation on receiving INV - CFCreateConnGrpMemInv -> - ifM - (featureVersionSupported (peerChatVRange conn) groupNoDirectVersion) - sendWithoutDirectCReq - sendWithDirectCReq + CFCreateConnGrpMemInv + | isCompatibleRange (peerChatVRange conn) groupNoDirectVRange -> sendWithoutDirectCReq + | otherwise -> sendWithDirectCReq where sendWithoutDirectCReq = do let GroupMember {groupMemberId, memberId} = m @@ -4291,11 +4289,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do groupConnIds <- createConn directConnIds <- case memberChatVRange of Nothing -> Just <$> createConn - Just mcvr -> - ifM - (featureVersionSupported (fromChatVRange mcvr) groupNoDirectVersion) - (pure Nothing) - (Just <$> createConn) + Just mcvr + | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> pure Nothing + | otherwise -> Just <$> createConn let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId _ -> messageError "x.grp.mem.intro can be only sent by host member" @@ -4488,13 +4484,6 @@ updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange pure conn {peerChatVRange = msgChatVRange} | otherwise = pure conn -featureVersionSupported :: ChatMonad' m => VersionRange -> Version -> m Bool -featureVersionSupported peerVRange v = do - ChatConfig {chatVRange} <- asks config - case chatVRange `compatibleVersion` peerVRange of - Just (Compatible v') -> pure $ v' >= v - Nothing -> pure False - parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index d104e002f..13692b57c 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -54,9 +54,9 @@ currentChatVersion = 2 supportedChatVRange :: VersionRange supportedChatVRange = mkVersionRange 1 currentChatVersion --- version that starts support for skipping establishing direct connections in a group -groupNoDirectVersion :: Version -groupNoDirectVersion = 3 -- 2 +-- version range that supports skipping establishing direct connections in a group +groupNoDirectVRange :: VersionRange +groupNoDirectVRange = mkVersionRange 2 currentChatVersion data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} diff --git a/stack.yaml b/stack.yaml index c949cbb16..9c6b35432 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: 980e5c4d1ec15f44290542fd2a5d1c08456f00d1 + commit: 351f42650c57f310fc1ea858ff9b7178823f1fd4 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index a0d69d195..f34ab042e 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -795,7 +795,7 @@ withDirectoryServiceCfg tmp cfg test = do connectUsers ds superUser ds ##> "/ad" getContactLink ds True - withDirectory tmp dsLink test + withDirectory tmp cfg dsLink test restoreDirectoryService :: HasCallStack => FilePath -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () restoreDirectoryService tmp ctCount grCount test = do @@ -809,24 +809,24 @@ restoreDirectoryService tmp ctCount grCount test = do dsLink <- getContactLink ds False ds <## "auto_accept on" pure dsLink - withDirectory tmp dsLink test + withDirectory tmp testCfg dsLink test -withDirectory :: HasCallStack => FilePath -> String -> (TestCC -> String -> IO ()) -> IO () -withDirectory tmp dsLink test = do +withDirectory :: HasCallStack => FilePath -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () +withDirectory tmp cfg dsLink test = do let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] - runDirectory opts $ - withTestChat tmp "super_user" $ \superUser -> do + runDirectory cfg opts $ + withTestChatCfg tmp cfg "super_user" $ \superUser -> do superUser <## "1 contacts connected (use /cs for the list)" test superUser dsLink -runDirectory :: DirectoryOpts -> IO () -> IO () -runDirectory opts@DirectoryOpts {directoryLog} action = do +runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () +runDirectory cfg opts@DirectoryOpts {directoryLog} action = do st <- restoreDirectoryStore directoryLog t <- forkIO $ bot st threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) where - bot st = simplexChatCore testCfg (mkChatOpts opts) Nothing $ directoryService st opts + bot st = simplexChatCore cfg (mkChatOpts opts) Nothing $ directoryService st opts registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 43760c99b..9e5d4fe1c 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -22,7 +22,6 @@ import Simplex.Chat import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..)) import Simplex.Chat.Core import Simplex.Chat.Options -import Simplex.Chat.Protocol (groupNoDirectVersion) import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Terminal @@ -142,7 +141,7 @@ mkCfgCreateGroupDirect :: ChatConfig -> ChatConfig mkCfgCreateGroupDirect cfg = cfg {chatVRange = groupCreateDirectVRange} groupCreateDirectVRange :: VersionRange -groupCreateDirectVRange = mkVersionRange 1 (groupNoDirectVersion - 1) +groupCreateDirectVRange = mkVersionRange 1 1 createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 82481e9df..23df9f460 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -68,17 +68,17 @@ chatGroupTests = do it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts describe "direct connections in group are not established based on chat protocol version" $ do describe "3 members group" $ do - testNoDirect _0 _0 False -- True - testNoDirect _0 _1 False + testNoDirect _0 _0 True + testNoDirect _0 _1 True testNoDirect _1 _0 False testNoDirect _1 _1 False describe "4 members group" $ do - testNoDirect4 _0 _0 _0 False False False -- True True True - testNoDirect4 _0 _0 _1 False False False -- True False False - testNoDirect4 _0 _1 _0 False False False -- False True False - testNoDirect4 _0 _1 _1 False False False - testNoDirect4 _1 _0 _0 False False False -- False False True - testNoDirect4 _1 _0 _1 False False False + testNoDirect4 _0 _0 _0 True True True + testNoDirect4 _0 _0 _1 True True True + testNoDirect4 _0 _1 _0 True True False + testNoDirect4 _0 _1 _1 True True False + testNoDirect4 _1 _0 _0 False False True + testNoDirect4 _1 _0 _1 False False True testNoDirect4 _1 _1 _0 False False False testNoDirect4 _1 _1 _1 False False False where From 37eef3c6c99d69644650840c8a5b943eec839f79 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 6 Sep 2023 18:05:42 +0400 Subject: [PATCH 21/41] core: enable creation of direct connections in group (revert this instead of #3022) (#3025) --- src/Simplex/Chat.hs | 4 ++-- tests/ChatTests/Groups.hs | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 84f6645f9..359e7f5b5 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3041,7 +3041,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do groupConnReq@(CRInvitationUri _ _) -> case cmdFunction of -- [async agent commands] XGrpMemIntro continuation on receiving INV CFCreateConnGrpMemInv - | isCompatibleRange (peerChatVRange conn) groupNoDirectVRange -> sendWithoutDirectCReq + | isCompatibleRange (peerChatVRange conn) groupNoDirectVRange -> sendWithDirectCReq -- sendWithoutDirectCReq | otherwise -> sendWithDirectCReq where sendWithoutDirectCReq = do @@ -4290,7 +4290,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do directConnIds <- case memberChatVRange of Nothing -> Just <$> createConn Just mcvr - | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> pure Nothing + | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> Just <$> createConn -- pure Nothing | otherwise -> Just <$> createConn let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 23df9f460..d476285fc 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -68,17 +68,17 @@ chatGroupTests = do it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts describe "direct connections in group are not established based on chat protocol version" $ do describe "3 members group" $ do - testNoDirect _0 _0 True - testNoDirect _0 _1 True + testNoDirect _0 _0 False -- True + testNoDirect _0 _1 False -- True testNoDirect _1 _0 False testNoDirect _1 _1 False describe "4 members group" $ do - testNoDirect4 _0 _0 _0 True True True - testNoDirect4 _0 _0 _1 True True True - testNoDirect4 _0 _1 _0 True True False - testNoDirect4 _0 _1 _1 True True False - testNoDirect4 _1 _0 _0 False False True - testNoDirect4 _1 _0 _1 False False True + testNoDirect4 _0 _0 _0 False False False -- True True True + testNoDirect4 _0 _0 _1 False False False -- True True True + testNoDirect4 _0 _1 _0 False False False -- True True False + testNoDirect4 _0 _1 _1 False False False -- True True False + testNoDirect4 _1 _0 _0 False False False -- False False True + testNoDirect4 _1 _0 _1 False False False -- False False True testNoDirect4 _1 _1 _0 False False False testNoDirect4 _1 _1 _1 False False False where From edeaf36e8b8eede3194af5c11a5d433c11182d31 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 6 Sep 2023 19:54:13 +0100 Subject: [PATCH 22/41] core: C api to encrypt/decrypt local app files (#3029) * core: C api to encrypt/decrypt local app files * do not call CF.hPut with empty chunk --- src/Simplex/Chat/Mobile/File.hs | 67 ++++++++++++++++++++++++++++++++- tests/MobileTests.hs | 30 ++++++++++++--- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 25e694365..1c9219cab 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -1,10 +1,14 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TupleSections #-} module Simplex.Chat.Mobile.File ( cChatWriteFile, cChatReadFile, + cChatEncryptFile, + cChatDecryptFile, WriteFileResult (..), ReadFileResult (..), chatWriteFile, @@ -19,16 +23,18 @@ import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Lazy as LB import qualified Data.ByteString.Lazy.Char8 as LB' +import Data.Either (fromLeft) import Data.Word (Word8) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr import GHC.Generics (Generic) import Simplex.Chat.Mobile.Shared -import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..), CryptoFileHandle, FTCryptoError (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import UnliftIO (Handle, IOMode (..), withFile) data WriteFileResult = WFResult {cryptoArgs :: CryptoFileArgs} @@ -81,3 +87,62 @@ chatReadFile path keyStr nonceStr = do nonce <- liftEither $ strDecode nonceStr let file = CryptoFile path $ Just $ CFArgs key nonce withExceptT show $ CF.readFile file + +cChatEncryptFile :: CString -> CString -> IO CJSONString +cChatEncryptFile cFromPath cToPath = do + fromPath <- peekCAString cFromPath + toPath <- peekCAString cToPath + r <- chatEncryptFile fromPath toPath + newCAString . LB'.unpack $ J.encode r + +chatEncryptFile :: FilePath -> FilePath -> IO WriteFileResult +chatEncryptFile fromPath toPath = + either WFError WFResult <$> runExceptT encrypt + where + encrypt = do + cfArgs <- liftIO $ CF.randomArgs + let toFile = CryptoFile toPath $ Just cfArgs + withExceptT show $ + withFile fromPath ReadMode $ \r -> CF.withFile toFile WriteMode $ \w -> do + encryptChunks r w + liftIO $ CF.hPutTag w + pure cfArgs + encryptChunks r w = do + ch <- liftIO $ LB.hGet r chunkSize + unless (LB.null ch) $ liftIO $ CF.hPut w ch + unless (LB.length ch < chunkSize) $ encryptChunks r w + +cChatDecryptFile :: CString -> CString -> CString -> CString -> IO CString +cChatDecryptFile cFromPath cKey cNonce cToPath = do + fromPath <- peekCAString cFromPath + key <- B.packCString cKey + nonce <- B.packCString cNonce + toPath <- peekCAString cToPath + r <- chatDecryptFile fromPath key nonce toPath + newCAString r + +chatDecryptFile :: FilePath -> ByteString -> ByteString -> FilePath -> IO String +chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runExceptT decrypt + where + decrypt = do + key <- liftEither $ strDecode keyStr + nonce <- liftEither $ strDecode nonceStr + let fromFile = CryptoFile fromPath $ Just $ CFArgs key nonce + size <- liftIO $ CF.getFileContentsSize fromFile + withExceptT show $ + CF.withFile fromFile ReadMode $ \r -> withFile toPath WriteMode $ \w -> do + decryptChunks r w size + CF.hGetTag r + decryptChunks :: CryptoFileHandle -> Handle -> Integer -> ExceptT FTCryptoError IO () + decryptChunks r w !size = do + let chSize = min size chunkSize + chSize' = fromIntegral chSize + size' = size - chSize + ch <- liftIO $ CF.hGet r chSize' + when (B.length ch /= chSize') $ throwError $ FTCEFileIOError "encrypting file: unexpected EOF" + liftIO $ B.hPut w ch + when (size' > 0) $ decryptChunks r w size' + +chunkSize :: Num a => a +chunkSize = 65536 +{-# INLINE chunkSize #-} diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 604a1640e..26b096086 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -27,7 +27,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) +import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..), getFileContentsSize) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import System.FilePath (()) @@ -41,6 +41,7 @@ mobileTests = do it "should encrypt/decrypt WebRTC frames" testMediaApi it "should encrypt/decrypt WebRTC frames via C API" testMediaCApi it "should read/write encrypted files via C API" testFileCApi + it "should encrypt/decrypt files via C API" testFileEncryptionCApi noActiveUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -194,8 +195,25 @@ testFileCApi tmp = do contents <- getByteString (ptr' `plusPtr` (length r' + 1)) $ fromIntegral sz contents `shouldBe` src sz `shouldBe` len - where - jDecode :: FromJSON a => String -> IO (Maybe a) - jDecode = pure . J.decode . LB.pack - encodedCString :: StrEncoding a => a -> IO CString - encodedCString = newCAString . BS.unpack . strEncode + +testFileEncryptionCApi :: FilePath -> IO () +testFileEncryptionCApi tmp = do + src <- B.readFile "./tests/fixtures/test.pdf" + cFromPath <- newCAString "./tests/fixtures/test.pdf" + let toPath = tmp "test.encrypted.pdf" + cToPath <- newCAString toPath + r <- peekCAString =<< cChatEncryptFile cFromPath cToPath + Just (WFResult cfArgs@(CFArgs key nonce)) <- jDecode r + getFileContentsSize (CryptoFile toPath $ Just cfArgs) `shouldReturn` fromIntegral (B.length src) + cKey <- encodedCString key + cNonce <- encodedCString nonce + let toPath' = tmp "test.decrypted.pdf" + cToPath' <- newCAString toPath' + "" <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' + B.readFile toPath' `shouldReturn` src + +jDecode :: FromJSON a => String -> IO (Maybe a) +jDecode = pure . J.decode . LB.pack + +encodedCString :: StrEncoding a => a -> IO CString +encodedCString = newCAString . BS.unpack . strEncode From 47b783e727448d198371a3578c4851fd4d85a5b0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 6 Sep 2023 20:21:43 +0100 Subject: [PATCH 23/41] core: export chat_encrypt_file/chat_decrypt_file --- src/Simplex/Chat/Mobile.hs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 0f4b262b7..57113dea6 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -75,6 +75,10 @@ foreign export ccall "chat_write_file" cChatWriteFile :: CString -> Ptr Word8 -> foreign export ccall "chat_read_file" cChatReadFile :: CString -> CString -> CString -> IO (Ptr Word8) +foreign export ccall "chat_encrypt_file" cChatEncryptFile :: CString -> CString -> IO CJSONString + +foreign export ccall "chat_decrypt_file" cChatDecryptFile :: CString -> CString -> CString -> CString -> IO CString + -- | check / migrate database and initialize chat controller on success cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInit fp key conf ctrl = do From a90641c1d1c335be62391bcd892fbded92b02a6e Mon Sep 17 00:00:00 2001 From: Seth For Privacy Date: Thu, 7 Sep 2023 05:03:32 -0400 Subject: [PATCH 24/41] Fix broken RSS feed source URL (#3033) --- website/src/blogs-atom-feed.njk | 2 +- website/src/blogs-rss-feed.njk | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/blogs-atom-feed.njk b/website/src/blogs-atom-feed.njk index ea58fa2d1..849cedcf1 100644 --- a/website/src/blogs-atom-feed.njk +++ b/website/src/blogs-atom-feed.njk @@ -5,7 +5,7 @@ metadata: title: SimpleX Chat Blog subtitle: It allows you to stay up to date with the latest Blogs from SimpleX Chat. language: en - url: https://simplex.chat/, + url: https://simplex.chat/ author: name: SimpleX Chat email: chat@simplex.chat diff --git a/website/src/blogs-rss-feed.njk b/website/src/blogs-rss-feed.njk index 5163a7520..c84362eab 100644 --- a/website/src/blogs-rss-feed.njk +++ b/website/src/blogs-rss-feed.njk @@ -5,7 +5,7 @@ metadata: title: SimpleX Chat Blog subtitle: It allows you to stay up to date with the latest Blogs from SimpleX Chat. language: en - url: https://simplex.chat/, + url: https://simplex.chat/ author: name: SimpleX Chat email: chat@simplex.chat From a27f30ce12f26f130646f79251be6dad1e887a41 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 7 Sep 2023 12:59:37 +0300 Subject: [PATCH 25/41] android: changing a chat on user change (#3027) * android: changing a chat on user change * test * test2 * Revert "test2" This reverts commit 198873ecad601c0acfba8f8bf3c7aaa274cb54e8. * Revert "test" This reverts commit 6e0e3d49309171b38bebb9f55f855db7b85836c5. * style --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../kotlin/chat/simplex/common/App.kt | 17 ++++++++------- .../simplex/common/views/chat/ChatView.kt | 21 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) 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 6b9770c09..c08ad5f91 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 @@ -197,24 +197,25 @@ fun AndroidScreen(settingsState: SettingsViewState) { StartPartOfScreen(settingsState) } val scope = rememberCoroutineScope() - val onComposed: () -> Unit = { + val onComposed: suspend (chatId: String?) -> Unit = { chatId -> + // coroutine, scope and join() because: + // - it should be run from coroutine to wait until this function finishes + // - without using scope.launch it throws CancellationException when changing user + // - join allows to wait until completion scope.launch { offset.animateTo( - if (chatModel.chatId.value == null) 0f else maxWidth.value, + if (chatId == null) 0f else maxWidth.value, chatListAnimationSpec() ) - if (offset.value == 0f) { - currentChatId = null - } - } + }.join() } LaunchedEffect(Unit) { launch { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() .collect { - if (it != null) currentChatId = it - else onComposed() + if (it == null) onComposed(null) + currentChatId = 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 3370d34e7..e8afdfeb2 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 @@ -43,7 +43,7 @@ import java.net.URI import kotlin.math.sign @Composable -fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { +fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: String) -> Unit) { val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) } val searchText = rememberSaveable { mutableStateOf("") } val user = chatModel.currentUser.value @@ -66,12 +66,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { launch { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() + .filter { it != null && activeChat.value?.id != it } .collect { chatId -> - if (activeChat.value?.id != chatId && chatId != null) { - // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly - // Also for situation when chatId changes after clicking in notification, etc - activeChat.value = chatModel.getChat(chatId) - } + // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly + // Also for situation when chatId changes after clicking in notification, etc + activeChat.value = chatModel.getChat(chatId!!) markUnreadChatAsRead(activeChat, chatModel) } } @@ -91,7 +90,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { } .distinctUntilChanged() // Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions - .filter { it?.chatInfo != activeChat.value?.chatInfo && it != null } + .filter { it != null && it?.chatInfo != activeChat.value?.chatInfo } .collect { activeChat.value = it } } } @@ -422,7 +421,7 @@ fun ChatLayout( markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, - onComposed: () -> Unit, + onComposed: suspend (chatId: String) -> Unit, ) { val scope = rememberCoroutineScope() val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } } @@ -672,7 +671,7 @@ fun BoxWithConstraintsScope.ChatItemsList( showItemDetails: (ChatInfo, ChatItem) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, setFloatingButton: (@Composable () -> Unit) -> Unit, - onComposed: () -> Unit, + onComposed: suspend (chatId: String) -> Unit, ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -703,13 +702,13 @@ fun BoxWithConstraintsScope.ChatItemsList( scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) } } } - LaunchedEffect(Unit) { + LaunchedEffect(chat.id) { var stopListening = false snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex } .distinctUntilChanged() .filter { !stopListening } .collect { - onComposed() + onComposed(chat.id) stopListening = true } } From 748572ace91eb842c2941e8673d525dfcc5ae23c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Sep 2023 11:28:37 +0100 Subject: [PATCH 26/41] ui: types and stubs to encrypt local files (#3003) * ui: types and stubs to encrypt local files * ios: encrypt automatically received images in local storage * encrypt sent images, marked to be received via NSE * ios: encrypt sent and received local voice files * encrypt sent and received local files * fix NSE * remove comment * decrypt files in background thread --- apps/ios/Shared/Model/AudioRecPlay.swift | 12 ++- apps/ios/Shared/Model/ImageUtils.swift | 79 ++++++++++--------- apps/ios/Shared/Model/SimpleXAPI.swift | 24 ++---- .../Views/Chat/ChatItem/CIFileView.swift | 36 +++++++-- .../Views/Chat/ChatItem/CIImageView.swift | 6 +- .../Views/Chat/ChatItem/CIMetaView.swift | 23 +++--- .../Chat/ChatItem/CIRcvDecryptionError.swift | 4 +- .../Views/Chat/ChatItem/CIVideoView.swift | 9 ++- .../Views/Chat/ChatItem/CIVoiceView.swift | 15 ++-- .../Views/Chat/ChatItem/FramedItemView.swift | 2 +- .../Views/Chat/ChatItem/MsgContentView.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 13 ++- .../Chat/ComposeMessage/ComposeView.swift | 45 +++++++---- .../ComposeMessage/ComposeVoiceView.swift | 2 +- .../ios/Shared/Views/Helpers/ShareSheet.swift | 6 +- .../Views/UserSettings/PrivacySettings.swift | 4 + .../ios/SimpleX NSE/NotificationService.swift | 16 ++-- apps/ios/SimpleX.xcodeproj/project.pbxproj | 6 +- apps/ios/SimpleXChat/APITypes.swift | 21 ++--- apps/ios/SimpleXChat/AppGroup.swift | 4 +- apps/ios/SimpleXChat/ChatTypes.swift | 40 +++++++++- apps/ios/SimpleXChat/CryptoFile.swift | 64 +++++++++++++++ apps/ios/SimpleXChat/FileUtils.swift | 25 +++++- apps/ios/SimpleXChat/SimpleX.h | 14 ++++ .../chat/simplex/common/model/ChatModel.kt | 17 +++- .../chat/simplex/common/model/SimpleXAPI.kt | 21 ++--- .../chat/simplex/common/platform/Files.kt | 5 +- .../simplex/common/views/chat/ChatView.kt | 12 +-- .../simplex/common/views/chat/ComposeView.kt | 13 +-- .../common/views/chat/item/CIFileView.kt | 4 +- .../common/views/chat/item/CIImageView.kt | 5 +- .../common/views/chat/item/CIVIdeoView.kt | 10 +-- .../common/views/chat/item/CIVoiceView.kt | 20 ++--- .../common/views/chat/item/ChatItemView.kt | 6 +- .../common/views/chat/item/FramedItemView.kt | 2 +- .../simplex/common/views/helpers/Utils.kt | 17 ++-- 36 files changed, 407 insertions(+), 197 deletions(-) create mode 100644 apps/ios/SimpleXChat/CryptoFile.swift diff --git a/apps/ios/Shared/Model/AudioRecPlay.swift b/apps/ios/Shared/Model/AudioRecPlay.swift index 698799457..973d79ab3 100644 --- a/apps/ios/Shared/Model/AudioRecPlay.swift +++ b/apps/ios/Shared/Model/AudioRecPlay.swift @@ -103,9 +103,15 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { self.onFinishPlayback = onFinishPlayback } - func start(fileName: String, at: TimeInterval?) { - let url = getAppFilePath(fileName) - audioPlayer = try? AVAudioPlayer(contentsOf: url) + func start(fileSource: CryptoFile, at: TimeInterval?) { + let url = getAppFilePath(fileSource.filePath) + if let cfArgs = fileSource.cryptoArgs { + if let data = try? readCryptoFile(path: url.path, cryptoArgs: cfArgs) { + audioPlayer = try? AVAudioPlayer(data: data) + } + } else { + audioPlayer = try? AVAudioPlayer(contentsOf: url) + } audioPlayer?.delegate = self audioPlayer?.prepareToPlay() if let at = at { diff --git a/apps/ios/Shared/Model/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift index 4987f5a6f..90070e74d 100644 --- a/apps/ios/Shared/Model/ImageUtils.swift +++ b/apps/ios/Shared/Model/ImageUtils.swift @@ -11,42 +11,43 @@ import SimpleXChat import SwiftUI import AVKit -func getLoadedFilePath(_ file: CIFile?) -> String? { - if let fileName = getLoadedFileName(file) { - return getAppFilePath(fileName).path - } - return nil -} - -func getLoadedFileName(_ file: CIFile?) -> String? { - if let file = file, - file.loaded, - let fileName = file.filePath { - return fileName +func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? { + if let file = file, file.loaded { + return file.fileSource } return nil } func getLoadedImage(_ file: CIFile?) -> UIImage? { - let loadedFilePath = getLoadedFilePath(file) - if let loadedFilePath = loadedFilePath, let fileName = file?.filePath { - let filePath = getAppFilePath(fileName) + if let fileSource = getLoadedFileSource(file) { + let filePath = getAppFilePath(fileSource.filePath) do { - let data = try Data(contentsOf: filePath) + let data = try getFileData(filePath, fileSource.cryptoArgs) let img = UIImage(data: data) - try img?.setGifFromData(data, levelOfIntegrity: 1.0) - return img + do { + try img?.setGifFromData(data, levelOfIntegrity: 1.0) + return img + } catch { + return UIImage(data: data) + } } catch { - return UIImage(contentsOfFile: loadedFilePath) + return nil } } return nil } +func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data { + if let cfArgs = cfArgs { + return try readCryptoFile(path: path.path, cryptoArgs: cfArgs) + } else { + return try Data(contentsOf: path) + } +} + func getLoadedVideo(_ file: CIFile?) -> URL? { - let loadedFilePath = getLoadedFilePath(file) - if loadedFilePath != nil, let fileName = file?.filePath { - let filePath = getAppFilePath(fileName) + if let fileSource = getLoadedFileSource(file) { + let filePath = getAppFilePath(fileSource.filePath) if FileManager.default.fileExists(atPath: filePath.path) { return filePath } @@ -54,18 +55,18 @@ func getLoadedVideo(_ file: CIFile?) -> URL? { return nil } -func saveAnimImage(_ image: UIImage) -> String? { +func saveAnimImage(_ image: UIImage) -> CryptoFile? { let fileName = generateNewFileName("IMG", "gif") guard let imageData = image.imageData else { return nil } - return saveFile(imageData, fileName) + return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get()) } -func saveImage(_ uiImage: UIImage) -> String? { +func saveImage(_ uiImage: UIImage) -> CryptoFile? { let hasAlpha = imageHasAlpha(uiImage) let ext = hasAlpha ? "png" : "jpg" if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) { let fileName = generateNewFileName("IMG", ext) - return saveFile(imageDataResized, fileName) + return saveFile(imageDataResized, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get()) } return nil } @@ -157,13 +158,19 @@ func imageHasAlpha(_ img: UIImage) -> Bool { return false } -func saveFileFromURL(_ url: URL) -> String? { - let savedFile: String? +func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? { + let savedFile: CryptoFile? if url.startAccessingSecurityScopedResource() { do { - let fileData = try Data(contentsOf: url) let fileName = uniqueCombine(url.lastPathComponent) - savedFile = saveFile(fileData, fileName) + let toPath = getAppFilePath(fileName).path + if encrypted { + let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: toPath) + savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs) + } else { + try FileManager.default.copyItem(atPath: url.path, toPath: toPath) + savedFile = CryptoFile.plain(fileName) + } } catch { logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)") savedFile = nil @@ -176,18 +183,16 @@ func saveFileFromURL(_ url: URL) -> String? { return savedFile } -func saveFileFromURLWithoutLoad(_ url: URL) -> String? { - let savedFile: String? +func moveTempFileFromURL(_ url: URL) -> CryptoFile? { do { let fileName = uniqueCombine(url.lastPathComponent) try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName)) ChatModel.shared.filesToDelete.remove(url) - savedFile = fileName + return CryptoFile.plain(fileName) } catch { - logger.error("FileUtils.saveFileFromURLWithoutLoad error: \(error.localizedDescription)") - savedFile = nil + logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)") + return nil } - return savedFile } func generateNewFileName(_ prefix: String, _ ext: String) -> String { @@ -288,4 +293,4 @@ extension UIImage { } return self } -} \ No newline at end of file +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 59524c2c3..7a625bae6 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -315,7 +315,7 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws - throw r } -func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? { +func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? { let chatModel = ChatModel.shared let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl) let r: ChatResponse @@ -807,14 +807,14 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat)) } -func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async { - if let chatItem = await apiReceiveFile(fileId: fileId, auto: auto) { +func receiveFile(user: any UserLike, fileId: Int64, encrypted: Bool, auto: Bool = false) async { + if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: encrypted, auto: auto) { await chatItemSimpleUpdate(user, chatItem) } } -func apiReceiveFile(fileId: Int64, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? { - let r = await chatSendCmd(.receiveFile(fileId: fileId, inline: inline)) +func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? { + let r = await chatSendCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline)) let am = AlertManager.shared if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } if case .rcvFileAcceptedSndCancelled = r { @@ -1357,7 +1357,7 @@ func processReceivedMsg(_ res: ChatResponse) async { } if let file = cItem.autoReceiveFile() { Task { - await receiveFile(user: user, fileId: file.fileId, auto: true) + await receiveFile(user: user, fileId: file.fileId, encrypted: cItem.encryptLocalFile, auto: true) } } if cItem.showNotification { @@ -1660,15 +1660,3 @@ private struct UserResponse: Decodable { var user: User? var error: String? } - -struct RuntimeError: Error { - let message: String - - init(_ message: String) { - self.message = message - } - - public var localizedDescription: String { - return message - } -} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 0c43ebe41..1c32f36c9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -16,8 +16,8 @@ struct CIFileView: View { var body: some View { let metaReserve = edited - ? " " - : " " + ? " " + : " " Button(action: fileAction) { HStack(alignment: .bottom, spacing: 6) { fileIndicator() @@ -84,7 +84,8 @@ struct CIFileView: View { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") if let user = ChatModel.shared.currentUser { - await receiveFile(user: user, fileId: file.fileId) + let encrypted = file.fileProtocol == .xftp && privacyEncryptLocalFilesGroupDefault.get() + await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted) } } } else { @@ -109,9 +110,8 @@ struct CIFileView: View { } case .rcvComplete: logger.debug("CIFileView fileAction - in .rcvComplete") - if let filePath = getLoadedFilePath(file) { - let url = URL(fileURLWithPath: filePath) - showShareSheet(items: [url]) + if let fileSource = getLoadedFileSource(file) { + saveCryptoFile(fileSource) } default: break } @@ -193,6 +193,30 @@ struct CIFileView: View { } } +func saveCryptoFile(_ fileSource: CryptoFile) { + if let cfArgs = fileSource.cryptoArgs { + let url = getAppFilePath(fileSource.filePath) + let tempUrl = getTempFilesDirectory().appendingPathComponent(fileSource.filePath) + Task { + do { + try decryptCryptoFile(fromPath: url.path, cryptoArgs: cfArgs, toPath: tempUrl.path) + await MainActor.run { + showShareSheet(items: [tempUrl]) { + removeFile(tempUrl) + } + } + } catch { + await MainActor.run { + AlertManager.shared.showAlertMsg(title: "Error decrypting file", message: "Error: \(error.localizedDescription)") + } + } + } + } else { + let url = getAppFilePath(fileSource.filePath) + showShareSheet(items: [url]) + } +} + struct CIFileView_Previews: PreviewProvider { static var previews: some View { let sentFile: ChatItem = ChatItem( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index b13ee5282..bb4317957 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -16,6 +16,7 @@ struct CIImageView: View { let maxWidth: CGFloat @Binding var imgWidth: CGFloat? @State var scrollProxy: ScrollViewProxy? + @State var metaColor: Color @State private var showFullScreenImage = false var body: some View { @@ -36,9 +37,8 @@ struct CIImageView: View { case .rcvInvitation: Task { if let user = ChatModel.shared.currentUser { - await receiveFile(user: user, fileId: file.fileId) + await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile) } - // TODO image accepted alert? } case .rcvAccepted: switch file.fileProtocol { @@ -110,7 +110,7 @@ struct CIImageView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: size, height: size) - .foregroundColor(.white) + .foregroundColor(metaColor) .padding(padding) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 996afd048..30430dc19 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -21,27 +21,28 @@ struct CIMetaView: View { } else { let meta = chatItem.meta let ttl = chat.chatInfo.timedMessagesTTL + let encrypted = chatItem.encryptedFile switch meta.itemStatus { case let .sndSent(sndProgress): switch sndProgress { - case .complete: ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .sent) - case .partial: ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .sent) + case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent) + case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent) } case let .sndRcvd(_, sndProgress): switch sndProgress { case .complete: ZStack { - ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd1) - ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd2) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2) } case .partial: ZStack { - ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .rcvd1) - ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .rcvd2) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2) } } default: - ciMetaText(meta, chatTTL: ttl, color: metaColor) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor) } } } @@ -53,7 +54,7 @@ enum SentCheckmark { case rcvd2 } -func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text { +func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text { var r = Text("") if meta.itemEdited { r = r + statusIconText("pencil", color) @@ -80,7 +81,11 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparen } else if !meta.disappearing { r = r + statusIconText("circlebadge.fill", .clear) + Text(" ") } - return (r + meta.timestampText.foregroundColor(color)).font(.caption) + if let enc = encrypted { + r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ") + } + r = r + meta.timestampText.foregroundColor(color) + return r.font(.caption) } private func statusIconText(_ icon: String, _ color: Color) -> Text { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 2e0a19ead..e1a5c252e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -118,7 +118,7 @@ struct CIRcvDecryptionError: View { .foregroundColor(syncSupported ? .accentColor : .secondary) .font(.callout) + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, transparent: true) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) ) } .padding(.horizontal, 12) @@ -139,7 +139,7 @@ struct CIRcvDecryptionError: View { .foregroundColor(.red) .italic() + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, transparent: true) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) } .padding(.horizontal, 12) CIMetaView(chatItem: chatItem) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 6de2e44b7..3807a11b4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -59,7 +59,7 @@ struct CIVideoView: View { if let file = file { switch file.fileStatus { case .rcvInvitation: - receiveFileIfValidSize(file: file, receiveFile: receiveFile) + receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile) case .rcvAccepted: switch file.fileProtocol { case .xftp: @@ -85,7 +85,7 @@ struct CIVideoView: View { } if let file = file, case .rcvInvitation = file.fileStatus { Button { - receiveFileIfValidSize(file: file, receiveFile: receiveFile) + receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile) } label: { playPauseIcon("play.fill") } @@ -253,10 +253,11 @@ struct CIVideoView: View { .padding([.trailing, .top], 11) } - private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool) async -> Void) { + // TODO encrypt: where file size is checked? + private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { Task { if let user = ChatModel.shared.currentUser { - await receiveFile(user, file.fileId, false) + await receiveFile(user, file.fileId, encrypted, false) } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 167823934..b0875abe8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -159,7 +159,8 @@ struct VoiceMessagePlayer: View { } } .onChange(of: chatModel.stopPreviousRecPlay) { it in - if let recordingFileName = getLoadedFileName(recordingFile), chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) { + if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath, + chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) { audioPlayer?.stop() playbackState = .noPlayback playbackTime = TimeInterval(0) @@ -174,8 +175,8 @@ struct VoiceMessagePlayer: View { switch playbackState { case .noPlayback: Button { - if let recordingFileName = getLoadedFileName(recordingFile) { - startPlayback(recordingFileName) + if let recordingSource = getLoadedFileSource(recordingFile) { + startPlayback(recordingSource) } } label: { playPauseIcon("play.fill") @@ -219,7 +220,7 @@ struct VoiceMessagePlayer: View { Button { Task { if let user = ChatModel.shared.currentUser { - await receiveFile(user: user, fileId: recordingFile.fileId) + await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get()) } } } label: { @@ -251,8 +252,8 @@ struct VoiceMessagePlayer: View { .clipShape(Circle()) } - private func startPlayback(_ recordingFileName: String) { - chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName) + private func startPlayback(_ recordingSource: CryptoFile) { + chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath) audioPlayer = AudioPlayer( onTimer: { playbackTime = $0 }, onFinishPlayback: { @@ -260,7 +261,7 @@ struct VoiceMessagePlayer: View { playbackTime = TimeInterval(0) } ) - audioPlayer?.start(fileName: recordingFileName, at: playbackTime) + audioPlayer?.start(fileSource: recordingSource, at: playbackTime) playbackState = .playing } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index ceaf175f9..aab0cd5f5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -97,7 +97,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, image): - CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy) + CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy, metaColor: metaColor) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 3ac908bb7..498b3cb2e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -80,7 +80,7 @@ struct MsgContentView: View { } private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, transparent: true) + (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 4f1b4fe72..2a0cd4f2c 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -601,15 +601,15 @@ struct ChatView: View { } menu.append(shareUIAction()) menu.append(copyUIAction()) - if let filePath = getLoadedFilePath(ci.file) { + if let fileSource = getLoadedFileSource(ci.file) { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if image.imageData != nil { - menu.append(saveFileAction(filePath)) + menu.append(saveFileAction(fileSource)) } else { menu.append(saveImageAction(image)) } } else { - menu.append(saveFileAction(filePath)) + menu.append(saveFileAction(fileSource)) } } if ci.meta.editable && !mc.isVoice && !live { @@ -747,13 +747,12 @@ struct ChatView: View { } } - private func saveFileAction(_ filePath: String) -> UIAction { + private func saveFileAction(_ fileSource: CryptoFile) -> UIAction { UIAction( title: NSLocalizedString("Save", comment: "chat item action"), - image: UIImage(systemName: "square.and.arrow.down") + image: UIImage(systemName: fileSource.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open") ) { _ in - let fileURL = URL(fileURLWithPath: filePath) - showShareSheet(items: [fileURL]) + saveCryptoFile(fileSource) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 674f31bf7..c999c9dca 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -167,25 +167,23 @@ struct ComposeState { } func chatItemPreview(chatItem: ChatItem) -> ComposePreview { - let chatItemPreview: ComposePreview switch chatItem.content.msgContent { case .text: - chatItemPreview = .noPreview + return .noPreview case let .link(_, preview: preview): - chatItemPreview = .linkPreview(linkPreview: preview) + return .linkPreview(linkPreview: preview) case let .image(_, image): - chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)]) + return .mediaPreviews(mediaPreviews: [(image, nil)]) case let .video(_, image, _): - chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)]) + return .mediaPreviews(mediaPreviews: [(image, nil)]) case let .voice(_, duration): - chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration) + return .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration) case .file: let fileName = chatItem.file?.fileName ?? "" - chatItemPreview = .filePreview(fileName: fileName, file: getAppFilePath(fileName)) + return .filePreview(fileName: fileName, file: getAppFilePath(fileName)) default: - chatItemPreview = .noPreview + return .noPreview } - return chatItemPreview } enum UploadContent: Equatable { @@ -656,10 +654,10 @@ struct ComposeView: View { } case let .voicePreview(recordingFileName, duration): stopPlayback.toggle() - chatModel.filesToDelete.remove(getAppFilePath(recordingFileName)) - sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName, ttl: ttl) + let file = voiceCryptoFile(recordingFileName) + sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl) case let .filePreview(_, file): - if let savedFile = saveFileFromURL(file) { + if let savedFile = saveFileFromURL(file, encrypted: privacyEncryptLocalFilesGroupDefault.get()) { sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl) } } @@ -727,13 +725,28 @@ struct ComposeView: View { func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { let (image, data) = imageData - if case let .video(_, url, duration) = data, let savedFile = saveFileFromURLWithoutLoad(url) { + if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) { return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl) } return nil } - func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { + func voiceCryptoFile(_ fileName: String) -> CryptoFile? { + if !privacyEncryptLocalFilesGroupDefault.get() { + return CryptoFile.plain(fileName) + } + let url = getAppFilePath(fileName) + let toFile = generateNewFileName("voice", "m4a") + let toUrl = getAppFilePath(toFile) + if let cfArgs = try? encryptCryptoFile(fromPath: url.path, toPath: toUrl.path) { + removeFile(url) + return CryptoFile(filePath: toFile, cryptoArgs: cfArgs) + } else { + return nil + } + } + + func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { if let chatItem = await apiSendMessage( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, @@ -750,7 +763,7 @@ struct ComposeView: View { return chatItem } if let file = file { - removeFile(file) + removeFile(file.filePath) } return nil } @@ -770,7 +783,7 @@ struct ComposeView: View { } } - func saveAnyImage(_ img: UploadContent) -> String? { + func saveAnyImage(_ img: UploadContent) -> CryptoFile? { switch img { case let .simpleImage(image): return saveImage(image) case let .animatedImage(image): return saveAnimImage(image) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift index 2bd23f8ae..2617bc77b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift @@ -188,7 +188,7 @@ struct ComposeVoiceView: View { playbackTime = recordingTime // animate progress bar to the end } ) - audioPlayer?.start(fileName: recordingFileName, at: playbackTime) + audioPlayer?.start(fileSource: CryptoFile.plain(recordingFileName), at: playbackTime) playbackState = .playing } } diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 15883f834..936c6cb3a 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -8,11 +8,15 @@ import SwiftUI -func showShareSheet(items: [Any]) { +func showShareSheet(items: [Any], completed: (() -> Void)? = nil) { let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first, let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController { let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) + if let completed = completed { + let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() } + activityViewController.completionWithItemsHandler = handler + } presentedViewController.present(activityViewController, animated: true) } } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 4b583caba..34b6f147b 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -15,6 +15,7 @@ struct PrivacySettings: View { @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true + @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true @State private var simplexLinkMode = privacySimplexLinkModeDefault.get() @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -63,6 +64,9 @@ struct PrivacySettings: View { } Section { + settingsRow("lock.doc") { + Toggle("Encrypt local files", isOn: $encryptLocalFiles) + } settingsRow("photo") { Toggle("Auto-accept images", isOn: $autoAcceptImages) .onChange(of: autoAcceptImages) { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index dc0af3698..f0a5c2a06 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -271,7 +271,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1)) } if let file = cItem.autoReceiveFile() { - cItem = autoReceiveFile(file) ?? cItem + cItem = autoReceiveFile(file, encrypted: cItem.encryptLocalFile) ?? cItem } let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(notification: createMessageReceivedNtf(user, cInfo, cItem)) : .empty return cItem.showNotification ? (aChatItem.chatId, ntf) : nil @@ -367,25 +367,25 @@ func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { return nil } -func apiReceiveFile(fileId: Int64, inline: Bool? = nil) -> AChatItem? { - let r = sendSimpleXCmd(.receiveFile(fileId: fileId, inline: inline)) +func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> AChatItem? { + let r = sendSimpleXCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline)) if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } logger.error("receiveFile error: \(responseError(r))") return nil } -func apiSetFileToReceive(fileId: Int64) { - let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId)) +func apiSetFileToReceive(fileId: Int64, encrypted: Bool) { + let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId, encrypted: encrypted)) if case .cmdOk = r { return } logger.error("setFileToReceive error: \(responseError(r))") } -func autoReceiveFile(_ file: CIFile) -> ChatItem? { +func autoReceiveFile(_ file: CIFile, encrypted: Bool) -> ChatItem? { switch file.fileProtocol { case .smp: - return apiReceiveFile(fileId: file.fileId)?.chatItem + return apiReceiveFile(fileId: file.fileId, encrypted: false)?.chatItem case .xftp: - apiSetFileToReceive(fileId: file.fileId) + apiSetFileToReceive(fileId: file.fileId, encrypted: encrypted) return nil } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8b814e3c0..fc301a3ab 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -77,6 +77,7 @@ 5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */; }; 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */; }; 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; }; + 5C9D811A2AA8727A001D49FD /* CryptoFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */; }; 5C9F83F42A9A7D98009AD0AA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83EF2A9A7D98009AD0AA /* libffi.a */; }; 5C9F83F52A9A7D98009AD0AA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83F02A9A7D98009AD0AA /* libgmp.a */; }; 5C9F83F62A9A7D98009AD0AA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83F12A9A7D98009AD0AA /* libgmpxx.a */; }; @@ -331,6 +332,7 @@ 5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseErrorView.swift; sourceTree = ""; }; 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseEncryptionView.swift; sourceTree = ""; }; 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = ""; }; + 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoFile.swift; sourceTree = ""; }; 5C9F83EF2A9A7D98009AD0AA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C9F83F02A9A7D98009AD0AA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C9F83F12A9A7D98009AD0AA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; @@ -723,10 +725,10 @@ 5CADE79929211BB900072E13 /* PreferencesView.swift */, 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */, 5C05DF522840AA1D00C683F9 /* CallSettings.swift */, - 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, 5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, + 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */, 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */, 5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */, @@ -779,6 +781,7 @@ 5CDCAD7D2818941F00503DA2 /* API.swift */, 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, 64DAE1502809D9F5000DA960 /* FileUtils.swift */, + 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */, 5C00168028C4FE760094D739 /* KeyChain.swift */, 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, @@ -1236,6 +1239,7 @@ 5CE2BA90284533A300EC33A6 /* JSON.swift in Sources */, 5CE2BA8B284533A300EC33A6 /* ChatTypes.swift in Sources */, 5CE2BA8F284533A300EC33A6 /* APITypes.swift in Sources */, + 5C9D811A2AA8727A001D49FD /* CryptoFile.swift in Sources */, 5CE2BA8C284533A300EC33A6 /* AppGroup.swift in Sources */, 5CE2BA8D284533A300EC33A6 /* CallTypes.swift in Sources */, 5CE2BA8E284533A300EC33A6 /* API.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d80626d6f..585b4f29e 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI -let jsonDecoder = getJSONDecoder() +public let jsonDecoder = getJSONDecoder() let jsonEncoder = getJSONEncoder() public enum ChatCommand { @@ -39,7 +39,7 @@ public enum ChatCommand { case apiGetChats(userId: Int64) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) - case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool, ttl: Int?) + case apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool, ttl: Int?) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) @@ -110,8 +110,8 @@ public enum ChatCommand { case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) - case receiveFile(fileId: Int64, inline: Bool?) - case setFileToReceive(fileId: Int64) + case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) + case setFileToReceive(fileId: Int64, encrypted: Bool) case cancelFile(fileId: Int64) case showVersion case string(String) @@ -157,7 +157,7 @@ public enum ChatCommand { (search == "" ? "" : " search=\(search)") case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)" case let .apiSendMessage(type, id, file, quotedItemId, mc, live, ttl): - let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)) + let msg = encodeJSON(ComposedMessage(fileSource: file, quotedItemId: quotedItemId, msgContent: mc)) let ttlStr = ttl != nil ? "\(ttl!)" : "default" return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msg)" case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" @@ -239,12 +239,13 @@ public enum ChatCommand { case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" - case let .receiveFile(fileId, inline): + case let .receiveFile(fileId, encrypted, inline): + let s = "/freceive \(fileId) encrypt=\(onOff(encrypted))" if let inline = inline { - return "/freceive \(fileId) inline=\(onOff(inline))" + return s + " inline=\(onOff(inline))" } - return "/freceive \(fileId)" - case let .setFileToReceive(fileId): return "/_set_file_to_receive \(fileId)" + return s + case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))" case let .cancelFile(fileId): return "/fcancel \(fileId)" case .showVersion: return "/version" case let .string(str): return str @@ -853,7 +854,7 @@ public enum ChatPagination { } struct ComposedMessage: Encodable { - var filePath: String? + var fileSource: CryptoFile? var quotedItemId: Int64? var msgContent: MsgContent } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 335ba0618..e09b95717 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -17,6 +17,7 @@ public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used +public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles" let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount" let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode" @@ -59,6 +60,7 @@ public func registerGroupDefaults() { GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false, GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true, GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false, + GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true, GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false, GROUP_DEFAULT_CALL_KIT_ENABLED: true, ]) @@ -113,7 +115,7 @@ public let ntfEnablePeriodicGroupDefault = BoolDefault(defaults: groupDefaults, public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) -public let privacyTransferImagesInlineGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE) +public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES) public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 48996abb7..ce8bd426c 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2112,6 +2112,17 @@ public struct ChatItem: Identifiable, Decodable { return nil } + public var encryptedFile: Bool? { + guard let fileSource = file?.fileSource else { return nil } + return fileSource.cryptoArgs != nil + } + + public var encryptLocalFile: Bool { + file?.fileProtocol == .xftp && + content.msgContent?.isVideo == false && + privacyEncryptLocalFilesGroupDefault.get() + } + public var memberDisplayName: String? { get { if case let .groupRcv(groupMember) = chatDir { @@ -2690,12 +2701,18 @@ public struct CIFile: Decodable { public var fileId: Int64 public var fileName: String public var fileSize: Int64 - public var filePath: String? + public var fileSource: CryptoFile? public var fileStatus: CIFileStatus public var fileProtocol: FileProtocol public static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile { - CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus, fileProtocol: .xftp) + let f: CryptoFile? + if let filePath = filePath { + f = CryptoFile.plain(filePath) + } else { + f = nil + } + return CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, fileSource: f, fileStatus: fileStatus, fileProtocol: .xftp) } public var loaded: Bool { @@ -2742,6 +2759,25 @@ public struct CIFile: Decodable { } } +public struct CryptoFile: Codable { + public var filePath: String // the name of the file, not a full path + public var cryptoArgs: CryptoFileArgs? + + public init(filePath: String, cryptoArgs: CryptoFileArgs?) { + self.filePath = filePath + self.cryptoArgs = cryptoArgs + } + + public static func plain(_ f: String) -> CryptoFile { + CryptoFile(filePath: f, cryptoArgs: nil) + } +} + +public struct CryptoFileArgs: Codable { + public var fileKey: String + public var fileNonce: String +} + public struct CancelAction { public var uiAction: String public var alert: AlertInfo diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift new file mode 100644 index 000000000..d641464ee --- /dev/null +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -0,0 +1,64 @@ +// +// CryptoFile.swift +// SimpleX (iOS) +// +// Created by Evgeny on 05/09/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import Foundation + +enum WriteFileResult: Decodable { + case result(cryptoArgs: CryptoFileArgs) + case error(writeError: String) +} + +public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { + let ptr: UnsafeMutableRawPointer = malloc(data.count) + memcpy(ptr, (data as NSData).bytes, data.count) + var cPath = path.cString(using: .utf8)! + let cjson = chat_write_file(&cPath, ptr, Int32(data.count))! + let d = fromCString(cjson).data(using: .utf8)! + switch try jsonDecoder.decode(WriteFileResult.self, from: d) { + case let .result(cfArgs): return cfArgs + case let .error(err): throw RuntimeError(err) + } +} + +enum ReadFileResult: Decodable { + case result(fileSize: Int) + case error(readError: String) +} + +public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> Data { + var cPath = path.cString(using: .utf8)! + var cKey = cryptoArgs.fileKey.cString(using: .utf8)! + var cNonce = cryptoArgs.fileNonce.cString(using: .utf8)! + let r = chat_read_file(&cPath, &cKey, &cNonce)! + let d = String.init(cString: r).data(using: .utf8)! + switch try jsonDecoder.decode(ReadFileResult.self, from: d) { + case let .error(err): throw RuntimeError(err) + case let .result(size): return Data(bytes: r.advanced(by: d.count + 1), count: size) + } +} + +public func encryptCryptoFile(fromPath: String, toPath: String) throws -> CryptoFileArgs { + var cFromPath = fromPath.cString(using: .utf8)! + var cToPath = toPath.cString(using: .utf8)! + let cjson = chat_encrypt_file(&cFromPath, &cToPath)! + let d = fromCString(cjson).data(using: .utf8)! + switch try jsonDecoder.decode(WriteFileResult.self, from: d) { + case let .result(cfArgs): return cfArgs + case let .error(err): throw RuntimeError(err) + } +} + +public func decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) throws { + var cFromPath = fromPath.cString(using: .utf8)! + var cKey = cryptoArgs.fileKey.cString(using: .utf8)! + var cNonce = cryptoArgs.fileNonce.cString(using: .utf8)! + var cToPath = toPath.cString(using: .utf8)! + let cErr = chat_decrypt_file(&cFromPath, &cKey, &cNonce, &cToPath)! + let err = fromCString(cErr) + if err != "" { throw RuntimeError(err) } +} diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 148ab12e2..60d281f14 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -173,11 +173,16 @@ public func getAppFilePath(_ fileName: String) -> URL { getAppFilesDirectory().appendingPathComponent(fileName) } -public func saveFile(_ data: Data, _ fileName: String) -> String? { +public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> CryptoFile? { let filePath = getAppFilePath(fileName) do { - try data.write(to: filePath) - return fileName + if encrypted { + let cfArgs = try writeCryptoFile(path: filePath.path, data: data) + return CryptoFile(filePath: fileName, cryptoArgs: cfArgs) + } else { + try data.write(to: filePath) + return CryptoFile.plain(fileName) + } } catch { logger.error("FileUtils.saveFile error: \(error.localizedDescription)") return nil @@ -210,7 +215,7 @@ public func cleanupFile(_ aChatItem: AChatItem) { let cItem = aChatItem.chatItem let mc = cItem.content.msgContent if case .file = mc, - let fileName = cItem.file?.filePath { + let fileName = cItem.file?.fileSource?.filePath { removeFile(fileName) } } @@ -221,3 +226,15 @@ public func getMaxFileSize(_ fileProtocol: FileProtocol) -> Int64 { case .smp: return MAX_FILE_SIZE_SMP } } + +public struct RuntimeError: Error { + let message: String + + public init(_ message: String) { + self.message = message + } + + public var localizedDescription: String { + return message + } +} diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 199c688f2..55b44dee3 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -25,3 +25,17 @@ extern char *chat_parse_server(char *str); extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_encrypt_media(char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); + +// chat_write_file returns NUL-terminated string with JSON of WriteFileResult +extern char *chat_write_file(char *path, char *data, int len); + +// chat_read_file returns a buffer with: +// 1. NUL-terminated C string with JSON of ReadFileResult, followed by +// 2. file data, the length is defined in ReadFileResult +extern char *chat_read_file(char *path, char *key, char *nonce); + +// chat_encrypt_file returns NUL-terminated string with JSON of WriteFileResult +extern char *chat_encrypt_file(char *fromPath, char *toPath); + +// chat_decrypt_file returns NUL-terminated string with the error message +extern char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); 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 0eb35fccd..a0120eb96 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 @@ -2024,7 +2024,7 @@ class CIFile( val fileId: Long, val fileName: String, val fileSize: Long, - val filePath: String? = null, + val fileSource: CryptoFile? = null, val fileStatus: CIFileStatus, val fileProtocol: FileProtocol ) { @@ -2072,10 +2072,23 @@ class CIFile( filePath: String? = "test.txt", fileStatus: CIFileStatus = CIFileStatus.RcvComplete ): CIFile = - CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP) + CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP) } } +@Serializable +class CryptoFile( + val filePath: String, + val cryptoArgs: CryptoFileArgs? +) { + companion object { + fun plain(f: String): CryptoFile = CryptoFile(f, null) + } +} + +@Serializable +class CryptoFileArgs(val fileKey: String, val fileNonce: String) + class CancelAction( val uiActionId: StringResource, val alert: AlertInfo 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 c5b11ef6d..612c167bf 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 @@ -586,7 +586,7 @@ object ChatController { return null } - suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { + suspend fun apiSendMessage(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) return when (r) { @@ -1079,8 +1079,8 @@ object ChatController { return false } - suspend fun apiReceiveFile(fileId: Long, inline: Boolean? = null, auto: Boolean = false): AChatItem? { - val r = sendCmd(CC.ReceiveFile(fileId, inline)) + suspend fun apiReceiveFile(fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { + val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline)) return when (r) { is CR.RcvFileAccepted -> r.chatItem is CR.RcvFileAcceptedSndCancelled -> { @@ -1413,7 +1413,8 @@ 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, auto = true) } + // TODO encrypt images and voice + withApi { receiveFile(r.user, file.fileId, encrypted = false, auto = true) } } if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) @@ -1647,8 +1648,8 @@ object ChatController { } } - suspend fun receiveFile(user: UserLike, fileId: Long, auto: Boolean = false) { - val chatItem = apiReceiveFile(fileId, auto = auto) + suspend fun receiveFile(user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { + val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto) if (chatItem != null) { chatItemSimpleUpdate(user, chatItem) } @@ -1804,7 +1805,7 @@ sealed class CC { class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() - class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC() + class ApiSendMessage(val type: ChatType, val id: Long, val file: CryptoFile?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() @@ -1867,7 +1868,7 @@ sealed class CC { class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() - class ReceiveFile(val fileId: Long, val inline: Boolean?): CC() + class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() class ShowVersion(): CC() @@ -1972,7 +1973,7 @@ sealed class CC { is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" - is ReceiveFile -> if (inline == null) "/freceive $fileId" else "/freceive $fileId inline=${onOff(inline)}" + is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" is ShowVersion -> "/version" } @@ -2134,7 +2135,7 @@ sealed class ChatPagination { } @Serializable -class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent) +class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) @Serializable class XFTPFileConfig(val minFileSize: Long) 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 9c702df54..53b0f8bd9 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 @@ -62,8 +62,9 @@ fun getAppFilePath(fileName: String): String { } fun getLoadedFilePath(file: CIFile?): String? { - return if (file?.filePath != null && file.loaded) { - val filePath = getAppFilePath(file.filePath) + val f = file?.fileSource?.filePath + return if (f != null && file.loaded) { + val filePath = getAppFilePath(f) if (File(filePath).exists()) filePath else null } else { null 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 e8afdfeb2..f6e328afd 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 @@ -244,8 +244,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, - receiveFile = { fileId -> - withApi { chatModel.controller.receiveFile(user, fileId) } + receiveFile = { fileId, encrypted -> + withApi { chatModel.controller.receiveFile(user, fileId, encrypted) } }, cancelFile = { fileId -> withApi { chatModel.controller.cancelFile(user, fileId) } @@ -403,7 +403,7 @@ fun ChatLayout( showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, @@ -656,7 +656,7 @@ fun BoxWithConstraintsScope.ChatItemsList( showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, @@ -1257,7 +1257,7 @@ fun PreviewChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, - receiveFile = {}, + receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = {}, startCall = {}, @@ -1324,7 +1324,7 @@ fun PreviewGroupChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, - receiveFile = {}, + receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = {}, startCall = {}, 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 6c66ee2b9..01090705d 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 @@ -317,7 +317,7 @@ fun ComposeView( chatModel.filesToDelete.clear() } - suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { val aChatItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, @@ -331,7 +331,7 @@ fun ComposeView( chatModel.addChatItem(cInfo, aChatItem.chatItem) return aChatItem.chatItem } - if (file != null) removeFile(file) + if (file != null) removeFile(file.filePath) return null } @@ -404,7 +404,7 @@ fun ComposeView( sent = updateMessage(liveMessage.chatItem, cInfo, live) } else { val msgs: ArrayList = ArrayList() - val files: ArrayList = ArrayList() + val files: ArrayList = ArrayList() when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) @@ -413,7 +413,7 @@ fun ComposeView( val file = when (it) { is UploadContent.SimpleImage -> saveImage(it.uri) is UploadContent.AnimatedImage -> saveAnimImage(it.uri) - is UploadContent.Video -> saveFileFromUri(it.uri) + is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false) } if (file != null) { files.add(file) @@ -432,12 +432,13 @@ fun ComposeView( withContext(Dispatchers.IO) { Files.move(tmpFile.toPath(), actualFile.toPath()) } - files.add(actualFile.name) + // TODO encrypt voice files + files.add(CryptoFile.plain(actualFile.name)) deleteUnusedFiles() msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000)) } is ComposePreview.FilePreview -> { - val file = saveFileFromUri(preview.uri) + val file = saveFileFromUri(preview.uri, encrypted = false) if (file != null) { files.add((file)) msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText 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 773533ca7..4642600fc 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 @@ -28,7 +28,7 @@ import java.net.URI fun CIFileView( file: CIFile?, edited: Boolean, - receiveFile: (Long) -> Unit + receiveFile: (Long, Boolean) -> Unit ) { val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) @@ -71,7 +71,7 @@ fun CIFileView( when (file.fileStatus) { is CIFileStatus.RcvInvitation -> { if (fileSizeValid()) { - receiveFile(file.fileId) + receiveFile(file.fileId, false) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), 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 73fc3f41a..75d6a9c30 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 @@ -31,7 +31,7 @@ fun CIImageView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, - receiveFile: (Long) -> Unit + receiveFile: (Long, Boolean) -> Unit ) { @Composable fun progressIndicator() { @@ -152,7 +152,8 @@ fun CIImageView( when (file.fileStatus) { CIFileStatus.RcvInvitation -> if (fileSizeValid()) { - receiveFile(file.fileId) + // TODO encrypt image + receiveFile(file.fileId, false) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), 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 5d2d581b1..aad1e8a8f 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 @@ -31,7 +31,7 @@ fun CIVideoView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, - receiveFile: (Long) -> Unit + receiveFile: (Long, Boolean) -> Unit ) { Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), @@ -54,7 +54,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, encrypted = false, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -80,7 +80,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (file?.fileStatus is CIFileStatus.RcvInvitation) { - PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, encrypted = false, receiveFile) } } } } @@ -301,9 +301,9 @@ private fun fileSizeValid(file: CIFile?): Boolean { return false } -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { +private fun receiveFileIfValidSize(file: CIFile, encrypted: Boolean, receiveFile: (Long, Boolean) -> Unit) { if (fileSizeValid(file)) { - receiveFile(file.fileId) + receiveFile(file.fileId, encrypted) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), 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 937422589..6ec39bb4f 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 @@ -37,18 +37,19 @@ fun CIVoiceView( ci: ChatItem, timedMessagesTTL: Int?, longClick: () -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, ) { Row( Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp), verticalAlignment = Alignment.CenterVertically ) { if (file != null) { - val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(file) } - var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) } - val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) } - val progress = rememberSaveable(file.filePath) { mutableStateOf(0) } - val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) } + val f = file.fileSource?.filePath + val filePath = remember(f, file.fileStatus) { getLoadedFilePath(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 = { AudioPlayer.play(filePath, audioPlaying, progress, duration, true) brokenAudio = !audioPlaying.value @@ -94,7 +95,7 @@ private fun VoiceLayout( play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, onProgressChanged: (Int) -> Unit, ) { @Composable @@ -248,7 +249,7 @@ private fun VoiceMsgIndicator( play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, ) { val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() } val strokeColor = MaterialTheme.colors.primary @@ -268,7 +269,8 @@ private fun VoiceMsgIndicator( } } else { if (file?.fileStatus is CIFileStatus.RcvInvitation) { - PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick) + // TODO encrypt voice + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, false) }, {}, longClick = longClick) } else if (file?.fileStatus is CIFileStatus.RcvTransfer || file?.fileStatus is CIFileStatus.RcvAccepted ) { 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 2857d6acc..cc2d97e3f 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 @@ -48,7 +48,7 @@ fun ChatItemView( useLinkPreviews: Boolean, linkMode: SimplexLinkMode, deleteMessage: (Long, CIDeleteMode) -> Unit, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, @@ -566,7 +566,7 @@ fun PreviewChatItemView() { linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, deleteMessage = { _, _ -> }, - receiveFile = {}, + receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = {}, acceptCall = { _ -> }, @@ -595,7 +595,7 @@ fun PreviewChatItemViewDeletedContent() { linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, deleteMessage = { _, _ -> }, - receiveFile = {}, + receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = {}, acceptCall = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 9a2827280..92cf62a85 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -36,7 +36,7 @@ fun FramedItemView( imageProvider: (() -> ImageGalleryProvider)? = null, linkMode: SimplexLinkMode, showMenu: MutableState, - receiveFile: (Long) -> Unit, + receiveFile: (Long, Boolean) -> Unit, onLinkLongClick: (link: String) -> Unit = {}, scrollToItem: (Long) -> Unit = {}, ) { 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 e4670fef1..b9eeee12b 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 @@ -95,12 +95,13 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri return null } -fun saveImage(uri: URI): String? { +fun saveImage(uri: URI): CryptoFile? { val bitmap = getBitmapFromUri(uri) ?: return null return saveImage(bitmap) } -fun saveImage(image: ImageBitmap): String? { +fun saveImage(image: ImageBitmap): CryptoFile? { + // TODO encrypt image return try { val ext = if (image.hasAlpha()) "png" else "jpg" val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) @@ -110,14 +111,15 @@ fun saveImage(image: ImageBitmap): String? { dataResized.writeTo(output) output.flush() output.close() - fileToSave + CryptoFile.plain(fileToSave) } catch (e: Exception) { Log.e(TAG, "Util.kt saveImage error: ${e.stackTraceToString()}") null } } -fun saveAnimImage(uri: URI): String? { +fun saveAnimImage(uri: URI): CryptoFile? { + // TODO encrypt image return try { val filename = getFileName(uri)?.lowercase() var ext = when { @@ -135,7 +137,7 @@ fun saveAnimImage(uri: URI): String? { input?.copyTo(output) } } - fileToSave + CryptoFile.plain(fileToSave) } catch (e: Exception) { Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}") null @@ -144,15 +146,16 @@ fun saveAnimImage(uri: URI): String? { expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? -fun saveFileFromUri(uri: URI): String? { +fun saveFileFromUri(uri: URI, encrypted: Boolean): CryptoFile? { return try { val inputStream = uri.inputStream() val fileToSave = getFileName(uri) + // TODO encrypt file if "encrypted" is true if (inputStream != null && fileToSave != null) { val destFileName = uniqueCombine(fileToSave) val destFile = File(getAppFilePath(destFileName)) Files.copy(inputStream, destFile.toPath()) - destFileName + CryptoFile.plain(destFileName) } else { Log.e(TAG, "Util.kt saveFileFromUri null inputStream") null From 7cd4a417e7d39c10292bce81cde0640bac24b40a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Sep 2023 11:51:17 +0100 Subject: [PATCH 27/41] ios: fix type that was preventing sent item status update --- apps/ios/SimpleXChat/APITypes.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 585b4f29e..ad641810c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -482,7 +482,7 @@ public enum ChatResponse: Decodable, Error { case groupEmpty(user: UserRef, groupInfo: GroupInfo) case userContactLinkSubscribed case newChatItem(user: UserRef, chatItem: AChatItem) - case chatItemStatusUpdated(UserRef: User, chatItem: AChatItem) + case chatItemStatusUpdated(user: UserRef, chatItem: AChatItem) case chatItemUpdated(user: UserRef, chatItem: AChatItem) case chatItemNotChanged(user: UserRef, chatItem: AChatItem) case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction) From b5a0269aa201c60fcc7fdfd9e0d85d45a7e2300f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Sep 2023 13:44:37 +0100 Subject: [PATCH 28/41] core: support unicode filenames and catch IO exceptions in C API for local file encryption (#3035) * core: support unicode filenames in C API * catch IO exceptions and return as errors --- src/Simplex/Chat/Mobile/File.hs | 28 ++++++----- tests/MobileTests.hs | 86 ++++++++++++++++++++++++++------- 2 files changed, 84 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 1c9219cab..a0fb3eb5b 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -34,6 +34,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..), Cryp import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Util (catchAll) import UnliftIO (Handle, IOMode (..), withFile) data WriteFileResult @@ -45,7 +46,7 @@ instance ToJSON WriteFileResult where toEncoding = J.genericToEncoding . sumType cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString cChatWriteFile cPath ptr len = do - path <- peekCAString cPath + path <- peekCString cPath s <- getByteString ptr len r <- chatWriteFile path s newCAString $ LB'.unpack $ J.encode r @@ -54,8 +55,8 @@ chatWriteFile :: FilePath -> ByteString -> IO WriteFileResult chatWriteFile path s = do cfArgs <- CF.randomArgs let file = CryptoFile path $ Just cfArgs - either (WFError . show) (\_ -> WFResult cfArgs) - <$> runExceptT (CF.writeFile file $ LB.fromStrict s) + either WFError (\_ -> WFResult cfArgs) + <$> runCatchExceptT (withExceptT show $ CF.writeFile file $ LB.fromStrict s) data ReadFileResult = RFResult {fileSize :: Int} @@ -66,7 +67,7 @@ instance ToJSON ReadFileResult where toEncoding = J.genericToEncoding . sumTypeJ cChatReadFile :: CString -> CString -> CString -> IO (Ptr Word8) cChatReadFile cPath cKey cNonce = do - path <- peekCAString cPath + path <- peekCString cPath key <- B.packCString cKey nonce <- B.packCString cNonce (r, s) <- chatReadFile path key nonce @@ -78,7 +79,7 @@ cChatReadFile cPath cKey cNonce = do chatReadFile :: FilePath -> ByteString -> ByteString -> IO (ReadFileResult, ByteString) chatReadFile path keyStr nonceStr = do - either ((,"") . RFError) result <$> runExceptT readFile_ + either ((,"") . RFError) result <$> runCatchExceptT readFile_ where result s = let s' = LB.toStrict s in (RFResult $ B.length s', s') readFile_ :: ExceptT String IO LB.ByteString @@ -90,14 +91,14 @@ chatReadFile path keyStr nonceStr = do cChatEncryptFile :: CString -> CString -> IO CJSONString cChatEncryptFile cFromPath cToPath = do - fromPath <- peekCAString cFromPath - toPath <- peekCAString cToPath + fromPath <- peekCString cFromPath + toPath <- peekCString cToPath r <- chatEncryptFile fromPath toPath newCAString . LB'.unpack $ J.encode r chatEncryptFile :: FilePath -> FilePath -> IO WriteFileResult chatEncryptFile fromPath toPath = - either WFError WFResult <$> runExceptT encrypt + either WFError WFResult <$> runCatchExceptT encrypt where encrypt = do cfArgs <- liftIO $ CF.randomArgs @@ -114,15 +115,15 @@ chatEncryptFile fromPath toPath = cChatDecryptFile :: CString -> CString -> CString -> CString -> IO CString cChatDecryptFile cFromPath cKey cNonce cToPath = do - fromPath <- peekCAString cFromPath + fromPath <- peekCString cFromPath key <- B.packCString cKey nonce <- B.packCString cNonce - toPath <- peekCAString cToPath + toPath <- peekCString cToPath r <- chatDecryptFile fromPath key nonce toPath newCAString r - + chatDecryptFile :: FilePath -> ByteString -> ByteString -> FilePath -> IO String -chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runExceptT decrypt +chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runCatchExceptT decrypt where decrypt = do key <- liftEither $ strDecode keyStr @@ -143,6 +144,9 @@ chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runExceptT dec liftIO $ B.hPut w ch when (size' > 0) $ decryptChunks r w size' +runCatchExceptT :: ExceptT String IO a -> IO (Either String a) +runCatchExceptT action = runExceptT action `catchAll` (pure . Left . show) + chunkSize :: Num a => a chunkSize = 65536 {-# INLINE chunkSize #-} diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 26b096086..6746266d5 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -18,6 +18,7 @@ import Data.Word (Word8) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr +import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import Simplex.Chat.Mobile import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared @@ -27,21 +28,36 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..), getFileContentsSize) +import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) +import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import System.Directory (copyFile) import System.FilePath (()) +import System.IO (utf8) import Test.Hspec mobileTests :: HasCallStack => SpecWith FilePath mobileTests = do describe "mobile API" $ do + runIO $ do + setLocaleEncoding utf8 + setFileSystemEncoding utf8 + setForeignEncoding utf8 it "start new chat without user" testChatApiNoUser it "start new chat with existing user" testChatApi it "should encrypt/decrypt WebRTC frames" testMediaApi it "should encrypt/decrypt WebRTC frames via C API" testMediaCApi - it "should read/write encrypted files via C API" testFileCApi - it "should encrypt/decrypt files via C API" testFileEncryptionCApi + describe "should read/write encrypted files via C API" $ do + it "latin1 name" $ testFileCApi "test" + it "utf8 name 1" $ testFileCApi "тест" + it "utf8 name 2" $ testFileCApi "👍" + it "no exception on missing file" testMissingFileCApi + describe "should encrypt/decrypt files via C API" $ do + it "latin1 name" $ testFileEncryptionCApi "test" + it "utf8 name 1" $ testFileEncryptionCApi "тест" + it "utf8 name 2" $ testFileEncryptionCApi "👍" + it "no exception on missing file" testMissingFileEncryptionCApi noActiveUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -176,16 +192,19 @@ instance FromJSON WriteFileResult where parseJSON = J.genericParseJSON . sumType instance FromJSON ReadFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RF" -testFileCApi :: FilePath -> IO () -testFileCApi tmp = do +testFileCApi :: FilePath -> FilePath -> IO () +testFileCApi fileName tmp = do src <- B.readFile "./tests/fixtures/test.pdf" - cPath <- newCAString $ tmp "test.pdf" + let path = tmp (fileName <> ".pdf") + cPath <- newCString path let len = B.length src cLen = fromIntegral len ptr <- mallocBytes $ B.length src putByteString ptr src r <- peekCAString =<< cChatWriteFile cPath ptr cLen - Just (WFResult (CFArgs key nonce)) <- jDecode r + Just (WFResult cfArgs@(CFArgs key nonce)) <- jDecode r + let encryptedFile = CryptoFile path $ Just cfArgs + CF.getFileContentsSize encryptedFile `shouldReturn` fromIntegral (B.length src) cKey <- encodedCString key cNonce <- encodedCString nonce ptr' <- cChatReadFile cPath cKey cNonce @@ -196,22 +215,53 @@ testFileCApi tmp = do contents `shouldBe` src sz `shouldBe` len -testFileEncryptionCApi :: FilePath -> IO () -testFileEncryptionCApi tmp = do - src <- B.readFile "./tests/fixtures/test.pdf" - cFromPath <- newCAString "./tests/fixtures/test.pdf" - let toPath = tmp "test.encrypted.pdf" - cToPath <- newCAString toPath - r <- peekCAString =<< cChatEncryptFile cFromPath cToPath - Just (WFResult cfArgs@(CFArgs key nonce)) <- jDecode r - getFileContentsSize (CryptoFile toPath $ Just cfArgs) `shouldReturn` fromIntegral (B.length src) +testMissingFileCApi :: FilePath -> IO () +testMissingFileCApi tmp = do + let path = tmp "missing_file" + cPath <- newCString path + CFArgs key nonce <- CF.randomArgs cKey <- encodedCString key cNonce <- encodedCString nonce - let toPath' = tmp "test.decrypted.pdf" - cToPath' <- newCAString toPath' + ptr <- cChatReadFile cPath cKey cNonce + r <- peekCAString $ castPtr ptr + Just (RFError err) <- jDecode r + err `shouldContain` "missing_file: openBinaryFile: does not exist" + +testFileEncryptionCApi :: FilePath -> FilePath -> IO () +testFileEncryptionCApi fileName tmp = do + let fromPath = tmp (fileName <> ".source.pdf") + copyFile "./tests/fixtures/test.pdf" fromPath + src <- B.readFile fromPath + cFromPath <- newCString fromPath + let toPath = tmp (fileName <> ".encrypted.pdf") + cToPath <- newCString toPath + r <- peekCAString =<< cChatEncryptFile cFromPath cToPath + Just (WFResult cfArgs@(CFArgs key nonce)) <- jDecode r + CF.getFileContentsSize (CryptoFile toPath $ Just cfArgs) `shouldReturn` fromIntegral (B.length src) + cKey <- encodedCString key + cNonce <- encodedCString nonce + let toPath' = tmp (fileName <> ".decrypted.pdf") + cToPath' <- newCString toPath' "" <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' B.readFile toPath' `shouldReturn` src +testMissingFileEncryptionCApi :: FilePath -> IO () +testMissingFileEncryptionCApi tmp = do + let fromPath = tmp "missing_file.source.pdf" + toPath = tmp "missing_file.encrypted.pdf" + cFromPath <- newCString fromPath + cToPath <- newCString toPath + r <- peekCAString =<< cChatEncryptFile cFromPath cToPath + Just (WFError err) <- jDecode r + err `shouldContain` fromPath + CFArgs key nonce <- CF.randomArgs + cKey <- encodedCString key + cNonce <- encodedCString nonce + let toPath' = tmp "missing_file.decrypted.pdf" + cToPath' <- newCString toPath' + err' <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' + err' `shouldContain` toPath + jDecode :: FromJSON a => String -> IO (Maybe a) jDecode = pure . J.decode . LB.pack From 82fd3b9004fcd37f90e0020d078cd00faa797d0d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Sep 2023 20:18:43 +0100 Subject: [PATCH 29/41] core: change encoding of the result returned by chat_read_file C API (#3036) * core: change encoding of the result returned by chat_read_file C API * remove unused dependency * remove pointer cast --- src/Simplex/Chat/Mobile/File.hs | 37 +++++++++++++++++---------------- tests/ChatTests/Files.hs | 6 +++--- tests/MobileTests.hs | 19 ++++++++++------- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index a0fb3eb5b..4aabbcd12 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -23,11 +23,13 @@ import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Lazy as LB import qualified Data.ByteString.Lazy.Char8 as LB' +import Data.Char (chr) import Data.Either (fromLeft) -import Data.Word (Word8) +import Data.Word (Word8, Word32) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr +import Foreign.Storable (poke) import GHC.Generics (Generic) import Simplex.Chat.Mobile.Shared import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..), CryptoFileHandle, FTCryptoError (..)) @@ -70,24 +72,23 @@ cChatReadFile cPath cKey cNonce = do path <- peekCString cPath key <- B.packCString cKey nonce <- B.packCString cNonce - (r, s) <- chatReadFile path key nonce - let r' = LB.toStrict $ J.encode r <> "\NUL" - ptr <- mallocBytes $ B.length r' + B.length s - putByteString ptr r' - unless (B.null s) $ putByteString (ptr `plusPtr` B.length r') s - pure ptr + chatReadFile path key nonce >>= \case + Left e -> castPtr <$> newCString (chr 1 : e) + Right s -> do + let s' = LB.toStrict s + len = B.length s' + ptr <- mallocBytes $ len + 5 + poke ptr 0 + poke (ptr `plusPtr` 1) (fromIntegral len :: Word32) + putByteString (ptr `plusPtr` 5) s' + pure ptr -chatReadFile :: FilePath -> ByteString -> ByteString -> IO (ReadFileResult, ByteString) -chatReadFile path keyStr nonceStr = do - either ((,"") . RFError) result <$> runCatchExceptT readFile_ - where - result s = let s' = LB.toStrict s in (RFResult $ B.length s', s') - readFile_ :: ExceptT String IO LB.ByteString - readFile_ = do - key <- liftEither $ strDecode keyStr - nonce <- liftEither $ strDecode nonceStr - let file = CryptoFile path $ Just $ CFArgs key nonce - withExceptT show $ CF.readFile file +chatReadFile :: FilePath -> ByteString -> ByteString -> IO (Either String LB.ByteString) +chatReadFile path keyStr nonceStr = runCatchExceptT $ do + key <- liftEither $ strDecode keyStr + nonce <- liftEither $ strDecode nonceStr + let file = CryptoFile path $ Just $ CFArgs key nonce + withExceptT show $ CF.readFile file cChatEncryptFile :: CString -> CString -> IO CJSONString cChatEncryptFile cFromPath cToPath = do diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 0adb234ce..927705f4d 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -1041,9 +1041,9 @@ testXFTPFileTransferEncrypted = alice <## "completed uploading file 1 (test.pdf) for bob" bob <## "started receiving file 1 (test.pdf) from alice" bob <## "completed receiving file 1 (test.pdf) from alice" - (RFResult destLen, dest) <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce) - fromIntegral destLen `shouldBe` srcLen - dest `shouldBe` src + Right dest <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce) + LB.length dest `shouldBe` fromIntegral srcLen + LB.toStrict dest `shouldBe` src where cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 6746266d5..07d0d2fe9 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -13,11 +13,13 @@ import qualified Data.Aeson as J import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Char8 as BS +import Data.ByteString.Internal (create, memcpy) import qualified Data.ByteString.Lazy.Char8 as LB -import Data.Word (Word8) +import Data.Word (Word8, Word32) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr +import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import Simplex.Chat.Mobile import Simplex.Chat.Mobile.File @@ -207,13 +209,14 @@ testFileCApi fileName tmp = do CF.getFileContentsSize encryptedFile `shouldReturn` fromIntegral (B.length src) cKey <- encodedCString key cNonce <- encodedCString nonce + -- the returned pointer contains 0, buffer length as Word32, then buffer ptr' <- cChatReadFile cPath cKey cNonce - -- the returned pointer contains NUL-terminated JSON string of ReadFileResult followed by the file contents - r' <- peekCAString $ castPtr ptr' - Just (RFResult sz) <- jDecode r' - contents <- getByteString (ptr' `plusPtr` (length r' + 1)) $ fromIntegral sz + peek ptr' `shouldReturn` (0 :: Word8) + sz :: Word32 <- peek (ptr' `plusPtr` 1) + let sz' = fromIntegral sz + contents <- create sz' $ \toPtr -> memcpy toPtr (ptr' `plusPtr` 5) sz' contents `shouldBe` src - sz `shouldBe` len + sz' `shouldBe` fromIntegral len testMissingFileCApi :: FilePath -> IO () testMissingFileCApi tmp = do @@ -223,8 +226,8 @@ testMissingFileCApi tmp = do cKey <- encodedCString key cNonce <- encodedCString nonce ptr <- cChatReadFile cPath cKey cNonce - r <- peekCAString $ castPtr ptr - Just (RFError err) <- jDecode r + peek ptr `shouldReturn` 1 + err <- peekCAString (ptr `plusPtr` 1) err `shouldContain` "missing_file: openBinaryFile: does not exist" testFileEncryptionCApi :: FilePath -> FilePath -> IO () From e76440ee66cbd6be0208adbd37020d913e5b47e2 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 7 Sep 2023 22:32:47 +0300 Subject: [PATCH 30/41] desktop: local alias update (#3026) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../simplex/common/views/chat/ChatInfoView.kt | 30 +++++++++++-------- .../views/helpers/DefaultBasicTextField.kt | 10 ++----- .../newchat/ContactConnectionInfoView.kt | 2 +- 3 files changed, 21 insertions(+), 21 deletions(-) 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 87f8a7e65..170f87013 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 @@ -24,6 +24,7 @@ import androidx.compose.ui.text.* 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.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -278,7 +279,7 @@ fun ChatInfoLayout( ChatInfoHeader(chat.chatInfo, contact) } - LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged) + LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged) SectionSpacer() if (customUserProfile != null) { SectionView(generalGetString(MR.strings.incognito).uppercase()) { @@ -403,13 +404,16 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { @Composable fun LocalAliasEditor( + chatId: String, initialValue: String, center: Boolean = true, leadingIcon: Boolean = false, focus: Boolean = false, updateValue: (String) -> Unit ) { - var value by rememberSaveable { mutableStateOf(initialValue) } + val state = remember(chatId) { + mutableStateOf(TextFieldValue(initialValue)) + } var updatedValueAtLeastOnce = remember { false } val modifier = if (center) Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp) @@ -418,7 +422,7 @@ fun LocalAliasEditor( Row(Modifier.fillMaxWidth(), horizontalArrangement = if (center) Arrangement.Center else Arrangement.Start) { DefaultBasicTextField( modifier, - value, + state, { Text( generalGetString(MR.strings.text_field_set_contact_placeholder), @@ -431,27 +435,27 @@ fun LocalAliasEditor( } else null, color = MaterialTheme.colors.secondary, focus = focus, - textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center), - keyboardActions = KeyboardActions(onDone = { updateValue(value) }) + textStyle = TextStyle.Default.copy(textAlign = if (state.value.text.isEmpty() || !center) TextAlign.Start else TextAlign.Center), + keyboardActions = KeyboardActions(onDone = { updateValue(state.value.text) }) ) { - value = it + state.value = it updatedValueAtLeastOnce = true } } - LaunchedEffect(Unit) { - var prevValue = value - snapshotFlow { value } + LaunchedEffect(chatId) { + var prevValue = state.value + snapshotFlow { state.value } .distinctUntilChanged() .onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing .conflate() // get the latest value - .filter { it == value && it != prevValue } // don't process old ones + .filter { it == state.value && it != prevValue } // don't process old ones .collect { - updateValue(it) + updateValue(it.text) prevValue = it } } - DisposableEffect(Unit) { - onDispose { if (updatedValueAtLeastOnce) updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast + DisposableEffect(chatId) { + onDispose { if (updatedValueAtLeastOnce) updateValue(state.value.text) } // just in case snapshotFlow will be canceled when user presses Back too fast } } 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 65eb11321..71801e7a5 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 @@ -32,7 +32,7 @@ import kotlinx.coroutines.launch @Composable fun DefaultBasicTextField( modifier: Modifier, - initialValue: String, + state: MutableState, placeholder: (@Composable () -> Unit)? = null, leadingIcon: (@Composable () -> Unit)? = null, focus: Boolean = false, @@ -41,11 +41,8 @@ fun DefaultBasicTextField( selectTextOnFocus: Boolean = false, keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions: KeyboardActions = KeyboardActions(), - onValueChange: (String) -> Unit, + onValueChange: (TextFieldValue) -> Unit, ) { - val state = remember { - mutableStateOf(TextFieldValue(initialValue)) - } val focusRequester = remember { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current @@ -83,8 +80,7 @@ fun DefaultBasicTextField( minHeight = TextFieldDefaults.MinHeight ), onValueChange = { - state.value = it - onValueChange(it.text) + onValueChange(it) }, cursorBrush = SolidColor(colors.cursorColor(false).value), visualTransformation = VisualTransformation.None, 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 fe62a7d9d..934c050d8 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 @@ -126,7 +126,7 @@ private fun ContactConnectionInfoLayout( ) if (contactConnection.groupLinkId == null) { - LocalAliasEditor(contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged) + LocalAliasEditor(contactConnection.id, contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged) } SectionView { From 113a57c7c759d4e9674cdea2428c8a028622c397 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Sep 2023 22:43:51 +0100 Subject: [PATCH 31/41] ios: update chat_read_file (#3037) --- apps/ios/SimpleXChat/CryptoFile.swift | 25 +++++++++++++++---------- apps/ios/SimpleXChat/SimpleX.h | 11 ++++++----- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift index d641464ee..dcb2be9ae 100644 --- a/apps/ios/SimpleXChat/CryptoFile.swift +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -25,20 +25,25 @@ public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { } } -enum ReadFileResult: Decodable { - case result(fileSize: Int) - case error(readError: String) -} - public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> Data { var cPath = path.cString(using: .utf8)! var cKey = cryptoArgs.fileKey.cString(using: .utf8)! var cNonce = cryptoArgs.fileNonce.cString(using: .utf8)! - let r = chat_read_file(&cPath, &cKey, &cNonce)! - let d = String.init(cString: r).data(using: .utf8)! - switch try jsonDecoder.decode(ReadFileResult.self, from: d) { - case let .error(err): throw RuntimeError(err) - case let .result(size): return Data(bytes: r.advanced(by: d.count + 1), count: size) + let ptr = chat_read_file(&cPath, &cKey, &cNonce)! + let status = UInt8(ptr.pointee) + switch status { + case 0: // ok + let dLen = Data(bytes: ptr.advanced(by: 1), count: 4) + let len = dLen.withUnsafeBytes { $0.load(as: UInt32.self) } + let d = Data(bytes: ptr.advanced(by: 5), count: Int(len)) + free(ptr) + return d + case 1: // error + let err = String.init(cString: ptr) + free(ptr) + throw RuntimeError(err) + default: + throw RuntimeError("unexpected chat_read_file status: \(status)") } } diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 55b44dee3..67c2fa728 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -26,16 +26,17 @@ extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_encrypt_media(char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); -// chat_write_file returns NUL-terminated string with JSON of WriteFileResult +// chat_write_file returns null-terminated string with JSON of WriteFileResult extern char *chat_write_file(char *path, char *data, int len); // chat_read_file returns a buffer with: -// 1. NUL-terminated C string with JSON of ReadFileResult, followed by -// 2. file data, the length is defined in ReadFileResult +// result status (1 byte), then if +// status == 0 (success): buffer length (uint32, 4 bytes), buffer of specified length. +// status == 1 (error): null-terminated error message string. extern char *chat_read_file(char *path, char *key, char *nonce); -// chat_encrypt_file returns NUL-terminated string with JSON of WriteFileResult +// chat_encrypt_file returns null-terminated string with JSON of WriteFileResult extern char *chat_encrypt_file(char *fromPath, char *toPath); -// chat_decrypt_file returns NUL-terminated string with the error message +// chat_decrypt_file returns null-terminated string with the error message extern char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); From ad656224077c68b997c7839e2f19ca594991bdba Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 8 Sep 2023 00:45:00 +0300 Subject: [PATCH 32/41] desktop: catch Toast exception (#3028) --- .../kotlin/chat/simplex/common/model/NtfManager.desktop.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 486b147f8..94e985328 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 @@ -104,7 +104,11 @@ object NtfManager { actions.forEach { builder.action(it.first, it.second) } - prevNtfs.add(chatId to builder.toast()) + try { + prevNtfs.add(chatId to builder.toast()) + } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) + } } private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) { From 45682aa7ced97b3e9ac87f4c8d6f998181b26b1f Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 8 Sep 2023 13:45:22 +0300 Subject: [PATCH 33/41] website: fix the apple-app-site-association (#3038) --- website/src/.well-known/README.md | 4 +++- .../index.json} | 0 2 files changed, 3 insertions(+), 1 deletion(-) rename website/src/.well-known/{apple-app-site-association => apple-app-site-association/index.json} (100%) diff --git a/website/src/.well-known/README.md b/website/src/.well-known/README.md index ec4c7f57e..6346c85a7 100644 --- a/website/src/.well-known/README.md +++ b/website/src/.well-known/README.md @@ -12,4 +12,6 @@ File `assetlinks.json` includes certificate hashes for: ## iOS -`apple-app-site-association` currently does not work, as it needs to be served with `Content-type: application/json; charset=utf-8` and GitHub pages do not support adding this header to files without JSON extension. +`apple-app-site-association` needs to be served with `Content-type: application/json; charset=utf-8` and GitHub pages do not support adding this header to files without JSON extension. + +To workaround this (thanks to [StackOverflow - Serve json data from github pages](https://stackoverflow.com/questions/39199042/serve-json-data-from-github-pages)) we're creating directory named `apple-app-site-association` with `index.json` file that contains all the necessary configs. \ No newline at end of file diff --git a/website/src/.well-known/apple-app-site-association b/website/src/.well-known/apple-app-site-association/index.json similarity index 100% rename from website/src/.well-known/apple-app-site-association rename to website/src/.well-known/apple-app-site-association/index.json From 281d9c7f794de59dff035a99deffc2842c49547c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 8 Sep 2023 13:21:56 +0100 Subject: [PATCH 34/41] ios: add Finnish and Ukranian interface languages (#3040) --- .../cs.xcloc/Localized Contents/cs.xliff | 8 + .../de.xcloc/Localized Contents/de.xliff | 8 + .../en.xcloc/Localized Contents/en.xliff | 10 + .../es.xcloc/Localized Contents/es.xliff | 8 + .../AccentColor.colorset/Contents.json | 15 + .../Shared/Assets.xcassets/Contents.json | 6 + .../AccentColor.colorset/Contents.json | 23 + .../Shared/Assets.xcassets/Contents.json | 6 + .../SimpleX NSE/en.lproj/InfoPlist.strings | 6 + .../en.lproj/Localizable.strings | 30 + .../en.lproj/SimpleX--iOS--InfoPlist.strings | 10 + .../fi.xcloc/contents.json | 12 + .../fr.xcloc/Localized Contents/fr.xliff | 8 + .../it.xcloc/Localized Contents/it.xliff | 8 + .../ja.xcloc/Localized Contents/ja.xliff | 8 + .../nl.xcloc/Localized Contents/nl.xliff | 8 + .../pl.xcloc/Localized Contents/pl.xliff | 8 + .../ru.xcloc/Localized Contents/ru.xliff | 8 + .../th.xcloc/Localized Contents/th.xliff | 8 + .../AccentColor.colorset/Contents.json | 15 + .../Shared/Assets.xcassets/Contents.json | 6 + .../uk.xcloc/Localized Contents/uk.xliff | 14 +- .../AccentColor.colorset/Contents.json | 23 + .../Shared/Assets.xcassets/Contents.json | 6 + .../SimpleX NSE/en.lproj/InfoPlist.strings | 6 + .../en.lproj/Localizable.strings | 30 + .../en.lproj/SimpleX--iOS--InfoPlist.strings | 10 + .../uk.xcloc/contents.json | 12 + .../Localized Contents/zh-Hans.xliff | 8 + .../SimpleX NSE/fi.lproj/InfoPlist.strings | 9 + .../SimpleX NSE/uk.lproj/InfoPlist.strings | 9 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 14 + apps/ios/fi.lproj/Localizable.strings | 3675 +++++++++++++++++ .../fi.lproj/SimpleX--iOS--InfoPlist.strings | 15 + apps/ios/uk.lproj/Localizable.strings | 3675 +++++++++++++++++ .../uk.lproj/SimpleX--iOS--InfoPlist.strings | 15 + scripts/ios/export-localizations.sh | 2 +- scripts/ios/import-localizations.sh | 2 +- 38 files changed, 7735 insertions(+), 9 deletions(-) create mode 100644 apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json create mode 100644 apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json create mode 100644 apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings create mode 100644 apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/Localizable.strings create mode 100644 apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings create mode 100644 apps/ios/SimpleX Localizations/fi.xcloc/contents.json create mode 100644 apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json create mode 100644 apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json create mode 100644 apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings create mode 100644 apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/Localizable.strings create mode 100644 apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings create mode 100644 apps/ios/SimpleX Localizations/uk.xcloc/contents.json create mode 100644 apps/ios/SimpleX NSE/fi.lproj/InfoPlist.strings create mode 100644 apps/ios/SimpleX NSE/uk.lproj/InfoPlist.strings create mode 100644 apps/ios/fi.lproj/Localizable.strings create mode 100644 apps/ios/fi.lproj/SimpleX--iOS--InfoPlist.strings create mode 100644 apps/ios/uk.lproj/Localizable.strings create mode 100644 apps/ios/uk.lproj/SimpleX--iOS--InfoPlist.strings 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 bef40f5ae..8e90ae459 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -1819,6 +1819,10 @@ Šifrovat databázi? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Zašifrovaná databáze @@ -1949,6 +1953,10 @@ Chyba při vytváření profilu! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Chyba při mazání databáze chatu 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 d9286fb4f..fe164da9a 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -1819,6 +1819,10 @@ Datenbank verschlüsseln? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Verschlüsselte Datenbank @@ -1949,6 +1953,10 @@ Fehler beim Erstellen des Profils! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Fehler beim Löschen der Chat-Datenbank 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 2ef116055..5374efbf0 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1819,6 +1819,11 @@ Encrypt database? No comment provided by engineer. + + Encrypt local files + Encrypt local files + No comment provided by engineer. + Encrypted database Encrypted database @@ -1949,6 +1954,11 @@ Error creating profile! No comment provided by engineer. + + Error decrypting file + Error decrypting file + No comment provided by engineer. + Error deleting chat database Error deleting chat database 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 174261590..84325c118 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -1819,6 +1819,10 @@ ¿Cifrar base de datos? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Base de datos cifrada @@ -1949,6 +1953,10 @@ ¡Error al crear perfil! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Error al eliminar base de datos diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..e919fc253 --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "locale" : "fi" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..aaa7f79bc --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.000", + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.533" + } + }, + "idiom" : "universal" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings new file mode 100644 index 000000000..124ddbcc3 --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/Localizable.strings new file mode 100644 index 000000000..cf485752e --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/Localizable.strings @@ -0,0 +1,30 @@ +/* No comment provided by engineer. */ +"_italic_" = "\\_italic_"; + +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact."; + +/* No comment provided by engineer. */ +"*bold*" = "\\*bold*"; + +/* No comment provided by engineer. */ +"`a + b`" = "\\`a + b`"; + +/* No comment provided by engineer. */ +"~strike~" = "\\~strike~"; + +/* call status */ +"connecting call" = "connecting call…"; + +/* No comment provided by engineer. */ +"Connecting server…" = "Connecting to server…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; + +/* rcv group event chat item */ +"member connected" = "connected"; + +/* No comment provided by engineer. */ +"No group!" = "Group not found!"; + 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 new file mode 100644 index 000000000..3af673b19 --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,10 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; +/* Privacy - Camera Usage Description */ +"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 - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media"; diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/contents.json new file mode 100644 index 000000000..c46e0f6a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/fi.xcloc/contents.json @@ -0,0 +1,12 @@ +{ + "developmentRegion" : "en", + "project" : "SimpleX.xcodeproj", + "targetLocale" : "fi", + "toolInfo" : { + "toolBuildNumber" : "15A5219j", + "toolID" : "com.apple.dt.xcode", + "toolName" : "Xcode", + "toolVersion" : "15.0" + }, + "version" : "1.0" +} \ No newline at end of file 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 113e00309..95de1b8b2 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -1819,6 +1819,10 @@ Chiffrer la base de données ? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Base de données chiffrée @@ -1949,6 +1953,10 @@ Erreur lors de la création du profil ! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Erreur lors de la suppression de la base de données du chat 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 de3b0c0a6..fb6ea10da 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -1819,6 +1819,10 @@ Crittografare il database? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Database crittografato @@ -1949,6 +1953,10 @@ Errore nella creazione del profilo! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Errore nell'eliminazione del database della chat 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 1bba3acde..c7460be60 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -1818,6 +1818,10 @@ データベースを暗号化しますか? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database 暗号化済みデータベース @@ -1948,6 +1952,10 @@ プロフィール作成にエラー発生! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database チャットデータベース削除にエラー発生 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 c32f79b78..7882a062e 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -1819,6 +1819,10 @@ Database versleutelen? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Versleutelde database @@ -1949,6 +1953,10 @@ Fout bij aanmaken van profiel! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Fout bij het verwijderen van de chat database 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 68bc9b929..7402249c0 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -1819,6 +1819,10 @@ Zaszyfrować bazę danych? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Zaszyfrowana baza danych @@ -1949,6 +1953,10 @@ Błąd tworzenia profilu! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Błąd usuwania bazy danych czatu 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 5641ba085..eec4fd40d 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -1819,6 +1819,10 @@ Зашифровать базу данных? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database База данных зашифрована @@ -1949,6 +1953,10 @@ Ошибка создания профиля! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database Ошибка при удалении данных чата 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 72182b2c7..11bde620f 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -1807,6 +1807,10 @@ Encrypt ฐานข้อมูล? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database Encrypt ฐานข้อมูลเรียบร้อยแล้ว @@ -1937,6 +1941,10 @@ เกิดข้อผิดพลาดในการสร้างโปรไฟล์! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database เกิดข้อผิดพลาดในการลบฐานข้อมูลแชท diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..4b8ee6308 --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "locale" : "uk" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 3051a6112..52c69fbfa 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -1891,7 +1891,7 @@ Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Встановіть [SimpleX Chat для терміналу] (https://github.com/simplex-chat/simplex-chat) + Встановіть [SimpleX Chat для терміналу](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. @@ -2586,7 +2586,7 @@ We will be adding server redundancy to prevent lost messages. Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Читайте більше в нашому [GitHub репозиторії] (https://github.com/simplex-chat/simplex-chat#readme). + Читайте більше в нашому [GitHub репозиторії](https://github.com/simplex-chat/simplex-chat#readme). No comment provided by engineer. @@ -3892,17 +3892,17 @@ SimpleX servers cannot see your profile. [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Внесок] (https://github.com/simplex-chat/simplex-chat#contribute) + [Внесок](https://github.com/simplex-chat/simplex-chat#contribute) No comment provided by engineer. [Send us email](mailto:chat@simplex.chat) - [Напишіть нам електронною поштою] (mailto:chat@simplex.chat) + [Напишіть нам електронною поштою](mailto:chat@simplex.chat) No comment provided by engineer. [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Зірка на GitHub] (https://github.com/simplex-chat/simplex-chat) + [Зірка на GitHub](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. @@ -5369,7 +5369,7 @@ SimpleX servers cannot see your profile. Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Читайте більше в [Посібнику користувача] (https://simplex.chat/docs/guide/readme.html#connect-to-friends). + Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/readme.html#connect-to-friends). No comment provided by engineer. @@ -5419,7 +5419,7 @@ SimpleX servers cannot see your profile. Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address). - Читайте більше в [Посібнику користувача] (https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address). + Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address). No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..aaa7f79bc --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.000", + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.533" + } + }, + "idiom" : "universal" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings new file mode 100644 index 000000000..124ddbcc3 --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/Localizable.strings new file mode 100644 index 000000000..cf485752e --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/Localizable.strings @@ -0,0 +1,30 @@ +/* No comment provided by engineer. */ +"_italic_" = "\\_italic_"; + +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact."; + +/* No comment provided by engineer. */ +"*bold*" = "\\*bold*"; + +/* No comment provided by engineer. */ +"`a + b`" = "\\`a + b`"; + +/* No comment provided by engineer. */ +"~strike~" = "\\~strike~"; + +/* call status */ +"connecting call" = "connecting call…"; + +/* No comment provided by engineer. */ +"Connecting server…" = "Connecting to server…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; + +/* rcv group event chat item */ +"member connected" = "connected"; + +/* No comment provided by engineer. */ +"No group!" = "Group not found!"; + 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 new file mode 100644 index 000000000..3af673b19 --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,10 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; +/* Privacy - Camera Usage Description */ +"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 - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media"; diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/contents.json new file mode 100644 index 000000000..6ad42fd10 --- /dev/null +++ b/apps/ios/SimpleX Localizations/uk.xcloc/contents.json @@ -0,0 +1,12 @@ +{ + "developmentRegion" : "en", + "project" : "SimpleX.xcodeproj", + "targetLocale" : "uk", + "toolInfo" : { + "toolBuildNumber" : "15A5219j", + "toolID" : "com.apple.dt.xcode", + "toolName" : "Xcode", + "toolVersion" : "15.0" + }, + "version" : "1.0" +} \ No newline at end of file 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 d77ef1e81..8fa66159d 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 @@ -1808,6 +1808,10 @@ 加密数据库? No comment provided by engineer. + + Encrypt local files + No comment provided by engineer. + Encrypted database 加密数据库 @@ -1938,6 +1942,10 @@ 创建资料错误! No comment provided by engineer. + + Error decrypting file + No comment provided by engineer. + Error deleting chat database 删除聊天数据库错误 diff --git a/apps/ios/SimpleX NSE/fi.lproj/InfoPlist.strings b/apps/ios/SimpleX NSE/fi.lproj/InfoPlist.strings new file mode 100644 index 000000000..28a503d90 --- /dev/null +++ b/apps/ios/SimpleX NSE/fi.lproj/InfoPlist.strings @@ -0,0 +1,9 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; + +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; + +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. Kaikki oikeudet pidätetään."; + diff --git a/apps/ios/SimpleX NSE/uk.lproj/InfoPlist.strings b/apps/ios/SimpleX NSE/uk.lproj/InfoPlist.strings new file mode 100644 index 000000000..da1f5367b --- /dev/null +++ b/apps/ios/SimpleX NSE/uk.lproj/InfoPlist.strings @@ -0,0 +1,9 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; + +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; + +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Авторське право © 2022 SimpleX Chat. Всі права захищені."; + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index fc301a3ab..026bc963a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -268,6 +268,8 @@ 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionInfo.swift; sourceTree = ""; }; 5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenMediaView.swift; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; + 5C136D8E2AAB3D14006DE2FC /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = "fi.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; + 5C136D8F2AAB3D14006DE2FC /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; 5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = ""; }; 5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; @@ -296,6 +298,8 @@ 5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = ""; }; 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; }; + 5C636F662AAB3D2400751C84 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = "uk.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; + 5C636F672AAB3D2400751C84 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = ""; }; 5C65DAE429C77136003CEE45 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 5C65DAE629C771B9003CEE45 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C65DAE729C771B9003CEE45 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; @@ -415,6 +419,8 @@ 5CE2BA96284537A800EC33A6 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; + 5CE6C7B32AAB1515007F345C /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + 5CE6C7B42AAB1527007F345C /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; @@ -1006,6 +1012,8 @@ pl, ja, th, + fi, + uk, ); mainGroup = 5CA059BD279559F40002BEB4; packageReferences = ( @@ -1287,6 +1295,8 @@ 5C6D183329E93FBA00D430B3 /* pl */, 5CAC411B2A192DE800C331A2 /* ja */, 5CA3ED502A9422D1005D71E2 /* th */, + 5C136D8F2AAB3D14006DE2FC /* fi */, + 5C636F672AAB3D2400751C84 /* uk */, ); name = InfoPlist.strings; sourceTree = ""; @@ -1306,6 +1316,8 @@ 5CAB912529E93F9400F34A95 /* pl */, 5CAC41182A192D8400C331A2 /* ja */, 5CA3ED4D2A942170005D71E2 /* th */, + 5CE6C7B32AAB1515007F345C /* fi */, + 5CE6C7B42AAB1527007F345C /* uk */, ); name = Localizable.strings; sourceTree = ""; @@ -1324,6 +1336,8 @@ 5C6D183229E93FBA00D430B3 /* pl */, 5CAC411A2A192DE800C331A2 /* ja */, 5CA3ED4F2A9422D1005D71E2 /* th */, + 5C136D8E2AAB3D14006DE2FC /* fi */, + 5C636F662AAB3D2400751C84 /* uk */, ); name = "SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings new file mode 100644 index 000000000..47cce5061 --- /dev/null +++ b/apps/ios/fi.lproj/Localizable.strings @@ -0,0 +1,3675 @@ +/* No comment provided by engineer. */ +"\n" = "\n"; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" (" = " ("; + +/* No comment provided by engineer. */ +" (can be copied)" = " (voidaan kopioida)"; + +/* No comment provided by engineer. */ +"_italic_" = "\\_italic_"; + +/* No comment provided by engineer. */ +"- more stable message delivery.\n- a bit better groups.\n- and more!" = "- vakaampi viestien toimitus.\n- hieman paremmat ryhmät.\n- ja paljon muuta!"; + +/* No comment provided by engineer. */ +"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- ääniviestit enintään 5 minuuttia.\n- mukautettu katoamisaika.\n- historian muokkaaminen."; + +/* No comment provided by engineer. */ +", " = ", "; + +/* No comment provided by engineer. */ +": " = ": "; + +/* No comment provided by engineer. */ +"!1 colored!" = "!1 värillinen!"; + +/* No comment provided by engineer. */ +"." = "."; + +/* No comment provided by engineer. */ +"(" = "("; + +/* No comment provided by engineer. */ +")" = ")"; + +/* No comment provided by engineer. */ +"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Osallistu](https://github.com/simplex-chat/simplex-chat#contribute)"; + +/* No comment provided by engineer. */ +"[Send us email](mailto:chat@simplex.chat)" = "[Lähetä meille sähköpostia](mailto:chat@simplex.chat)"; + +/* No comment provided by engineer. */ +"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Tähti GitHubissa](https://github.com/simplex-chat/simplex-chat)"; + +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Lisää uusi kontakti**: luo kertakäyttöinen QR-koodi tai linkki kontaktille."; + +/* No comment provided by engineer. */ +"**Create link / QR code** for your contact to use." = "**Luo linkki / QR-koodi* kontaktille."; + +/* No comment provided by engineer. */ +"**e2e encrypted** audio call" = "**e2e-salattu** äänipuhelu"; + +/* No comment provided by engineer. */ +"**e2e encrypted** video call" = "**e2e-salattu** videopuhelu"; + +/* No comment provided by engineer. */ +"**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." = "**Yksityisempi**: tarkista uudet viestit 20 minuutin välein. Laitetunnus jaetaan SimpleX Chat -palvelimen kanssa, mutta ei sitä, kuinka monta yhteystietoa tai viestiä sinulla on."; + +/* No comment provided by engineer. */ +"**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." = "**Yksityisin**: älä käytä SimpleX Chat -ilmoituspalvelinta, tarkista viestit ajoittain taustalla (riippuu siitä, kuinka usein käytät sovellusta)."; + +/* No comment provided by engineer. */ +"**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Liitä vastaanotettu linkki** tai avaa se selaimessa ja napauta **Avaa mobiilisovelluksessa**."; + +/* No comment provided by engineer. */ +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Huomaa**: et voi palauttaa tai muuttaa tunnuslausetta, jos kadotat sen."; + +/* No comment provided by engineer. */ +"**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." = "**Suositus**: laitetunnus ja ilmoitukset lähetetään SimpleX Chat -ilmoituspalvelimelle, mutta ei viestin sisältöä, kokoa tai sitä, keneltä se on peräisin."; + +/* No comment provided by engineer. */ +"**Scan QR code**: to connect to your contact in person or via video call." = "**Skannaa QR-koodi**: muodosta yhteys kontaktiisi henkilökohtaisesti tai videopuhelun kautta."; + +/* No comment provided by engineer. */ +"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Varoitus**: Välittömät push-ilmoitukset vaativat tunnuslauseen, joka on tallennettu Keychainiin."; + +/* No comment provided by engineer. */ +"*bold*" = "\\*bold*"; + +/* copied message info title, # */ +"# %@" = "# %@"; + +/* copied message info */ +"## History" = "## Historia"; + +/* copied message info */ +"## In reply to" = "## vastauksena"; + +/* No comment provided by engineer. */ +"#secret#" = "#salaisuus#"; + +/* No comment provided by engineer. */ +"%@" = "% @"; + +/* No comment provided by engineer. */ +"%@ (current)" = "%@ (nykyinen)"; + +/* copied message info */ +"%@ (current):" = "% (nykyinen):"; + +/* No comment provided by engineer. */ +"%@ / %@" = "%@ / % @"; + +/* No comment provided by engineer. */ +"%@ %@" = "%@ % @"; + +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ ja %@ yhdistetty"; + +/* copied message info, <sender> at <time> */ +"%@ at %@:" = "%1$@ klo %2$@:"; + +/* notification title */ +"%@ is connected!" = "%@ on yhdistetty!"; + +/* No comment provided by engineer. */ +"%@ is not verified" = "%@ ei ole vahvistettu"; + +/* No comment provided by engineer. */ +"%@ is verified" = "%@ on vahvistettu"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ palvelimet"; + +/* notification title */ +"%@ wants to connect!" = "%@ haluaa muodostaa yhteyden!"; + +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ ja %lld muut jäsenet yhdistetty"; + +/* copied message info */ +"%@:" = "%@:"; + +/* time interval */ +"%d days" = "%d päivää"; + +/* time interval */ +"%d hours" = "%d tuntia"; + +/* time interval */ +"%d min" = "%d min"; + +/* time interval */ +"%d months" = "%d kuukautta"; + +/* time interval */ +"%d sec" = "%d sek"; + +/* integrity error chat item */ +"%d skipped message(s)" = "%d ohitettua viestiä"; + +/* time interval */ +"%d weeks" = "%d viikkoa"; + +/* No comment provided by engineer. */ +"%lld" = "%lld"; + +/* No comment provided by engineer. */ +"%lld %@" = "%lld %@"; + +/* No comment provided by engineer. */ +"%lld contact(s) selected" = "%lld kontaktia valittu"; + +/* No comment provided by engineer. */ +"%lld file(s) with total size of %@" = "%lld tiedosto(a), joiden kokonaiskoko on %@"; + +/* No comment provided by engineer. */ +"%lld members" = "%lld jäsenet"; + +/* No comment provided by engineer. */ +"%lld minutes" = "%lld minuuttia"; + +/* No comment provided by engineer. */ +"%lld second(s)" = "%lld sekunti(a)"; + +/* No comment provided by engineer. */ +"%lld seconds" = "%lld sekuntia"; + +/* No comment provided by engineer. */ +"%lldd" = "%lldd"; + +/* No comment provided by engineer. */ +"%lldh" = "%lldh"; + +/* No comment provided by engineer. */ +"%lldk" = "%lldk"; + +/* No comment provided by engineer. */ +"%lldm" = "%lldm"; + +/* No comment provided by engineer. */ +"%lldmth" = "%lldmth"; + +/* No comment provided by engineer. */ +"%llds" = "%llds"; + +/* No comment provided by engineer. */ +"%lldw" = "%lldw"; + +/* No comment provided by engineer. */ +"%u messages failed to decrypt." = "%u viestien salauksen purku epäonnistui."; + +/* No comment provided by engineer. */ +"%u messages skipped." = "%u viestit ohitettu."; + +/* No comment provided by engineer. */ +"`a + b`" = "\\`a + b`"; + +/* email text */ +"<p>Hi!</p>\n<p><a href=\"%@\">Connect to me via SimpleX Chat</a></p>" = "<p> Hei! </p>\n<p> <a href=\"%@\"> Ollaan yhteydessä SimpleX Chatin kautta</a></p>"; + +/* No comment provided by engineer. */ +"~strike~" = "\\~strike~"; + +/* No comment provided by engineer. */ +"0s" = "0s"; + +/* time interval */ +"1 day" = "1 päivä"; + +/* time interval */ +"1 hour" = "1 tunti"; + +/* No comment provided by engineer. */ +"1 minute" = "1 minuutti"; + +/* time interval */ +"1 month" = "1 kuukausi"; + +/* time interval */ +"1 week" = "1 viikko"; + +/* No comment provided by engineer. */ +"1-time link" = "Kertakäyttölinkki"; + +/* No comment provided by engineer. */ +"5 minutes" = "5 minuuttia"; + +/* No comment provided by engineer. */ +"6" = "6"; + +/* No comment provided by engineer. */ +"30 seconds" = "30 sekuntia"; + +/* No comment provided by engineer. */ +"A few more things" = "Muutama asia lisää"; + +/* notification title */ +"A new contact" = "Uusi kontakti"; + +/* No comment provided by engineer. */ +"A new random profile will be shared." = "Uusi satunnainen profiili jaetaan."; + +/* No comment provided by engineer. */ +"A separate TCP connection will be used **for each chat profile you have in the app**." = "Erillistä TCP-yhteyttä käytetään **jokaiselle sovelluksessa olevalle chat-profiilille**."; + +/* No comment provided by engineer. */ +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Jokaiselle kontaktille ja ryhmän jäsenelle käytetään erillistä TCP-yhteyttä**.\n**Huomaa**: jos kontakteja on useita, akun ja liikenteen kulutus voi olla huomattavasti suurempi ja jotkin yhteydet voivat epäonnistua."; + +/* No comment provided by engineer. */ +"Abort" = "Keskeytä"; + +/* No comment provided by engineer. */ +"Abort changing address" = "Keskeytä osoitteenvaihto"; + +/* No comment provided by engineer. */ +"Abort changing address?" = "Keskeytä osoitteenvaihto?"; + +/* No comment provided by engineer. */ +"About SimpleX" = "Tietoja SimpleX:stä"; + +/* No comment provided by engineer. */ +"About SimpleX address" = "Tietoja SimpleX osoitteesta"; + +/* No comment provided by engineer. */ +"About SimpleX Chat" = "Tietoja SimpleX Chatistä"; + +/* No comment provided by engineer. */ +"above, then choose:" = "edellä, valitse sitten:"; + +/* No comment provided by engineer. */ +"Accent color" = "Korostusväri"; + +/* accept contact request via notification + accept incoming call via notification */ +"Accept" = "Hyväksy"; + +/* No comment provided by engineer. */ +"Accept connection request?" = "Hyväksy yhteyspyyntö?"; + +/* notification body */ +"Accept contact request from %@?" = "Hyväksy kontaktipyyntö %@:ltä?"; + +/* accept contact request via notification */ +"Accept incognito" = "Hyväksy tuntematon"; + +/* call status */ +"accepted call" = "hyväksytty puhelu"; + +/* No comment provided by engineer. */ +"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Lisää osoite profiiliisi, jotta kontaktisi voivat jakaa sen muiden kanssa. Profiilipäivitys lähetetään kontakteillesi."; + +/* No comment provided by engineer. */ +"Add preset servers" = "Lisää esiasetettuja palvelimia"; + +/* No comment provided by engineer. */ +"Add profile" = "Lisää profiili"; + +/* No comment provided by engineer. */ +"Add server…" = "Lisää palvelin…"; + +/* No comment provided by engineer. */ +"Add servers by scanning QR codes." = "Lisää palvelimia skannaamalla QR-koodeja."; + +/* No comment provided by engineer. */ +"Add to another device" = "Lisää toiseen laitteeseen"; + +/* No comment provided by engineer. */ +"Add welcome message" = "Lisää tervetuloviesti"; + +/* No comment provided by engineer. */ +"Address" = "Osoite"; + +/* No comment provided by engineer. */ +"Address change will be aborted. Old receiving address will be used." = "Osoitteenmuutos keskeytetään. Käytetään vanhaa vastaanotto-osoitetta."; + +/* member role */ +"admin" = "ylläpitäjä"; + +/* No comment provided by engineer. */ +"Admins can create the links to join groups." = "Ylläpitäjät voivat luoda linkkejä ryhmiin liittymiseen."; + +/* No comment provided by engineer. */ +"Advanced network settings" = "Verkon lisäasetukset"; + +/* chat item text */ +"agreeing encryption for %@…" = "salauksesta sovitaan %@:lle…"; + +/* chat item text */ +"agreeing encryption…" = "hyväksyy salausta…"; + +/* No comment provided by engineer. */ +"All app data is deleted." = "Kaikki sovelluksen tiedot poistetaan."; + +/* No comment provided by engineer. */ +"All chats and messages will be deleted - this cannot be undone!" = "Kaikki keskustelut ja viestit poistetaan - tätä ei voi kumota!"; + +/* No comment provided by engineer. */ +"All data is erased when it is entered." = "Kaikki tiedot poistetaan, kun se syötetään."; + +/* No comment provided by engineer. */ +"All group members will remain connected." = "Kaikki ryhmän jäsenet pysyvät yhteydessä."; + +/* No comment provided by engineer. */ +"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Kaikki viestit poistetaan - tätä ei voi kumota! Viestit poistuvat VAIN sinulta."; + +/* No comment provided by engineer. */ +"All your contacts will remain connected." = "Kaikki kontaktisi pysyvät yhteydessä."; + +/* No comment provided by engineer. */ +"All your contacts will remain connected. Profile update will be sent to your contacts." = "Kaikki kontaktisi pysyvät yhteydessä. Profiilipäivitys lähetetään kontakteillesi."; + +/* No comment provided by engineer. */ +"Allow" = "Salli"; + +/* No comment provided by engineer. */ +"Allow calls only if your contact allows them." = "Salli puhelut vain, jos kontaktisi sallii ne."; + +/* No comment provided by engineer. */ +"Allow disappearing messages only if your contact allows it to you." = "Salli katoavat viestit vain, jos kontaktisi sallii sen sinulle."; + +/* No comment provided by engineer. */ +"Allow irreversible message deletion only if your contact allows it to you." = "Salli peruuttamaton viestien poisto vain, jos kontaktisi sallii ne sinulle."; + +/* No comment provided by engineer. */ +"Allow message reactions only if your contact allows them." = "Salli reaktiot viesteihin vain, jos kontaktisi sallii ne."; + +/* No comment provided by engineer. */ +"Allow message reactions." = "Salli viestireaktiot."; + +/* No comment provided by engineer. */ +"Allow sending direct messages to members." = "Salli yksityisviestien lähettäminen jäsenille."; + +/* No comment provided by engineer. */ +"Allow sending disappearing messages." = "Salli katoavien viestien lähettäminen."; + +/* No comment provided by engineer. */ +"Allow to irreversibly delete sent messages." = "Salli lähetettyjen viestien peruuttamaton poistaminen."; + +/* No comment provided by engineer. */ +"Allow to send files and media." = "Salli tiedostojen ja median lähettäminen."; + +/* No comment provided by engineer. */ +"Allow to send voice messages." = "Salli ääniviestien lähettäminen."; + +/* No comment provided by engineer. */ +"Allow voice messages only if your contact allows them." = "Salli ääniviestit vain, jos kontaktisi sallii ne."; + +/* No comment provided by engineer. */ +"Allow voice messages?" = "Salli ääniviestit?"; + +/* No comment provided by engineer. */ +"Allow your contacts adding message reactions." = "Salli kontaktiesi lisätä viestireaktioita."; + +/* No comment provided by engineer. */ +"Allow your contacts to call you." = "Salli kontaktiesi soittaa sinulle."; + +/* No comment provided by engineer. */ +"Allow your contacts to irreversibly delete sent messages." = "Salli kontaktiesi poistaa lähetetyt viestit peruuttamattomasti."; + +/* No comment provided by engineer. */ +"Allow your contacts to send disappearing messages." = "Salli kontaktiesi lähettää katoavia viestejä."; + +/* No comment provided by engineer. */ +"Allow your contacts to send voice messages." = "Salli kontaktiesi lähettää ääniviestejä."; + +/* No comment provided by engineer. */ +"Already connected?" = "Oletko jo muodostanut yhteyden?"; + +/* pref value */ +"always" = "aina"; + +/* No comment provided by engineer. */ +"Always use relay" = "Käytä aina relettä"; + +/* No comment provided by engineer. */ +"An empty chat profile with the provided name is created, and the app opens as usual." = "Luodaan tyhjä chat-profiili annetulla nimellä, ja sovellus avautuu normaalisti."; + +/* No comment provided by engineer. */ +"Answer call" = "Vastaa puheluun"; + +/* No comment provided by engineer. */ +"App build: %@" = "Sovellusversio: %@"; + +/* No comment provided by engineer. */ +"App icon" = "Sovelluksen kuvake"; + +/* No comment provided by engineer. */ +"App passcode" = "Sovelluksen pääsykoodi"; + +/* No comment provided by engineer. */ +"App passcode is replaced with self-destruct passcode." = "Sovelluksen pääsykoodi korvataan itsetuhoutuvalla pääsykoodilla."; + +/* No comment provided by engineer. */ +"App version" = "Sovellusversio"; + +/* No comment provided by engineer. */ +"App version: v%@" = "Sovellusversio: v%@"; + +/* No comment provided by engineer. */ +"Appearance" = "Ulkonäkö"; + +/* No comment provided by engineer. */ +"Attach" = "Liitä"; + +/* No comment provided by engineer. */ +"Audio & video calls" = "Ääni- ja videopuhelut"; + +/* No comment provided by engineer. */ +"Audio and video calls" = "Ääni- ja videopuhelut"; + +/* No comment provided by engineer. */ +"audio call (not e2e encrypted)" = "äänipuhelu (ei e2e-salattu)"; + +/* chat feature */ +"Audio/video calls" = "Ääni/videopuhelut"; + +/* No comment provided by engineer. */ +"Audio/video calls are prohibited." = "Ääni-/videopuhelut ovat kiellettyjä."; + +/* PIN entry */ +"Authentication cancelled" = "Tunnistautuminen peruutettu"; + +/* No comment provided by engineer. */ +"Authentication failed" = "Tunnistautuminen epäonnistui"; + +/* No comment provided by engineer. */ +"Authentication is required before the call is connected, but you may miss calls." = "Tunnistautuminen vaaditaan ennen kuin puhelu yhdistetään, mutta puheluita voi jäädä vastaamatta."; + +/* No comment provided by engineer. */ +"Authentication unavailable" = "Tunnistautuminen ei ole käytettävissä"; + +/* No comment provided by engineer. */ +"Auto-accept" = "Hyväksy automaattisesti"; + +/* No comment provided by engineer. */ +"Auto-accept contact requests" = "Hyväksy yhteydenottopyynnöt automaattisesti"; + +/* No comment provided by engineer. */ +"Auto-accept images" = "Hyväksy kuvat automaattisesti"; + +/* No comment provided by engineer. */ +"Back" = "Takaisin"; + +/* integrity error chat item */ +"bad message hash" = "virheellinen viestin tarkiste"; + +/* No comment provided by engineer. */ +"Bad message hash" = "Virheellinen viestin tarkiste"; + +/* integrity error chat item */ +"bad message ID" = "virheellinen viestin tunniste"; + +/* No comment provided by engineer. */ +"Bad message ID" = "Virheellinen viestin tunniste"; + +/* No comment provided by engineer. */ +"Better messages" = "Parempia viestejä"; + +/* No comment provided by engineer. */ +"bold" = "lihavoitu"; + +/* No comment provided by engineer. */ +"Both you and your contact can add message reactions." = "Sekä sinä että kontaktisi voivat käyttää viestireaktioita."; + +/* No comment provided by engineer. */ +"Both you and your contact can irreversibly delete sent messages." = "Sekä sinä että kontaktisi voitte peruuttamattomasti poistaa lähetetyt viestit."; + +/* No comment provided by engineer. */ +"Both you and your contact can make calls." = "Sekä sinä että kontaktisi voitte soittaa puheluita."; + +/* No comment provided by engineer. */ +"Both you and your contact can send disappearing messages." = "Sekä sinä että kontaktisi voitte lähettää katoavia viestejä."; + +/* No comment provided by engineer. */ +"Both you and your contact can send voice messages." = "Sekä sinä että kontaktisi voitte lähettää ääniviestejä."; + +/* 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)." = "Chat-profiilin mukaan (oletus) tai [yhteyden mukaan](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; + +/* No comment provided by engineer. */ +"Call already ended!" = "Puhelu on jo päättynyt!"; + +/* call status */ +"call error" = "soittovirhe"; + +/* call status */ +"call in progress" = "puhelu käynnissä"; + +/* call status */ +"calling…" = "soittaa…"; + +/* No comment provided by engineer. */ +"Calls" = "Puhelut"; + +/* No comment provided by engineer. */ +"Can't delete user profile!" = "Käyttäjäprofiilia ei voi poistaa!"; + +/* No comment provided by engineer. */ +"Can't invite contact!" = "Kontaktia ei voi kutsua!"; + +/* No comment provided by engineer. */ +"Can't invite contacts!" = "Kontakteja ei voi kutsua!"; + +/* No comment provided by engineer. */ +"Cancel" = "Peruuta"; + +/* feature offered item */ +"cancelled %@" = "peruutettu %@"; + +/* No comment provided by engineer. */ +"Cannot access keychain to save database password" = "Ei pääsyä avainnippuun tietokannan salasanan tallentamiseksi"; + +/* No comment provided by engineer. */ +"Cannot receive file" = "Tiedostoa ei voi vastaanottaa"; + +/* No comment provided by engineer. */ +"Change" = "Muuta"; + +/* No comment provided by engineer. */ +"Change database passphrase?" = "Muutetaanko tietokannan tunnuslause?"; + +/* authentication reason */ +"Change lock mode" = "Vaihda lukitustilaa"; + +/* No comment provided by engineer. */ +"Change member role?" = "Vaihda jäsenroolia?"; + +/* authentication reason */ +"Change passcode" = "Vaihda pääsykoodi"; + +/* No comment provided by engineer. */ +"Change receiving address" = "Vaihda vastaanotto-osoitetta"; + +/* No comment provided by engineer. */ +"Change receiving address?" = "Vaihda vastaanotto-osoite?"; + +/* No comment provided by engineer. */ +"Change role" = "Vaihda rooli"; + +/* authentication reason */ +"Change self-destruct mode" = "Vaihda itsetuhotilaa"; + +/* authentication reason + set passcode view */ +"Change self-destruct passcode" = "Vaihda itsetuhoutuva pääsykoodi"; + +/* chat item text */ +"changed address for you" = "muuttunut osoite sinulle"; + +/* rcv group event chat item */ +"changed role of %@ to %@" = "%1$@:n roolin muuttui %2$@:ksi"; + +/* rcv group event chat item */ +"changed your role to %@" = "roolisi muuttui %@:ksi"; + +/* chat item text */ +"changing address for %@…" = "osoitteen muuttaminen %@:lle…"; + +/* chat item text */ +"changing address…" = "muuttamassa osoitetta…"; + +/* No comment provided by engineer. */ +"Chat archive" = "Chat-arkisto"; + +/* No comment provided by engineer. */ +"Chat console" = "Chat-konsoli"; + +/* No comment provided by engineer. */ +"Chat database" = "Chat-tietokanta"; + +/* No comment provided by engineer. */ +"Chat database deleted" = "Chat-tietokanta poistettu"; + +/* No comment provided by engineer. */ +"Chat database imported" = "Chat-tietokanta tuotu"; + +/* No comment provided by engineer. */ +"Chat is running" = "Chat on käynnissä"; + +/* No comment provided by engineer. */ +"Chat is stopped" = "Chat on pysäytetty"; + +/* No comment provided by engineer. */ +"Chat preferences" = "Chat-asetukset"; + +/* No comment provided by engineer. */ +"Chats" = "Keskustelut"; + +/* No comment provided by engineer. */ +"Check server address and try again." = "Tarkista palvelimen osoite ja yritä uudelleen."; + +/* No comment provided by engineer. */ +"Chinese and Spanish interface" = "Kiinalainen ja espanjalainen käyttöliittymä"; + +/* No comment provided by engineer. */ +"Choose file" = "Valitse tiedosto"; + +/* No comment provided by engineer. */ +"Choose from library" = "Valitse kirjastosta"; + +/* No comment provided by engineer. */ +"Clear" = "Tyhjennä"; + +/* No comment provided by engineer. */ +"Clear conversation" = "Tyhjennä keskustelu"; + +/* No comment provided by engineer. */ +"Clear conversation?" = "Tyhjennä keskustelu?"; + +/* No comment provided by engineer. */ +"Clear verification" = "Tyhjennä vahvistus"; + +/* No comment provided by engineer. */ +"colored" = "värillinen"; + +/* No comment provided by engineer. */ +"Colors" = "Värit"; + +/* server test step */ +"Compare file" = "Vertaa tiedostoa"; + +/* No comment provided by engineer. */ +"Compare security codes with your contacts." = "Vertaa turvakoodeja kontaktiesi kanssa."; + +/* No comment provided by engineer. */ +"complete" = "valmis"; + +/* No comment provided by engineer. */ +"Configure ICE servers" = "Määritä ICE-palvelimet"; + +/* No comment provided by engineer. */ +"Confirm" = "Vahvista"; + +/* No comment provided by engineer. */ +"Confirm database upgrades" = "Vahvista tietokannan päivitykset"; + +/* No comment provided by engineer. */ +"Confirm new passphrase…" = "Vahvista uusi tunnuslause…"; + +/* No comment provided by engineer. */ +"Confirm Passcode" = "Vahvista pääsykoodi"; + +/* No comment provided by engineer. */ +"Confirm password" = "Vahvista salasana"; + +/* 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"; + +/* No comment provided by engineer. */ +"Connect via link / QR code" = "Yhdistä linkillä / QR-koodilla"; + +/* No comment provided by engineer. */ +"Connect via one-time link" = "Yhdistä kertalinkillä"; + +/* No comment provided by engineer. */ +"connected" = "yhdistetty"; + +/* No comment provided by engineer. */ +"connecting" = "yhdistää"; + +/* No comment provided by engineer. */ +"connecting (accepted)" = "yhdistäminen (hyväksytty)"; + +/* No comment provided by engineer. */ +"connecting (announced)" = "yhdistäminen (ilmoitettu)"; + +/* No comment provided by engineer. */ +"connecting (introduced)" = "yhdistäminen (esitelty)"; + +/* No comment provided by engineer. */ +"connecting (introduction invitation)" = "yhdistäminen (esittelykutsu)"; + +/* call status */ +"connecting call" = "yhdistää puhelun…"; + +/* No comment provided by engineer. */ +"Connecting server…" = "Yhteyden muodostaminen palvelimeen…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "Yhteyden muodostaminen palvelimeen... (virhe: %@)"; + +/* chat list item title */ +"connecting…" = "yhdistää…"; + +/* No comment provided by engineer. */ +"Connection" = "Yhteys"; + +/* No comment provided by engineer. */ +"Connection error" = "Yhteysvirhe"; + +/* No comment provided by engineer. */ +"Connection error (AUTH)" = "Yhteysvirhe (AUTH)"; + +/* chat list item title (it should not be shown */ +"connection established" = "yhteys luotu"; + +/* No comment provided by engineer. */ +"Connection request sent!" = "Yhteyspyyntö lähetetty!"; + +/* No comment provided by engineer. */ +"Connection timeout" = "Yhteyden aikakatkaisu"; + +/* connection information */ +"connection:%@" = "yhteys:%@"; + +/* No comment provided by engineer. */ +"Contact allows" = "Kontakti sallii"; + +/* 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"; + +/* No comment provided by engineer. */ +"contact has no e2e encryption" = "kontaktilla ei ole e2e-salausta"; + +/* notification */ +"Contact hidden:" = "Kontakti piilotettu:"; + +/* notification */ +"Contact is connected" = "Kontakti on yhdistetty"; + +/* No comment provided by engineer. */ +"Contact is not connected yet!" = "Kontaktia ei ole vielä yhdistetty!"; + +/* No comment provided by engineer. */ +"Contact name" = "Kontaktin nimi"; + +/* No comment provided by engineer. */ +"Contact preferences" = "Kontaktin asetukset"; + +/* No comment provided by engineer. */ +"Contacts" = "Kontaktit"; + +/* No comment provided by engineer. */ +"Contacts can mark messages for deletion; you will be able to view them." = "Kontaktit voivat merkitä viestit poistettaviksi; voit katsella niitä."; + +/* No comment provided by engineer. */ +"Continue" = "Jatka"; + +/* chat item action */ +"Copy" = "Kopioi"; + +/* No comment provided by engineer. */ +"Core version: v%@" = "Ydinversio: v%@"; + +/* No comment provided by engineer. */ +"Create" = "Luo"; + +/* No comment provided by engineer. */ +"Create an address to let people connect with you." = "Luo osoite, jolla ihmiset voivat ottaa sinuun yhteyttä."; + +/* server test step */ +"Create file" = "Luo tiedosto"; + +/* No comment provided by engineer. */ +"Create group link" = "Luo ryhmälinkki"; + +/* No comment provided by engineer. */ +"Create link" = "Luo linkki"; + +/* No comment provided by engineer. */ +"Create one-time invitation link" = "Luo kertakutsulinkki"; + +/* server test step */ +"Create queue" = "Luo jono"; + +/* No comment provided by engineer. */ +"Create secret group" = "Luo salainen ryhmä"; + +/* No comment provided by engineer. */ +"Create SimpleX address" = "Luo SimpleX-osoite"; + +/* No comment provided by engineer. */ +"Create your profile" = "Luo profiilisi"; + +/* No comment provided by engineer. */ +"Created on %@" = "Luotu %@"; + +/* No comment provided by engineer. */ +"creator" = "luoja"; + +/* No comment provided by engineer. */ +"Current Passcode" = "Nykyinen pääsykoodi"; + +/* No comment provided by engineer. */ +"Current passphrase…" = "Nykyinen tunnuslause…"; + +/* No comment provided by engineer. */ +"Currently maximum supported file size is %@." = "Nykyinen tuettu enimmäistiedostokoko on %@."; + +/* dropdown time picker choice */ +"custom" = "mukautettu"; + +/* No comment provided by engineer. */ +"Custom time" = "Mukautettu aika"; + +/* No comment provided by engineer. */ +"Dark" = "Tumma"; + +/* No comment provided by engineer. */ +"Database downgrade" = "Tietokannan alentaminen"; + +/* No comment provided by engineer. */ +"Database encrypted!" = "Tietokanta salattu!"; + +/* No comment provided by engineer. */ +"Database encryption passphrase will be updated and stored in the keychain.\n" = "Tietokannan salaustunnuslause päivitetään ja tallennetaan avainnippuun.\n"; + +/* No comment provided by engineer. */ +"Database encryption passphrase will be updated.\n" = "Tietokannan salauksen tunnuslause päivitetään.\n"; + +/* No comment provided by engineer. */ +"Database error" = "Tietokantavirhe"; + +/* No comment provided by engineer. */ +"Database ID" = "Tietokannan tunnus"; + +/* copied message info */ +"Database ID: %d" = "Tietokannan tunnus: %d"; + +/* No comment provided by engineer. */ +"Database IDs and Transport isolation option." = "Tietokantatunnukset ja kuljetuseristysvaihtoehto."; + +/* No comment provided by engineer. */ +"Database is encrypted using a random passphrase, you can change it." = "Tietokanta on salattu satunnaisella tunnuslauseella, voit muuttaa sitä."; + +/* No comment provided by engineer. */ +"Database is encrypted using a random passphrase. Please change it before exporting." = "Tietokanta on salattu satunnaisella tunnuslauseella. Vaihda se ennen vientiä."; + +/* No comment provided by engineer. */ +"Database passphrase" = "Tietokannan tunnuslause"; + +/* No comment provided by engineer. */ +"Database passphrase & export" = "Tietokannan tunnuslause ja vienti"; + +/* No comment provided by engineer. */ +"Database passphrase is different from saved in the keychain." = "Tietokannan tunnuslause eroaa avainnippuun tallennetusta."; + +/* No comment provided by engineer. */ +"Database passphrase is required to open chat." = "Keskustelun avaamiseen tarvitaan tietokannan tunnuslause."; + +/* No comment provided by engineer. */ +"Database upgrade" = "Tietokannan päivitys"; + +/* No comment provided by engineer. */ +"database version is newer than the app, but no down migration for: %@" = "tietokantaversio on uudempi kuin sovellus, mutta ei alaspäin siirtymistä varten: %@"; + +/* No comment provided by engineer. */ +"Database will be encrypted and the passphrase stored in the keychain.\n" = "Tietokanta salataan ja tunnuslause tallennetaan avainnippuun.\n"; + +/* No comment provided by engineer. */ +"Database will be encrypted.\n" = "Tietokanta salataan.\n"; + +/* No comment provided by engineer. */ +"Database will be migrated when the app restarts" = "Tietokanta siirretään, kun sovellus käynnistyy uudelleen"; + +/* time unit */ +"days" = "päivää"; + +/* No comment provided by engineer. */ +"Decentralized" = "Hajautettu"; + +/* message decrypt error item */ +"Decryption error" = "Salauksen purkuvirhe"; + +/* pref value */ +"default (%@)" = "oletusarvo (%@)"; + +/* No comment provided by engineer. */ +"default (no)" = "oletusarvo (ei)"; + +/* No comment provided by engineer. */ +"default (yes)" = "oletusarvo (kyllä)"; + +/* chat item action */ +"Delete" = "Poista"; + +/* No comment provided by engineer. */ +"Delete address" = "Poista osoite"; + +/* No comment provided by engineer. */ +"Delete address?" = "Poista osoite?"; + +/* No comment provided by engineer. */ +"Delete after" = "Poista jälkeen"; + +/* No comment provided by engineer. */ +"Delete all files" = "Poista kaikki tiedostot"; + +/* No comment provided by engineer. */ +"Delete archive" = "Poista arkisto"; + +/* No comment provided by engineer. */ +"Delete chat archive?" = "Poista keskusteluarkisto?"; + +/* No comment provided by engineer. */ +"Delete chat profile" = "Poista keskusteluprofiili"; + +/* No comment provided by engineer. */ +"Delete chat profile?" = "Poista keskusteluprofiili?"; + +/* No comment provided by engineer. */ +"Delete connection" = "Poista yhteys"; + +/* No comment provided by engineer. */ +"Delete contact" = "Poista kontakti"; + +/* 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"; + +/* server test step */ +"Delete file" = "Poista tiedosto"; + +/* No comment provided by engineer. */ +"Delete files and media?" = "Poista tiedostot ja media?"; + +/* No comment provided by engineer. */ +"Delete files for all chat profiles" = "Poista tiedostot kaikista keskusteluprofiileista"; + +/* chat feature */ +"Delete for everyone" = "Poista kaikilta"; + +/* No comment provided by engineer. */ +"Delete for me" = "Poista minulta"; + +/* No comment provided by engineer. */ +"Delete group" = "Poista ryhmä"; + +/* No comment provided by engineer. */ +"Delete group?" = "Poista ryhmä?"; + +/* No comment provided by engineer. */ +"Delete invitation" = "Poista kutsu"; + +/* No comment provided by engineer. */ +"Delete link" = "Poista linkki"; + +/* No comment provided by engineer. */ +"Delete link?" = "Poista linkki?"; + +/* No comment provided by engineer. */ +"Delete member message?" = "Poista jäsenviesti?"; + +/* No comment provided by engineer. */ +"Delete message?" = "Poista viesti?"; + +/* No comment provided by engineer. */ +"Delete messages" = "Poista viestit"; + +/* No comment provided by engineer. */ +"Delete messages after" = "Poista viestit tämän jälkeen"; + +/* No comment provided by engineer. */ +"Delete old database" = "Poista vanha tietokanta"; + +/* No comment provided by engineer. */ +"Delete old database?" = "Poista vanha tietokanta?"; + +/* No comment provided by engineer. */ +"Delete pending connection" = "Poista vireillä oleva yhteys"; + +/* No comment provided by engineer. */ +"Delete pending connection?" = "Poistetaanko odottava yhteys?"; + +/* No comment provided by engineer. */ +"Delete profile" = "Poista profiili"; + +/* server test step */ +"Delete queue" = "Poista jono"; + +/* No comment provided by engineer. */ +"Delete user profile?" = "Poista käyttäjäprofiili?"; + +/* deleted chat item */ +"deleted" = "poistettu"; + +/* No comment provided by engineer. */ +"Deleted at" = "Poistettu klo"; + +/* copied message info */ +"Deleted at: %@" = "Poistettu klo: %@"; + +/* rcv group event chat item */ +"deleted group" = "poistettu ryhmä"; + +/* No comment provided by engineer. */ +"Delivery" = "Toimitus"; + +/* No comment provided by engineer. */ +"Delivery receipts are disabled!" = "Toimituskuittaukset poissa käytöstä!"; + +/* No comment provided by engineer. */ +"Delivery receipts!" = "Toimituskuittaukset!"; + +/* No comment provided by engineer. */ +"Description" = "Kuvaus"; + +/* No comment provided by engineer. */ +"Develop" = "Kehitä"; + +/* No comment provided by engineer. */ +"Developer tools" = "Kehittäjätyökalut"; + +/* No comment provided by engineer. */ +"Device" = "Laite"; + +/* No comment provided by engineer. */ +"Device authentication is disabled. Turning off SimpleX Lock." = "Laitteen todennus on poistettu käytöstä. SimpleX Lock kytketään pois päältä."; + +/* No comment provided by engineer. */ +"Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "Laitteen todennus ei ole käytössä. Voit ottaa SimpleX Lockin käyttöön Asetuksista, kun olet ottanut laitteen todennuksen käyttöön."; + +/* No comment provided by engineer. */ +"different migration in the app/database: %@ / %@" = "eri siirtyminen sovelluksessa/tietokannassa: %@ / %@"; + +/* No comment provided by engineer. */ +"Different names, avatars and transport isolation." = "Eri nimet, avatarit ja kuljetuseristys."; + +/* connection level description */ +"direct" = "suora"; + +/* chat feature */ +"Direct messages" = "Yksityisviestit"; + +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this group." = "Yksityisviestit jäsenten välillä ovat kiellettyjä tässä ryhmässä."; + +/* No comment provided by engineer. */ +"Disable (keep overrides)" = "Poista käytöstä (pidä ohitukset)"; + +/* No comment provided by engineer. */ +"Disable for all" = "Poista käytöstä kaikilta"; + +/* authentication reason */ +"Disable SimpleX Lock" = "Poista SimpleX Lock käytöstä"; + +/* No comment provided by engineer. */ +"disabled" = "ei käytössä"; + +/* No comment provided by engineer. */ +"Disappearing message" = "Tuhoutuva viesti"; + +/* chat feature */ +"Disappearing messages" = "Tuhoutuvat viestit"; + +/* No comment provided by engineer. */ +"Disappearing messages are prohibited in this chat." = "Katoavat viestit ovat kiellettyjä tässä keskustelussa."; + +/* No comment provided by engineer. */ +"Disappearing messages are prohibited in this group." = "Katoavat viestit ovat kiellettyjä tässä ryhmässä."; + +/* No comment provided by engineer. */ +"Disappears at" = "Katoaa klo"; + +/* copied message info */ +"Disappears at: %@" = "Katoaa klo: %@"; + +/* server test step */ +"Disconnect" = "Katkaise"; + +/* No comment provided by engineer. */ +"Display name" = "Näyttönimi"; + +/* No comment provided by engineer. */ +"Display name:" = "Näyttönimi:"; + +/* No comment provided by engineer. */ +"Do it later" = "Tee myöhemmin"; + +/* No comment provided by engineer. */ +"Do NOT use SimpleX for emergency calls." = "Älä käytä SimpleX-sovellusta hätäpuheluihin."; + +/* No comment provided by engineer. */ +"Don't create address" = "Älä luo osoitetta"; + +/* No comment provided by engineer. */ +"Don't enable" = "Älä salli"; + +/* No comment provided by engineer. */ +"Don't show again" = "Älä näytä uudelleen"; + +/* No comment provided by engineer. */ +"Downgrade and open chat" = "Alenna ja avaa keskustelu"; + +/* server test step */ +"Download file" = "Lataa tiedosto"; + +/* No comment provided by engineer. */ +"Duplicate display name!" = "Päällekkäinen näyttönimi!"; + +/* integrity error chat item */ +"duplicate message" = "päällekkäinen viesti"; + +/* No comment provided by engineer. */ +"Duration" = "Kesto"; + +/* No comment provided by engineer. */ +"e2e encrypted" = "e2e-salattu"; + +/* chat item action */ +"Edit" = "Muokkaa"; + +/* No comment provided by engineer. */ +"Edit group profile" = "Muokkaa ryhmäprofiilia"; + +/* No comment provided by engineer. */ +"Enable" = "Salli"; + +/* No comment provided by engineer. */ +"Enable (keep overrides)" = "Salli (pidä ohitukset)"; + +/* No comment provided by engineer. */ +"Enable automatic message deletion?" = "Ota automaattinen viestien poisto käyttöön?"; + +/* No comment provided by engineer. */ +"Enable for all" = "Salli kaikille"; + +/* No comment provided by engineer. */ +"Enable instant notifications?" = "Salli välittömät ilmoitukset?"; + +/* No comment provided by engineer. */ +"Enable lock" = "Ota lukitus käyttöön"; + +/* No comment provided by engineer. */ +"Enable notifications" = "Salli ilmoitukset"; + +/* No comment provided by engineer. */ +"Enable periodic notifications?" = "Salli säännölliset ilmoitukset?"; + +/* No comment provided by engineer. */ +"Enable self-destruct" = "Ota itsetuho käyttöön"; + +/* set passcode view */ +"Enable self-destruct passcode" = "Ota itsetuhoava pääsykoodi käyttöön"; + +/* authentication reason */ +"Enable SimpleX Lock" = "Ota SimpleX Lock käyttöön"; + +/* No comment provided by engineer. */ +"Enable TCP keep-alive" = "Ota TCP-säilytys käyttöön"; + +/* enabled status */ +"enabled" = "käytössä"; + +/* enabled status */ +"enabled for contact" = "käytössä kontaktille"; + +/* enabled status */ +"enabled for you" = "käytössä sinulle"; + +/* No comment provided by engineer. */ +"Encrypt" = "Salaa"; + +/* No comment provided by engineer. */ +"Encrypt database?" = "Salaa tietokanta?"; + +/* No comment provided by engineer. */ +"Encrypted database" = "Salattu tietokanta"; + +/* notification */ +"Encrypted message or another event" = "Salattu viesti tai muu tapahtuma"; + +/* notification */ +"Encrypted message: database error" = "Salattu viesti: tietokantavirhe"; + +/* notification */ +"Encrypted message: database migration error" = "Salattu viesti: tietokannan siirtovirhe"; + +/* notification */ +"Encrypted message: keychain error" = "Salattu viesti: avainnipun virhe"; + +/* notification */ +"Encrypted message: no passphrase" = "Salattu viesti: ei tunnuslausetta"; + +/* notification */ +"Encrypted message: unexpected error" = "Salattu viesti: odottamaton virhe"; + +/* chat item text */ +"encryption agreed" = "salaus sovittu"; + +/* chat item text */ +"encryption agreed for %@" = "salaus sovittu %@:lle"; + +/* chat item text */ +"encryption ok" = "salaus ok"; + +/* chat item text */ +"encryption ok for %@" = "salaus ok %@:lle"; + +/* chat item text */ +"encryption re-negotiation allowed" = "salauksen uudelleenneuvottelu sallittu"; + +/* chat item text */ +"encryption re-negotiation allowed for %@" = "salauksen uudelleenneuvottelu sallittu %@:lle"; + +/* chat item text */ +"encryption re-negotiation required" = "tarvitaan salauksen uudelleenneuvottelu"; + +/* chat item text */ +"encryption re-negotiation required for %@" = "tarvitaan salauksen uudelleenneuvottelu %@:lle"; + +/* No comment provided by engineer. */ +"ended" = "päättyi"; + +/* call status */ +"ended call %@" = "puhelu päättyi %@:lle"; + +/* No comment provided by engineer. */ +"Enter correct passphrase." = "Anna oikea tunnuslause."; + +/* No comment provided by engineer. */ +"Enter Passcode" = "Syötä pääsykoodi"; + +/* No comment provided by engineer. */ +"Enter passphrase…" = "Syötä tunnuslause…"; + +/* No comment provided by engineer. */ +"Enter password above to show!" = "Kirjoita yllä oleva salasana näyttääksesi!"; + +/* No comment provided by engineer. */ +"Enter server manually" = "Syötä palvelin manuaalisesti"; + +/* placeholder */ +"Enter welcome message…" = "Kirjoita tervetuloviesti…"; + +/* placeholder */ +"Enter welcome message… (optional)" = "Kirjoita tervetuloviesti... (valinnainen)"; + +/* No comment provided by engineer. */ +"error" = "virhe"; + +/* No comment provided by engineer. */ +"Error" = "Virhe"; + +/* No comment provided by engineer. */ +"Error aborting address change" = "Virhe osoitteenmuutoksen keskeytyksessä"; + +/* No comment provided by engineer. */ +"Error accepting contact request" = "Virhe kontaktipyynnön hyväksymisessä"; + +/* No comment provided by engineer. */ +"Error accessing database file" = "Virhe tietokantatiedoston käyttämisessä"; + +/* No comment provided by engineer. */ +"Error adding member(s)" = "Virhe lisättäessä jäseniä"; + +/* No comment provided by engineer. */ +"Error changing address" = "Virhe osoitteenvaihdossa"; + +/* No comment provided by engineer. */ +"Error changing role" = "Virhe roolin vaihdossa"; + +/* No comment provided by engineer. */ +"Error changing setting" = "Virhe asetuksen muuttamisessa"; + +/* No comment provided by engineer. */ +"Error creating address" = "Virhe osoitteen luomisessa"; + +/* No comment provided by engineer. */ +"Error creating group" = "Virhe ryhmän luomisessa"; + +/* No comment provided by engineer. */ +"Error creating group link" = "Virhe ryhmälinkin luomisessa"; + +/* No comment provided by engineer. */ +"Error creating profile!" = "Virhe profiilin luomisessa!"; + +/* No comment provided by engineer. */ +"Error deleting chat database" = "Virhe keskustelujen tietokannan poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting chat!" = "Virhe keskutelun poistamisessa!"; + +/* No comment provided by engineer. */ +"Error deleting connection" = "Virhe yhteyden poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting contact" = "Virhe kontaktin poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting database" = "Virhe tietokannan poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting old database" = "Virhe vanhan tietokannan poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting token" = "Virhe tokenin poistamisessa"; + +/* No comment provided by engineer. */ +"Error deleting user profile" = "Virhe käyttäjäprofiilin poistamisessa"; + +/* No comment provided by engineer. */ +"Error enabling delivery receipts!" = "Virhe toimituskuittauksien sallimisessa!"; + +/* No comment provided by engineer. */ +"Error enabling notifications" = "Virhe ilmoitusten käyttöönotossa"; + +/* No comment provided by engineer. */ +"Error encrypting database" = "Virhe tietokannan salauksessa"; + +/* No comment provided by engineer. */ +"Error exporting chat database" = "Virhe vietäessä keskustelujen tietokantaa"; + +/* No comment provided by engineer. */ +"Error importing chat database" = "Virhe keskustelujen tietokannan tuonnissa"; + +/* No comment provided by engineer. */ +"Error joining group" = "Virhe ryhmään liittymisessä"; + +/* No comment provided by engineer. */ +"Error loading %@ servers" = "Virhe %@-palvelimien lataamisessa"; + +/* No comment provided by engineer. */ +"Error receiving file" = "Virhe tiedoston vastaanottamisessa"; + +/* No comment provided by engineer. */ +"Error removing member" = "Virhe poistettaessa jäsentä"; + +/* No comment provided by engineer. */ +"Error saving %@ servers" = "Virhe %@ palvelimien tallentamisessa"; + +/* No comment provided by engineer. */ +"Error saving group profile" = "Virhe ryhmäprofiilin tallentamisessa"; + +/* No comment provided by engineer. */ +"Error saving ICE servers" = "Virhe ICE-palvelimien tallentamisessa"; + +/* No comment provided by engineer. */ +"Error saving passcode" = "Virhe pääsykoodin tallentamisessa"; + +/* No comment provided by engineer. */ +"Error saving passphrase to keychain" = "Virhe tunnuslauseen tallentamisessa avainnippuun"; + +/* No comment provided by engineer. */ +"Error saving user password" = "Virhe käyttäjän salasanan tallentamisessa"; + +/* No comment provided by engineer. */ +"Error sending email" = "Virhe sähköpostin lähettämisessä"; + +/* No comment provided by engineer. */ +"Error sending message" = "Virhe viestin lähettämisessä"; + +/* No comment provided by engineer. */ +"Error setting delivery receipts!" = "Virhe toimituskuittauksien asettamisessa!"; + +/* No comment provided by engineer. */ +"Error starting chat" = "Virhe käynnistettäessä keskustelua"; + +/* No comment provided by engineer. */ +"Error stopping chat" = "Virhe keskustelun lopettamisessa"; + +/* No comment provided by engineer. */ +"Error switching profile!" = "Virhe profiilin vaihdossa!"; + +/* No comment provided by engineer. */ +"Error synchronizing connection" = "Virhe yhteyden synkronoinnissa"; + +/* No comment provided by engineer. */ +"Error updating group link" = "Virhe ryhmälinkin päivittämisessä"; + +/* No comment provided by engineer. */ +"Error updating message" = "Virhe viestin päivityksessä"; + +/* No comment provided by engineer. */ +"Error updating settings" = "Virhe asetusten päivittämisessä"; + +/* No comment provided by engineer. */ +"Error updating user privacy" = "Virhe päivitettäessä käyttäjän tietosuojaa"; + +/* No comment provided by engineer. */ +"Error: " = "Virhe: "; + +/* No comment provided by engineer. */ +"Error: %@" = "Virhe: %@"; + +/* No comment provided by engineer. */ +"Error: no database file" = "Virhe: ei tietokantatiedostoa"; + +/* No comment provided by engineer. */ +"Error: URL is invalid" = "Virhe: URL on virheellinen"; + +/* No comment provided by engineer. */ +"Even when disabled in the conversation." = "Jopa kun ei käytössä keskustelussa."; + +/* No comment provided by engineer. */ +"event happened" = "tapahtuma tapahtui"; + +/* No comment provided by engineer. */ +"Exit without saving" = "Poistu tallentamatta"; + +/* No comment provided by engineer. */ +"Export database" = "Vie tietokanta"; + +/* No comment provided by engineer. */ +"Export error:" = "Vientivirhe:"; + +/* No comment provided by engineer. */ +"Exported database archive." = "Viety tietokanta-arkisto."; + +/* No comment provided by engineer. */ +"Exporting database archive…" = "Tietokanta-arkiston vienti…"; + +/* No comment provided by engineer. */ +"Failed to remove passphrase" = "Tunnuslauseen poisto epäonnistui"; + +/* No comment provided by engineer. */ +"Fast and no wait until the sender is online!" = "Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa!"; + +/* No comment provided by engineer. */ +"Favorite" = "Suosikki"; + +/* No comment provided by engineer. */ +"File will be deleted from servers." = "Tiedosto poistetaan palvelimilta."; + +/* No comment provided by engineer. */ +"File will be received when your contact completes uploading it." = "Tiedosto vastaanotetaan, kun kontaktisi on ladannut sen."; + +/* No comment provided by engineer. */ +"File will be received when your contact is online, please wait or check later!" = "Tiedosto vastaanotetaan, kun kontakti on online-tilassa, odota tai tarkista myöhemmin!"; + +/* No comment provided by engineer. */ +"File: %@" = "Tiedosto: %@"; + +/* No comment provided by engineer. */ +"Files & media" = "Tiedostot & media"; + +/* chat feature */ +"Files and media" = "Tiedostot ja media"; + +/* No comment provided by engineer. */ +"Files and media are prohibited in this group." = "Tiedostot ja media ovat tässä ryhmässä kiellettyjä."; + +/* No comment provided by engineer. */ +"Files and media prohibited!" = "Tiedostot ja media kielletty!"; + +/* No comment provided by engineer. */ +"Filter unread and favorite chats." = "Suodata lukemattomia- ja suosikkikeskusteluja."; + +/* No comment provided by engineer. */ +"Finally, we have them! 🚀" = "Vihdoinkin meillä! 🚀"; + +/* No comment provided by engineer. */ +"Find chats faster" = "Löydä keskustelut nopeammin"; + +/* No comment provided by engineer. */ +"Fix" = "Korjaa"; + +/* No comment provided by engineer. */ +"Fix connection" = "Korjaa yhteys"; + +/* No comment provided by engineer. */ +"Fix connection?" = "Korjaa yhteys?"; + +/* No comment provided by engineer. */ +"Fix encryption after restoring backups." = "Korjaa salaus varmuuskopioiden palauttamisen jälkeen."; + +/* No comment provided by engineer. */ +"Fix not supported by contact" = "Kontakti ei tue korjausta"; + +/* No comment provided by engineer. */ +"Fix not supported by group member" = "Ryhmän jäsen ei tue korjausta"; + +/* No comment provided by engineer. */ +"For console" = "Konsoliin"; + +/* No comment provided by engineer. */ +"French interface" = "Ranskalainen käyttöliittymä"; + +/* No comment provided by engineer. */ +"Full link" = "Koko linkki"; + +/* No comment provided by engineer. */ +"Full name (optional)" = "Koko nimi (valinnainen)"; + +/* No comment provided by engineer. */ +"Full name:" = "Koko nimi:"; + +/* No comment provided by engineer. */ +"Fully re-implemented - work in background!" = "Täysin uudistettu - toimii taustalla!"; + +/* No comment provided by engineer. */ +"Further reduced battery usage" = "Entistä pienempi akun käyttö"; + +/* No comment provided by engineer. */ +"GIFs and stickers" = "GIFit ja tarrat"; + +/* No comment provided by engineer. */ +"Group" = "Ryhmä"; + +/* No comment provided by engineer. */ +"group deleted" = "ryhmä poistettu"; + +/* No comment provided by engineer. */ +"Group display name" = "Ryhmän näyttönimi"; + +/* No comment provided by engineer. */ +"Group full name (optional)" = "Ryhmän näyttönimi (valinnainen)"; + +/* No comment provided by engineer. */ +"Group image" = "Ryhmäkuva"; + +/* No comment provided by engineer. */ +"Group invitation" = "Ryhmän kutsu"; + +/* No comment provided by engineer. */ +"Group invitation expired" = "Vanhentunut ryhmäkutsu"; + +/* No comment provided by engineer. */ +"Group invitation is no longer valid, it was removed by sender." = "Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen."; + +/* No comment provided by engineer. */ +"Group link" = "Ryhmälinkki"; + +/* No comment provided by engineer. */ +"Group links" = "Ryhmälinkit"; + +/* No comment provided by engineer. */ +"Group members can add message reactions." = "Ryhmän jäsenet voivat lisätä viestireaktioita."; + +/* No comment provided by engineer. */ +"Group members can irreversibly delete sent messages." = "Ryhmän jäsenet voivat poistaa lähetetyt viestit peruuttamattomasti."; + +/* No comment provided by engineer. */ +"Group members can send direct messages." = "Ryhmän jäsenet voivat lähettää suoraviestejä."; + +/* No comment provided by engineer. */ +"Group members can send disappearing messages." = "Ryhmän jäsenet voivat lähettää katoavia viestejä."; + +/* No comment provided by engineer. */ +"Group members can send files and media." = "Ryhmän jäsenet voivat lähettää tiedostoja ja mediaa."; + +/* No comment provided by engineer. */ +"Group members can send voice messages." = "Ryhmän jäsenet voivat lähettää ääniviestejä."; + +/* notification */ +"Group message:" = "Ryhmäviesti:"; + +/* No comment provided by engineer. */ +"Group moderation" = "Ryhmän moderointi"; + +/* No comment provided by engineer. */ +"Group preferences" = "Ryhmän asetukset"; + +/* No comment provided by engineer. */ +"Group profile" = "Ryhmäprofiili"; + +/* No comment provided by engineer. */ +"Group profile is stored on members' devices, not on the servers." = "Ryhmäprofiili tallennetaan jäsenten laitteille, ei palvelimille."; + +/* snd group event chat item */ +"group profile updated" = "ryhmäprofiili päivitetty"; + +/* No comment provided by engineer. */ +"Group welcome message" = "Ryhmän tervetuloviesti"; + +/* No comment provided by engineer. */ +"Group will be deleted for all members - this cannot be undone!" = "Ryhmä poistetaan kaikilta jäseniltä - tätä ei voi kumota!"; + +/* No comment provided by engineer. */ +"Group will be deleted for you - this cannot be undone!" = "Ryhmä poistetaan sinulta - tätä ei voi perua!"; + +/* No comment provided by engineer. */ +"Help" = "Apua"; + +/* No comment provided by engineer. */ +"Hidden" = "Piilotettu"; + +/* No comment provided by engineer. */ +"Hidden chat profiles" = "Piilotetut keskusteluprofiilit"; + +/* No comment provided by engineer. */ +"Hidden profile password" = "Piilotettu profiilin salasana"; + +/* chat item action */ +"Hide" = "Piilota"; + +/* No comment provided by engineer. */ +"Hide app screen in the recent apps." = "Piilota sovellusnäyttö viimeisimmissä sovelluksissa."; + +/* No comment provided by engineer. */ +"Hide profile" = "Piilota profiili"; + +/* No comment provided by engineer. */ +"Hide:" = "Piilota:"; + +/* No comment provided by engineer. */ +"History" = "Historia"; + +/* time unit */ +"hours" = "tuntia"; + +/* No comment provided by engineer. */ +"How it works" = "Kuinka se toimii"; + +/* No comment provided by engineer. */ +"How SimpleX works" = "Miten SimpleX toimii"; + +/* No comment provided by engineer. */ +"How to" = "Miten"; + +/* No comment provided by engineer. */ +"How to use it" = "Kuinka sitä käytetään"; + +/* No comment provided by engineer. */ +"How to use your servers" = "Miten käytät palvelimiasi"; + +/* No comment provided by engineer. */ +"ICE servers (one per line)" = "ICE-palvelimet (yksi per rivi)"; + +/* No comment provided by engineer. */ +"If you can't meet in person, show QR code in a video call, or share the link." = "Jos et voi tavata henkilökohtaisesti, näytä QR-koodi videopuhelussa tai jaa linkki."; + +/* No comment provided by engineer. */ +"If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link." = "Jos et voi tavata henkilökohtaisesti, voit **skannata QR-koodin videopuhelussa** tai kontaktisi voi jakaa kutsulinkin."; + +/* No comment provided by engineer. */ +"If you enter this passcode when opening the app, all app data will be irreversibly removed!" = "Jos syötät tämän pääsykoodin sovellusta avatessasi, kaikki sovelluksen tiedot poistetaan peruuttamattomasti!"; + +/* No comment provided by engineer. */ +"If you enter your self-destruct passcode while opening the app:" = "Jos syötät itsetuhoutuvan pääsykoodin sovellusta avattaessa:"; + +/* No comment provided by engineer. */ +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Jos haluat käyttää keskustelua nyt, napauta **Tee se myöhemmin** alla (sinulle tarjotaan tietokannan siirtämistä, kun käynnistät sovelluksen uudelleen)."; + +/* No comment provided by engineer. */ +"Ignore" = "Sivuuta"; + +/* No comment provided by engineer. */ +"Image will be received when your contact completes uploading it." = "Kuva vastaanotetaan, kun kontaktisi on ladannut sen."; + +/* No comment provided by engineer. */ +"Image will be received when your contact is online, please wait or check later!" = "Kuva vastaanotetaan, kun kontaktisi on verkossa, odota tai tarkista myöhemmin!"; + +/* No comment provided by engineer. */ +"Immediately" = "Heti"; + +/* No comment provided by engineer. */ +"Immune to spam and abuse" = "Immuuni roskapostille ja väärinkäytöksille"; + +/* No comment provided by engineer. */ +"Import" = "Tuo"; + +/* No comment provided by engineer. */ +"Import chat database?" = "Tuo keskustelujen-tietokanta?"; + +/* No comment provided by engineer. */ +"Import database" = "Tuo tietokanta"; + +/* No comment provided by engineer. */ +"Improved privacy and security" = "Parannettu yksityisyys ja turvallisuus"; + +/* No comment provided by engineer. */ +"Improved server configuration" = "Parannettu palvelimen kokoonpano"; + +/* No comment provided by engineer. */ +"In reply to" = "Vastauksena"; + +/* No comment provided by engineer. */ +"Incognito" = "Incognito"; + +/* No comment provided by engineer. */ +"Incognito mode" = "Incognito-tila"; + +/* No comment provided by engineer. */ +"Incognito mode protects your privacy by using a new random profile for each contact." = "Incognito-tila suojaa yksityisyyttäsi käyttämällä uutta satunnaista profiilia jokaiselle kontaktille."; + +/* chat list item description */ +"incognito via contact address link" = "incognito kontaktilinkin kautta"; + +/* chat list item description */ +"incognito via group link" = "incognito ryhmälinkin kautta"; + +/* chat list item description */ +"incognito via one-time link" = "incognito kertalinkillä"; + +/* notification */ +"Incoming audio call" = "Saapuva äänipuhelu"; + +/* notification */ +"Incoming call" = "Saapuva puhelu"; + +/* notification */ +"Incoming video call" = "Saapuva videopuhelu"; + +/* No comment provided by engineer. */ +"Incompatible database version" = "Yhteensopimaton tietokantaversio"; + +/* PIN entry */ +"Incorrect passcode" = "Väärä pääsykoodi"; + +/* No comment provided by engineer. */ +"Incorrect security code!" = "Väärä turvakoodi!"; + +/* connection level description */ +"indirect (%d)" = "epäsuora (%d)"; + +/* chat item action */ +"Info" = "Tiedot"; + +/* No comment provided by engineer. */ +"Initial role" = "Alkuperäinen rooli"; + +/* No comment provided by engineer. */ +"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Asenna [SimpleX Chat terminaalille](https://github.com/simplex-chat/simplex-chat)"; + +/* No comment provided by engineer. */ +"Instant push notifications will be hidden!\n" = "Välittömät push-ilmoitukset ovat piilossa!\n"; + +/* No comment provided by engineer. */ +"Instantly" = "Heti"; + +/* No comment provided by engineer. */ +"Interface" = "Käyttöliittymä"; + +/* invalid chat data */ +"invalid chat" = "virheellinen keskustelu"; + +/* No comment provided by engineer. */ +"invalid chat data" = "virheelliset keskustelu-tiedot"; + +/* No comment provided by engineer. */ +"Invalid connection link" = "Virheellinen yhteyslinkki"; + +/* invalid chat item */ +"invalid data" = "virheelliset tiedot"; + +/* No comment provided by engineer. */ +"Invalid server address!" = "Virheellinen palvelinosoite!"; + +/* item status text */ +"Invalid status" = "Virheellinen tila"; + +/* No comment provided by engineer. */ +"Invitation expired!" = "Vanhentunut kutsu!"; + +/* group name */ +"invitation to group %@" = "kutsu ryhmään %@"; + +/* No comment provided by engineer. */ +"Invite friends" = "Kutsu ystäviä"; + +/* No comment provided by engineer. */ +"Invite members" = "Kutsu jäseniä"; + +/* No comment provided by engineer. */ +"Invite to group" = "Kutsu ryhmään"; + +/* No comment provided by engineer. */ +"invited" = "kutsuttu"; + +/* rcv group event chat item */ +"invited %@" = "kutsuttu %@"; + +/* chat list item title */ +"invited to connect" = "kutsuttu yhteydenpitoon"; + +/* rcv group event chat item */ +"invited via your group link" = "kutsuttu ryhmäsi linkin kautta"; + +/* No comment provided by engineer. */ +"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "iOS-Avainnippua käytetään tunnuslauseen turvalliseen tallentamiseen - se mahdollistaa push-ilmoitusten vastaanottamisen."; + +/* No comment provided by engineer. */ +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "iOS-Avainnippua käytetään tunnuslauseen turvalliseen tallentamiseen sen muuttamisen tai sovelluksen uudelleen käynnistämisen jälkeen - se mahdollistaa push-ilmoitusten vastaanottamisen."; + +/* No comment provided by engineer. */ +"Irreversible message deletion" = "Peruuttamaton viestin poisto"; + +/* No comment provided by engineer. */ +"Irreversible message deletion is prohibited in this chat." = "Viestien peruuttamaton poisto on kielletty tässä keskustelussa."; + +/* No comment provided by engineer. */ +"Irreversible message deletion is prohibited in this group." = "Viestien peruuttamaton poisto on kielletty tässä ryhmässä."; + +/* No comment provided by engineer. */ +"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Se mahdollistaa useiden nimettömien yhteyksien muodostamisen yhdessä keskusteluprofiilissa ilman, että niiden välillä on jaettuja tietoja."; + +/* No comment provided by engineer. */ +"It can happen when you or your connection used the old database backup." = "Se voi tapahtua, kun sinä tai kontaktisi käytitte vanhaa varmuuskopiota tietokannasta."; + +/* 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." = "Se voi tapahtua, kun:\n1. Viestit vanhenivat lähettävässä päätelaitteessa kahden päivän päästä tai palvelimella 30 päivän kuluttua.\n2. Viestin salauksen purku epäonnistui, koska sinä tai kontaktisi käytitte vanhaa varmuuskopiota tietokannasta.\n3. Yhteys vaarantui."; + +/* 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 (%@)." = "Näyttäisi, että olet jo yhteydessä tämän linkin kautta. Jos näin ei ole, tapahtui virhe (%@)."; + +/* No comment provided by engineer. */ +"Italian interface" = "Italialainen käyttöliittymä"; + +/* No comment provided by engineer. */ +"italic" = "kursivoitu"; + +/* No comment provided by engineer. */ +"Japanese interface" = "Japanilainen käyttöliittymä"; + +/* No comment provided by engineer. */ +"Join" = "Liity"; + +/* No comment provided by engineer. */ +"join as %@" = "Liity %@:nä"; + +/* No comment provided by engineer. */ +"Join group" = "Liity ryhmään"; + +/* No comment provided by engineer. */ +"Join incognito" = "Liity incognito-tilassa"; + +/* No comment provided by engineer. */ +"Joining group" = "Liittyy ryhmään"; + +/* No comment provided by engineer. */ +"Keep your connections" = "Pidä kontaktisi"; + +/* No comment provided by engineer. */ +"Keychain error" = "Avainnipun virhe"; + +/* No comment provided by engineer. */ +"KeyChain error" = "Avainnipun virhe"; + +/* No comment provided by engineer. */ +"Large file!" = "Suuri tiedosto!"; + +/* No comment provided by engineer. */ +"Learn more" = "Lue lisää"; + +/* No comment provided by engineer. */ +"Leave" = "Poistu"; + +/* No comment provided by engineer. */ +"Leave group" = "Poistu ryhmästä"; + +/* No comment provided by engineer. */ +"Leave group?" = "Poistu ryhmästä?"; + +/* rcv group event chat item */ +"left" = "poistunut"; + +/* email subject */ +"Let's talk in SimpleX Chat" = "Jutellaan SimpleX Chatissa"; + +/* No comment provided by engineer. */ +"Light" = "Vaalea"; + +/* No comment provided by engineer. */ +"Limitations" = "Rajoitukset"; + +/* No comment provided by engineer. */ +"LIVE" = "LIVE"; + +/* No comment provided by engineer. */ +"Live message!" = "Live-viesti!"; + +/* No comment provided by engineer. */ +"Live messages" = "Live-viestit"; + +/* No comment provided by engineer. */ +"Local name" = "Paikallinen nimi"; + +/* No comment provided by engineer. */ +"Local profile data only" = "Vain paikalliset profiilitiedot"; + +/* No comment provided by engineer. */ +"Lock after" = "Lukitse jälkeen"; + +/* No comment provided by engineer. */ +"Lock mode" = "Lukitustila"; + +/* No comment provided by engineer. */ +"Make a private connection" = "Luo yksityinen yhteys"; + +/* No comment provided by engineer. */ +"Make one message disappear" = "Hävitä yksi viesti"; + +/* No comment provided by engineer. */ +"Make profile private!" = "Tee profiilista yksityinen!"; + +/* No comment provided by engineer. */ +"Make sure %@ server addresses are in correct format, line separated and are not duplicated (%@)." = "Varmista, että %@-palvelinosoitteet ovat oikeassa muodossa, että ne on erotettu toisistaan riveittäin ja että ne eivät ole päällekkäisiä (%@)."; + +/* No comment provided by engineer. */ +"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Varmista, että WebRTC ICE -palvelinosoitteet ovat oikeassa muodossa, rivieroteltuina ja että ne eivät ole päällekkäisiä."; + +/* No comment provided by engineer. */ +"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Monet ihmiset kysyivät: *Jos SimpleX:llä ei ole käyttäjätunnuksia, miten se voi toimittaa viestejä?*"; + +/* No comment provided by engineer. */ +"Mark deleted for everyone" = "Merkitse poistetuksi kaikilta"; + +/* No comment provided by engineer. */ +"Mark read" = "Merkitse luetuksi"; + +/* No comment provided by engineer. */ +"Mark verified" = "Merkitse vahvistetuksi"; + +/* No comment provided by engineer. */ +"Markdown in messages" = "Markdown viesteissä"; + +/* marked deleted chat item preview text */ +"marked deleted" = "merkitty poistetuksi"; + +/* No comment provided by engineer. */ +"Max 30 seconds, received instantly." = "Enintään 30 sekuntia, vastaanotetaan välittömästi."; + +/* member role */ +"member" = "jäsen"; + +/* No comment provided by engineer. */ +"Member" = "Jäsen"; + +/* rcv group event chat item */ +"member connected" = "yhdistetty"; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All group members will be notified." = "Jäsenen rooli muuttuu muotoon \"%@\". Kaikille ryhmän jäsenille ilmoitetaan asiasta."; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". The member will receive a new invitation." = "Jäsenen rooli muutetaan muotoon \"%@\". Jäsen saa uuden kutsun."; + +/* No comment provided by engineer. */ +"Member will be removed from group - this cannot be undone!" = "Jäsen poistetaan ryhmästä - tätä ei voi perua!"; + +/* item status text */ +"Message delivery error" = "Viestin toimitusvirhe"; + +/* No comment provided by engineer. */ +"Message delivery receipts!" = "Viestien toimituskuittaukset!"; + +/* No comment provided by engineer. */ +"Message draft" = "Viestiluonnos"; + +/* chat feature */ +"Message reactions" = "Viestireaktiot"; + +/* No comment provided by engineer. */ +"Message reactions are prohibited in this chat." = "Viestireaktiot ovat kiellettyjä tässä keskustelussa."; + +/* No comment provided by engineer. */ +"Message reactions are prohibited in this group." = "Viestireaktiot ovat kiellettyjä tässä ryhmässä."; + +/* notification */ +"message received" = "viesti vastaanotettu"; + +/* No comment provided by engineer. */ +"Message text" = "Viestin teksti"; + +/* No comment provided by engineer. */ +"Messages" = "Viestit"; + +/* No comment provided by engineer. */ +"Messages & files" = "Viestit ja tiedostot"; + +/* No comment provided by engineer. */ +"Migrating database archive…" = "Siirretään tietokannan arkistoa…"; + +/* No comment provided by engineer. */ +"Migration error:" = "Siirtovirhe:"; + +/* No comment provided by engineer. */ +"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Siirto epäonnistui. Jatka nykyisen tietokannan käyttöä napauttamalla alla **Poistu**. Ilmoita ongelmasta sovelluskehittäjille keskustelussa tai sähköpostitse [chat@simplex.chat](mailto:chat@simplex.chat)."; + +/* No comment provided by engineer. */ +"Migration is completed" = "Siirto on valmis"; + +/* No comment provided by engineer. */ +"Migrations: %@" = "Siirrot: %@"; + +/* time unit */ +"minutes" = "minuuttia"; + +/* call status */ +"missed call" = "vastaamaton puhelu"; + +/* chat item action */ +"Moderate" = "Moderoi"; + +/* moderated chat item */ +"moderated" = "moderoitu"; + +/* No comment provided by engineer. */ +"Moderated at" = "Moderoitu klo"; + +/* copied message info */ +"Moderated at: %@" = "Moderoitu klo: %@"; + +/* No comment provided by engineer. */ +"moderated by %@" = "%@ moderoi"; + +/* time unit */ +"months" = "kuukautta"; + +/* No comment provided by engineer. */ +"More improvements are coming soon!" = "Lisää parannuksia on tulossa pian!"; + +/* item status description */ +"Most likely this connection is deleted." = "Todennäköisesti tämä yhteys on poistettu."; + +/* No comment provided by engineer. */ +"Most likely this contact has deleted the connection with you." = "Todennäköisesti tämä kontakti on poistanut yhteyden sinuun."; + +/* No comment provided by engineer. */ +"Multiple chat profiles" = "Useita keskusteluprofiileja"; + +/* No comment provided by engineer. */ +"Mute" = "Mykistä"; + +/* No comment provided by engineer. */ +"Muted when inactive!" = "Mykistetty ei-aktiivisena!"; + +/* No comment provided by engineer. */ +"Name" = "Nimi"; + +/* No comment provided by engineer. */ +"Network & servers" = "Verkko ja palvelimet"; + +/* No comment provided by engineer. */ +"Network settings" = "Verkkoasetukset"; + +/* No comment provided by engineer. */ +"Network status" = "Verkon tila"; + +/* No comment provided by engineer. */ +"never" = "ei koskaan"; + +/* notification */ +"New contact request" = "Uusi kontaktipyyntö"; + +/* notification */ +"New contact:" = "Uusi kontakti:"; + +/* No comment provided by engineer. */ +"New database archive" = "Uusi tietokanta-arkisto"; + +/* No comment provided by engineer. */ +"New display name" = "Uusi näyttönimi"; + +/* No comment provided by engineer. */ +"New in %@" = "Uutta %@"; + +/* No comment provided by engineer. */ +"New member role" = "Uusi jäsenrooli"; + +/* notification */ +"new message" = "uusi viesti"; + +/* notification */ +"New message" = "Uusi viesti"; + +/* No comment provided by engineer. */ +"New Passcode" = "Uusi pääsykoodi"; + +/* No comment provided by engineer. */ +"New passphrase…" = "Uusi tunnuslause…"; + +/* pref value */ +"no" = "ei"; + +/* No comment provided by engineer. */ +"No" = "Ei"; + +/* Authentication unavailable */ +"No app password" = "Ei sovelluksen salasanaa"; + +/* No comment provided by engineer. */ +"No contacts selected" = "Kontakteja ei ole valittu"; + +/* No comment provided by engineer. */ +"No contacts to add" = "Ei lisättäviä kontakteja"; + +/* No comment provided by engineer. */ +"No delivery information" = "Ei toimitustietoja"; + +/* No comment provided by engineer. */ +"No device token!" = "Ei laitetunnusta!"; + +/* No comment provided by engineer. */ +"no e2e encryption" = "ei e2e-salausta"; + +/* No comment provided by engineer. */ +"No filtered chats" = "Ei suodatettuja keskusteluja"; + +/* No comment provided by engineer. */ +"No group!" = "Ryhmää ei löydy!"; + +/* No comment provided by engineer. */ +"No history" = "Ei historiaa"; + +/* No comment provided by engineer. */ +"No permission to record voice message" = "Ei lupaa ääniviestin tallentamiseen"; + +/* No comment provided by engineer. */ +"No received or sent files" = "Ei vastaanotettuja tai lähetettyjä tiedostoja"; + +/* copied message info in history */ +"no text" = "ei tekstiä"; + +/* No comment provided by engineer. */ +"Notifications" = "Ilmoitukset"; + +/* No comment provided by engineer. */ +"Notifications are disabled!" = "Ilmoitukset on poistettu käytöstä!"; + +/* No comment provided by engineer. */ +"Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Nyt järjestelmänvalvojat voivat:\n- poistaa jäsenten viestit.\n- poista jäsenet käytöstä (\"tarkkailija\" rooli)"; + +/* member role */ +"observer" = "tarkkailija"; + +/* enabled status + group pref value */ +"off" = "pois"; + +/* No comment provided by engineer. */ +"Off" = "Pois"; + +/* No comment provided by engineer. */ +"Off (Local)" = "Pois (Paikallinen)"; + +/* feature offered item */ +"offered %@" = "tarjottu %@"; + +/* feature offered item */ +"offered %@: %@" = "tarjottu %1$@: %2$@"; + +/* No comment provided by engineer. */ +"Ok" = "Ok"; + +/* No comment provided by engineer. */ +"Old database" = "Vanha tietokanta"; + +/* No comment provided by engineer. */ +"Old database archive" = "Vanha tietokanta-arkisto"; + +/* group pref value */ +"on" = "päällä"; + +/* No comment provided by engineer. */ +"One-time invitation link" = "Kertakutsulinkki"; + +/* No comment provided by engineer. */ +"Onion hosts will be required for connection. Requires enabling VPN." = "Yhteyden muodostamiseen tarvitaan Onion-isäntiä. Edellyttää VPN:n sallimista."; + +/* No comment provided by engineer. */ +"Onion hosts will be used when available. Requires enabling VPN." = "Onion-isäntiä käytetään, kun niitä on saatavilla. Edellyttää VPN:n sallimista."; + +/* No comment provided by engineer. */ +"Onion hosts will not be used." = "Onion-isäntiä ei käytetä."; + +/* No comment provided by engineer. */ +"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Vain asiakaslaitteet tallentavat käyttäjäprofiileja, yhteystietoja, ryhmiä ja viestejä, jotka on lähetetty **kaksinkertaisella päästä päähän -salauksella**."; + +/* No comment provided by engineer. */ +"Only group owners can change group preferences." = "Vain ryhmän omistajat voivat muuttaa ryhmän asetuksia."; + +/* No comment provided by engineer. */ +"Only group owners can enable files and media." = "Vain ryhmän omistajat voivat sallia tiedostoja ja mediaa."; + +/* No comment provided by engineer. */ +"Only group owners can enable voice messages." = "Vain ryhmän omistajat voivat ottaa ääniviestit käyttöön."; + +/* No comment provided by engineer. */ +"Only you can add message reactions." = "Vain sinä voit lisätä viestireaktioita."; + +/* No comment provided by engineer. */ +"Only you can irreversibly delete messages (your contact can mark them for deletion)." = "Vain sinä voit poistaa viestejä peruuttamattomasti (kontaktisi voi merkitä ne poistettavaksi)."; + +/* No comment provided by engineer. */ +"Only you can make calls." = "Vain sinä voit soittaa puheluita."; + +/* No comment provided by engineer. */ +"Only you can send disappearing messages." = "Vain sinä voit lähettää katoavia viestejä."; + +/* No comment provided by engineer. */ +"Only you can send voice messages." = "Vain sinä voit lähettää ääniviestejä."; + +/* No comment provided by engineer. */ +"Only your contact can add message reactions." = "Vain kontaktisi voi lisätä viestireaktioita."; + +/* No comment provided by engineer. */ +"Only your contact can irreversibly delete messages (you can mark them for deletion)." = "Vain kontaktisi voi poistaa viestejä peruuttamattomasti (voit merkitä ne poistettavaksi)."; + +/* No comment provided by engineer. */ +"Only your contact can make calls." = "Vain kontaktisi voi soittaa puheluita."; + +/* No comment provided by engineer. */ +"Only your contact can send disappearing messages." = "Vain kontaktisi voi lähettää katoavia viestejä."; + +/* No comment provided by engineer. */ +"Only your contact can send voice messages." = "Vain kontaktisi voi lähettää ääniviestejä."; + +/* No comment provided by engineer. */ +"Open chat" = "Avaa keskustelu"; + +/* authentication reason */ +"Open chat console" = "Avaa keskustelukonsoli"; + +/* No comment provided by engineer. */ +"Open Settings" = "Avaa Asetukset"; + +/* authentication reason */ +"Open user profiles" = "Avaa käyttäjäprofiilit"; + +/* No comment provided by engineer. */ +"Open-source protocol and code – anybody can run the servers." = "Avoimen lähdekoodin protokolla ja koodi - kuka tahansa voi käyttää palvelimia."; + +/* 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"; + +/* No comment provided by engineer. */ +"Passcode" = "Pääsykoodi"; + +/* No comment provided by engineer. */ +"Passcode changed!" = "Pääsykoodi vaihdettu!"; + +/* No comment provided by engineer. */ +"Passcode entry" = "Pääsykoodin syöttö"; + +/* No comment provided by engineer. */ +"Passcode not changed!" = "Pääsykoodia ei ole muutettu!"; + +/* No comment provided by engineer. */ +"Passcode set!" = "Pääsykoodi asetettu!"; + +/* No comment provided by engineer. */ +"Password to show" = "Salasana näytettäväksi"; + +/* No comment provided by engineer. */ +"Paste" = "Liitä"; + +/* No comment provided by engineer. */ +"Paste image" = "Liitä kuva"; + +/* No comment provided by engineer. */ +"Paste received link" = "Liitä vastaanotettu linkki"; + +/* placeholder */ +"Paste the link you received to connect with your contact." = "Liitä saamasi linkki, jonka avulla voit muodostaa yhteyden kontaktiisi."; + +/* No comment provided by engineer. */ +"peer-to-peer" = "vertais"; + +/* No comment provided by engineer. */ +"People can connect to you only via the links you share." = "Ihmiset voivat ottaa sinuun yhteyttä vain jakamiesi linkkien kautta."; + +/* No comment provided by engineer. */ +"Periodically" = "Ajoittain"; + +/* message decrypt error item */ +"Permanent decryption error" = "Pysyvä salauksen purkuvirhe"; + +/* No comment provided by engineer. */ +"PING count" = "PING-määrä"; + +/* No comment provided by engineer. */ +"PING interval" = "PING-väli"; + +/* No comment provided by engineer. */ +"Please ask your contact to enable sending voice messages." = "Pyydä kontaktiasi sallimaan ääniviestien lähettäminen."; + +/* No comment provided by engineer. */ +"Please check that you used the correct link or ask your contact to send you another one." = "Tarkista, että käytit oikeaa linkkiä tai pyydä kontaktiasi lähettämään sinulle uusi linkki."; + +/* No comment provided by engineer. */ +"Please check your network connection with %@ and try again." = "Tarkista verkkoyhteytesi %@:lla ja yritä uudelleen."; + +/* No comment provided by engineer. */ +"Please check yours and your contact preferences." = "Tarkista omasi ja kontaktin asetukset."; + +/* No comment provided by engineer. */ +"Please contact group admin." = "Ota yhteyttä ryhmän ylläpitäjään."; + +/* No comment provided by engineer. */ +"Please enter correct current passphrase." = "Anna oikea nykyinen tunnuslause."; + +/* No comment provided by engineer. */ +"Please enter the previous password after restoring database backup. This action can not be undone." = "Anna edellinen salasana tietokannan varmuuskopion palauttamisen jälkeen. Tätä toimintoa ei voi kumota."; + +/* No comment provided by engineer. */ +"Please remember or store it securely - there is no way to recover a lost passcode!" = "Muista tai säilytä se turvallisesti - kadonnutta pääsykoodia ei voi palauttaa!"; + +/* No comment provided by engineer. */ +"Please report it to the developers." = "Ilmoita siitä kehittäjille."; + +/* No comment provided by engineer. */ +"Please restart the app and migrate the database to enable push notifications." = "Käynnistä sovellus uudelleen ja siirrä tietokanta push-ilmoitusten ottamiseksi käyttöön."; + +/* No comment provided by engineer. */ +"Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Säilytä tunnuslause turvallisesti, ET pääse keskusteluihin, jos kadotat sen."; + +/* No comment provided by engineer. */ +"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Säilytä tunnuslause turvallisesti, ET voi muuttaa sitä, jos kadotat sen."; + +/* No comment provided by engineer. */ +"Polish interface" = "Puolalainen käyttöliittymä"; + +/* server test error */ +"Possibly, certificate fingerprint in server address is incorrect" = "Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen"; + +/* No comment provided by engineer. */ +"Preserve the last message draft, with attachments." = "Säilytä viimeinen viestiluonnos liitteineen."; + +/* No comment provided by engineer. */ +"Preset server" = "Esiasetettu palvelin"; + +/* No comment provided by engineer. */ +"Preset server address" = "Esiasetettu palvelimen osoite"; + +/* No comment provided by engineer. */ +"Preview" = "Esikatselu"; + +/* No comment provided by engineer. */ +"Privacy & security" = "Yksityisyys ja turvallisuus"; + +/* No comment provided by engineer. */ +"Privacy redefined" = "Yksityisyys uudelleen määritettynä"; + +/* No comment provided by engineer. */ +"Private filenames" = "Yksityiset tiedostonimet"; + +/* No comment provided by engineer. */ +"Profile and server connections" = "Profiili- ja palvelinyhteydet"; + +/* No comment provided by engineer. */ +"Profile image" = "Profiilikuva"; + +/* No comment provided by engineer. */ +"Profile password" = "Profiilin salasana"; + +/* No comment provided by engineer. */ +"Profile update will be sent to your contacts." = "Profiilipäivitys lähetetään kontakteillesi."; + +/* No comment provided by engineer. */ +"Prohibit audio/video calls." = "Estä ääni- ja videopuhelut."; + +/* No comment provided by engineer. */ +"Prohibit irreversible message deletion." = "Estä peruuttamaton viestien poistaminen."; + +/* No comment provided by engineer. */ +"Prohibit message reactions." = "Estä viestireaktiot."; + +/* No comment provided by engineer. */ +"Prohibit messages reactions." = "Estä viestireaktiot."; + +/* No comment provided by engineer. */ +"Prohibit sending direct messages to members." = "Estä suorien viestien lähettäminen jäsenille."; + +/* No comment provided by engineer. */ +"Prohibit sending disappearing messages." = "Estä katoavien viestien lähettäminen."; + +/* No comment provided by engineer. */ +"Prohibit sending files and media." = "Estä tiedostojen ja median lähettäminen."; + +/* No comment provided by engineer. */ +"Prohibit sending voice messages." = "Estä ääniviestien lähettäminen."; + +/* No comment provided by engineer. */ +"Protect app screen" = "Suojaa sovellusnäyttö"; + +/* No comment provided by engineer. */ +"Protect your chat profiles with a password!" = "Suojaa keskusteluprofiilisi salasanalla!"; + +/* No comment provided by engineer. */ +"Protocol timeout" = "Protokollan aikakatkaisu"; + +/* No comment provided by engineer. */ +"Protocol timeout per KB" = "Protokollan aikakatkaisu per KB"; + +/* No comment provided by engineer. */ +"Push notifications" = "Push-ilmoitukset"; + +/* No comment provided by engineer. */ +"Rate the app" = "Arvioi sovellus"; + +/* chat item menu */ +"React…" = "Reagoi…"; + +/* No comment provided by engineer. */ +"Read" = "Lue"; + +/* No comment provided by engineer. */ +"Read more" = "Lue lisää"; + +/* No comment provided by engineer. */ +"Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address)." = "Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address)."; + +/* No comment provided by engineer. */ +"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; + +/* No comment provided by engineer. */ +"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Lue lisää [GitHub-arkistosta](https://github.com/simplex-chat/simplex-chat#readme)."; + +/* No comment provided by engineer. */ +"Read more in our GitHub repository." = "Lue lisää GitHub-tietovarastostamme."; + +/* No comment provided by engineer. */ +"Receipts are disabled" = "Kuittaukset pois käytöstä"; + +/* No comment provided by engineer. */ +"received answer…" = "vastaus saatu…"; + +/* No comment provided by engineer. */ +"Received at" = "Vastaanotettu klo"; + +/* copied message info */ +"Received at: %@" = "Vastaanotettu klo: %@"; + +/* No comment provided by engineer. */ +"received confirmation…" = "vahvistus saatu…"; + +/* notification */ +"Received file event" = "Tiedoston vastaanottotapahtuma"; + +/* message info title */ +"Received message" = "Vastaanotettu viesti"; + +/* No comment provided by engineer. */ +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "Vastaanotto-osoite vaihdetaan toiseen palvelimeen. Osoitteenmuutos tehdään sen jälkeen, kun lähettäjä tulee verkkoon."; + +/* No comment provided by engineer. */ +"Receiving file will be stopped." = "Tiedoston vastaanotto pysäytetään."; + +/* No comment provided by engineer. */ +"Receiving via" = "Vastaanotto kautta"; + +/* No comment provided by engineer. */ +"Recipients see updates as you type them." = "Vastaanottajat näkevät päivitykset, kun kirjoitat niitä."; + +/* No comment provided by engineer. */ +"Reconnect all connected servers to force message delivery. It uses additional traffic." = "Yhdistä kaikki yhdistetyt palvelimet uudelleen pakottaaksesi viestin toimituksen. Tämä käyttää ylimääräistä liikennettä."; + +/* No comment provided by engineer. */ +"Reconnect servers?" = "Yhdistä palvelimet uudelleen?"; + +/* No comment provided by engineer. */ +"Record updated at" = "Tietue päivitetty klo"; + +/* copied message info */ +"Record updated at: %@" = "Tietue päivitetty klo: %@"; + +/* No comment provided by engineer. */ +"Reduced battery usage" = "Pienempi akun käyttö"; + +/* reject incoming call via notification */ +"Reject" = "Hylkää"; + +/* No comment provided by engineer. */ +"Reject (sender NOT notified)" = "Hylkää (lähettäjälle EI ilmoiteta)"; + +/* No comment provided by engineer. */ +"Reject contact request" = "Hylkää yhteyspyyntö"; + +/* call status */ +"rejected call" = "hylätty puhelu"; + +/* No comment provided by engineer. */ +"Relay server is only used if necessary. Another party can observe your IP address." = "Välityspalvelinta käytetään vain tarvittaessa. Toinen osapuoli voi tarkkailla IP-osoitettasi."; + +/* No comment provided by engineer. */ +"Relay server protects your IP address, but it can observe the duration of the call." = "Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa."; + +/* No comment provided by engineer. */ +"Remove" = "Poista"; + +/* No comment provided by engineer. */ +"Remove member" = "Poista jäsen"; + +/* No comment provided by engineer. */ +"Remove member?" = "Poista jäsen?"; + +/* No comment provided by engineer. */ +"Remove passphrase from keychain?" = "Poista tunnuslause avainnipusta?"; + +/* No comment provided by engineer. */ +"removed" = "poistettu"; + +/* rcv group event chat item */ +"removed %@" = "%@ poistettu"; + +/* rcv group event chat item */ +"removed you" = "poisti sinut"; + +/* No comment provided by engineer. */ +"Renegotiate" = "Neuvottele uudelleen"; + +/* No comment provided by engineer. */ +"Renegotiate encryption" = "Uudelleenneuvottele salaus"; + +/* No comment provided by engineer. */ +"Renegotiate encryption?" = "Uudelleenneuvottele salaus?"; + +/* chat item action */ +"Reply" = "Vastaa"; + +/* No comment provided by engineer. */ +"Required" = "Pakollinen"; + +/* No comment provided by engineer. */ +"Reset" = "Oletustilaan"; + +/* No comment provided by engineer. */ +"Reset colors" = "Oletusvärit"; + +/* No comment provided by engineer. */ +"Reset to defaults" = "Palauta oletusasetukset"; + +/* No comment provided by engineer. */ +"Restart the app to create a new chat profile" = "Käynnistä sovellus uudelleen uuden keskusteluprofiilin luomiseksi"; + +/* No comment provided by engineer. */ +"Restart the app to use imported chat database" = "Käynnistä sovellus uudelleen käyttääksesi tuotua keskustelujen-tietokantaa"; + +/* No comment provided by engineer. */ +"Restore" = "Palauta"; + +/* No comment provided by engineer. */ +"Restore database backup" = "Palauta tietokannan varmuuskopio"; + +/* No comment provided by engineer. */ +"Restore database backup?" = "Palauta tietokannan varmuuskopio?"; + +/* No comment provided by engineer. */ +"Restore database error" = "Virhe tietokannan palauttamisessa"; + +/* chat item action */ +"Reveal" = "Paljasta"; + +/* No comment provided by engineer. */ +"Revert" = "Palauta"; + +/* No comment provided by engineer. */ +"Revoke" = "Peruuta"; + +/* cancel file action */ +"Revoke file" = "Peruuta tiedosto"; + +/* No comment provided by engineer. */ +"Revoke file?" = "Peruuta tiedosto?"; + +/* No comment provided by engineer. */ +"Role" = "Rooli"; + +/* No comment provided by engineer. */ +"Run chat" = "Käynnistä chat"; + +/* chat item action */ +"Save" = "Tallenna"; + +/* No comment provided by engineer. */ +"Save (and notify contacts)" = "Tallenna (ja ilmoita kontakteille)"; + +/* No comment provided by engineer. */ +"Save and notify contact" = "Tallenna ja ilmoita kontaktille"; + +/* No comment provided by engineer. */ +"Save and notify group members" = "Tallenna ja ilmoita ryhmän jäsenille"; + +/* No comment provided by engineer. */ +"Save and update group profile" = "Tallenna ja päivitä ryhmäprofiili"; + +/* No comment provided by engineer. */ +"Save archive" = "Tallenna arkisto"; + +/* No comment provided by engineer. */ +"Save auto-accept settings" = "Tallenna automaattisen hyväksynnän asetukset"; + +/* No comment provided by engineer. */ +"Save group profile" = "Tallenna ryhmäprofiili"; + +/* No comment provided by engineer. */ +"Save passphrase and open chat" = "Tallenna tunnuslause ja avaa keskustelu"; + +/* No comment provided by engineer. */ +"Save passphrase in Keychain" = "Tallenna tunnuslause Avainnippuun"; + +/* No comment provided by engineer. */ +"Save preferences?" = "Tallenna asetukset?"; + +/* No comment provided by engineer. */ +"Save profile password" = "Tallenna profiilin salasana"; + +/* No comment provided by engineer. */ +"Save servers" = "Tallenna palvelimet"; + +/* No comment provided by engineer. */ +"Save servers?" = "Tallenna palvelimet?"; + +/* No comment provided by engineer. */ +"Save settings?" = "Tallenna asetukset?"; + +/* No comment provided by engineer. */ +"Save welcome message?" = "Tallenna tervetuloviesti?"; + +/* No comment provided by engineer. */ +"Saved WebRTC ICE servers will be removed" = "Tallennetut WebRTC ICE -palvelimet poistetaan"; + +/* No comment provided by engineer. */ +"Scan code" = "Skannaa koodi"; + +/* No comment provided by engineer. */ +"Scan QR code" = "Skannaa QR-koodi"; + +/* No comment provided by engineer. */ +"Scan security code from your contact's app." = "Skannaa turvakoodi kontaktisi sovelluksesta."; + +/* No comment provided by engineer. */ +"Scan server QR code" = "Skannaa palvelimen QR-koodi"; + +/* No comment provided by engineer. */ +"Search" = "Haku"; + +/* network option */ +"sec" = "sek"; + +/* time unit */ +"seconds" = "sekuntia"; + +/* No comment provided by engineer. */ +"secret" = "salainen"; + +/* server test step */ +"Secure queue" = "Turvallinen jono"; + +/* No comment provided by engineer. */ +"Security assessment" = "Turvallisuusarviointi"; + +/* No comment provided by engineer. */ +"Security code" = "Turvakoodi"; + +/* chat item text */ +"security code changed" = "turvakoodi on muuttunut"; + +/* No comment provided by engineer. */ +"Select" = "Valitse"; + +/* No comment provided by engineer. */ +"Self-destruct" = "Itsetuho"; + +/* No comment provided by engineer. */ +"Self-destruct passcode" = "Itsetuhoutuva pääsykoodi"; + +/* No comment provided by engineer. */ +"Self-destruct passcode changed!" = "Itsetuhoutuva pääsykoodi vaihdettu!"; + +/* No comment provided by engineer. */ +"Self-destruct passcode enabled!" = "Itsetuhoutuva pääsykoodi käytössä!"; + +/* No comment provided by engineer. */ +"Send" = "Lähetä"; + +/* No comment provided by engineer. */ +"Send a live message - it will update for the recipient(s) as you type it" = "Lähetä live-viesti - se päivittyy vastaanottajille, kun kirjoitat sitä"; + +/* No comment provided by engineer. */ +"Send delivery receipts to" = "Lähetä toimituskuittaukset vastaanottajalle"; + +/* No comment provided by engineer. */ +"Send direct message" = "Lähetä yksityisviesti"; + +/* No comment provided by engineer. */ +"Send disappearing message" = "Lähetä katoava viesti"; + +/* No comment provided by engineer. */ +"Send link previews" = "Lähetä linkkien esikatselu"; + +/* No comment provided by engineer. */ +"Send live message" = "Lähetä live-viesti"; + +/* No comment provided by engineer. */ +"Send notifications" = "Lähetys ilmoitukset"; + +/* No comment provided by engineer. */ +"Send notifications:" = "Lähetys ilmoitukset:"; + +/* No comment provided by engineer. */ +"Send questions and ideas" = "Lähetä kysymyksiä ja ideoita"; + +/* No comment provided by engineer. */ +"Send receipts" = "Lähetä kuittaukset"; + +/* No comment provided by engineer. */ +"Send them from gallery or custom keyboards." = "Lähetä ne galleriasta tai mukautetuista näppäimistöistä."; + +/* No comment provided by engineer. */ +"Sender cancelled file transfer." = "Lähettäjä peruutti tiedoston siirron."; + +/* No comment provided by engineer. */ +"Sender may have deleted the connection request." = "Lähettäjä on saattanut poistaa yhteyspyynnön."; + +/* No comment provided by engineer. */ +"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille näkyvissä keskusteluprofiileissa."; + +/* No comment provided by engineer. */ +"Sending delivery receipts will be enabled for all contacts." = "Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille."; + +/* No comment provided by engineer. */ +"Sending file will be stopped." = "Tiedoston lähettäminen lopetetaan."; + +/* No comment provided by engineer. */ +"Sending receipts is disabled for %lld contacts" = "Kuittauksien lähettäminen ei ole käytössä %lld kontakteille"; + +/* No comment provided by engineer. */ +"Sending receipts is disabled for %lld groups" = "Kuittien lähettäminen ei ole käytössä %lld ryhmille"; + +/* No comment provided by engineer. */ +"Sending receipts is enabled for %lld contacts" = "Kuittauksien lähettäminen on käytössä %lld kontakteille"; + +/* No comment provided by engineer. */ +"Sending receipts is enabled for %lld groups" = "Kuittauksien lähettäminen on käytössä %lld ryhmille"; + +/* No comment provided by engineer. */ +"Sending via" = "Lähetetään kautta"; + +/* No comment provided by engineer. */ +"Sent at" = "Lähetetty klo"; + +/* copied message info */ +"Sent at: %@" = "Lähetetty klo: %@"; + +/* notification */ +"Sent file event" = "Lähetetty tiedosto tapahtuma"; + +/* message info title */ +"Sent message" = "Lähetetty viesti"; + +/* No comment provided by engineer. */ +"Sent messages will be deleted after set time." = "Lähetetyt viestit poistetaan asetetun ajan kuluttua."; + +/* server test error */ +"Server requires authorization to create queues, check password" = "Palvelin vaatii valtuutuksen jonojen luomiseen, tarkista salasana"; + +/* server test error */ +"Server requires authorization to upload, check password" = "Palvelin vaatii valtuutuksen tiedoston lataamiseksi, tarkista salasana"; + +/* No comment provided by engineer. */ +"Server test failed!" = "Palvelintesti epäonnistui!"; + +/* No comment provided by engineer. */ +"Servers" = "Palvelimet"; + +/* No comment provided by engineer. */ +"Set 1 day" = "Aseta 1 päivä"; + +/* No comment provided by engineer. */ +"Set contact name…" = "Aseta kontaktin nimi…"; + +/* No comment provided by engineer. */ +"Set group preferences" = "Aseta ryhmän asetukset"; + +/* No comment provided by engineer. */ +"Set it instead of system authentication." = "Aseta se järjestelmän todennuksen sijaan."; + +/* No comment provided by engineer. */ +"Set passcode" = "Aseta pääsykoodi"; + +/* No comment provided by engineer. */ +"Set passphrase to export" = "Aseta tunnuslause vientiä varten"; + +/* No comment provided by engineer. */ +"Set the message shown to new members!" = "Aseta uusille jäsenille näytettävä viesti!"; + +/* No comment provided by engineer. */ +"Set timeouts for proxy/VPN" = "Aseta aikakatkaisut välityspalvelimelle/VPN:lle"; + +/* No comment provided by engineer. */ +"Settings" = "Asetukset"; + +/* chat item action */ +"Share" = "Jaa"; + +/* No comment provided by engineer. */ +"Share 1-time link" = "Jaa kertakäyttölinkki"; + +/* No comment provided by engineer. */ +"Share address" = "Jaa osoite"; + +/* No comment provided by engineer. */ +"Share address with contacts?" = "Jaa osoite kontakteille?"; + +/* No comment provided by engineer. */ +"Share link" = "Jaa linkki"; + +/* No comment provided by engineer. */ +"Share one-time invitation link" = "Jaa kertakutsulinkki"; + +/* No comment provided by engineer. */ +"Share with contacts" = "Jaa kontaktien kanssa"; + +/* No comment provided by engineer. */ +"Show calls in phone history" = "Näytä puhelut puhelinhistoriassa"; + +/* No comment provided by engineer. */ +"Show developer options" = "Näytä kehittäjävaihtoehdot"; + +/* No comment provided by engineer. */ +"Show last messages" = "Näytä viimeiset viestit"; + +/* No comment provided by engineer. */ +"Show preview" = "Näytä esikatselu"; + +/* No comment provided by engineer. */ +"Show:" = "Näytä:"; + +/* No comment provided by engineer. */ +"SimpleX address" = "SimpleX-osoite"; + +/* No comment provided by engineer. */ +"SimpleX Address" = "SimpleX-osoite"; + +/* No comment provided by engineer. */ +"SimpleX Chat security was audited by Trail of Bits." = "Trail of Bits on tarkastanut SimpleX Chatin tietoturvan."; + +/* simplex link type */ +"SimpleX contact address" = "SimpleX-yhteystiedot"; + +/* notification */ +"SimpleX encrypted message or connection event" = "SimpleX-salattu viesti tai yhteystapahtuma"; + +/* simplex link type */ +"SimpleX group link" = "SimpleX-ryhmän linkki"; + +/* No comment provided by engineer. */ +"SimpleX links" = "SimpleX-linkit"; + +/* No comment provided by engineer. */ +"SimpleX Lock" = "SimpleX Lock"; + +/* No comment provided by engineer. */ +"SimpleX Lock mode" = "SimpleX Lock -tila"; + +/* No comment provided by engineer. */ +"SimpleX Lock not enabled!" = "SimpleX Lock ei ole käytössä!"; + +/* No comment provided by engineer. */ +"SimpleX Lock turned on" = "SimpleX Lock päällä"; + +/* simplex link type */ +"SimpleX one-time invitation" = "SimpleX-kertakutsu"; + +/* No comment provided by engineer. */ +"Skip" = "Ohita"; + +/* No comment provided by engineer. */ +"Skipped messages" = "Ohitetut viestit"; + +/* No comment provided by engineer. */ +"Small groups (max 20)" = "Pienryhmät (max 20)"; + +/* No comment provided by engineer. */ +"SMP servers" = "SMP-palvelimet"; + +/* No comment provided by engineer. */ +"Some non-fatal errors occurred during import - you may see Chat console for more details." = "Tuonnin aikana tapahtui joitakin ei-vakavia virheitä – saatat nähdä Chat-konsolissa lisätietoja."; + +/* notification title */ +"Somebody" = "Joku"; + +/* No comment provided by engineer. */ +"Start a new chat" = "Aloita uusi keskustelu"; + +/* No comment provided by engineer. */ +"Start chat" = "Aloita keskustelu"; + +/* No comment provided by engineer. */ +"Start migration" = "Aloita siirto"; + +/* No comment provided by engineer. */ +"starting…" = "alkaa…"; + +/* No comment provided by engineer. */ +"Stop" = "Lopeta"; + +/* No comment provided by engineer. */ +"Stop chat to enable database actions" = "Pysäytä keskustelu tietokantatoimien mahdollistamiseksi"; + +/* No comment provided by engineer. */ +"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Pysäytä keskustelut viedäksesi, tuodaksesi tai poistaaksesi keskustelujen tietokannan. Et voi vastaanottaa ja lähettää viestejä, kun keskustelut on pysäytetty."; + +/* No comment provided by engineer. */ +"Stop chat?" = "Lopeta keskustelu?"; + +/* cancel file action */ +"Stop file" = "Pysäytä tiedosto"; + +/* No comment provided by engineer. */ +"Stop receiving file?" = "Lopeta tiedoston vastaanottaminen?"; + +/* No comment provided by engineer. */ +"Stop sending file?" = "Lopeta tiedoston lähettäminen?"; + +/* No comment provided by engineer. */ +"Stop sharing" = "Lopeta jakaminen"; + +/* No comment provided by engineer. */ +"Stop sharing address?" = "Lopeta osoitteen jakaminen?"; + +/* authentication reason */ +"Stop SimpleX" = "Lopeta SimpleX"; + +/* No comment provided by engineer. */ +"strike" = "soita"; + +/* No comment provided by engineer. */ +"Submit" = "Lähetä"; + +/* No comment provided by engineer. */ +"Support SimpleX Chat" = "SimpleX Chat tuki"; + +/* No comment provided by engineer. */ +"System" = "Järjestelmä"; + +/* No comment provided by engineer. */ +"System authentication" = "Järjestelmän todennus"; + +/* No comment provided by engineer. */ +"Take picture" = "Ota kuva"; + +/* No comment provided by engineer. */ +"Tap button " = "Napauta painiketta "; + +/* No comment provided by engineer. */ +"Tap to activate profile." = "Aktivoi profiili napauttamalla."; + +/* No comment provided by engineer. */ +"Tap to join" = "Liity napauttamalla"; + +/* No comment provided by engineer. */ +"Tap to join incognito" = "Napauta liittyäksesi incognito-tilassa"; + +/* No comment provided by engineer. */ +"Tap to start a new chat" = "Aloita uusi keskustelu napauttamalla"; + +/* No comment provided by engineer. */ +"TCP connection timeout" = "TCP-yhteyden aikakatkaisu"; + +/* No comment provided by engineer. */ +"TCP_KEEPCNT" = "TCP_KEEPCNT"; + +/* No comment provided by engineer. */ +"TCP_KEEPIDLE" = "TCP_KEEPIDLE"; + +/* No comment provided by engineer. */ +"TCP_KEEPINTVL" = "TCP_KEEPINTVL"; + +/* server test failure */ +"Test failed at step %@." = "Testi epäonnistui vaiheessa %@."; + +/* No comment provided by engineer. */ +"Test server" = "Testipalvelin"; + +/* No comment provided by engineer. */ +"Test servers" = "Testipalvelimet"; + +/* No comment provided by engineer. */ +"Tests failed!" = "Testit epäonnistuivat!"; + +/* No comment provided by engineer. */ +"Thank you for installing SimpleX Chat!" = "Kiitos SimpleX Chatin asentamisesta!"; + +/* No comment provided by engineer. */ +"Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Kiitos käyttäjille - [osallistu Weblaten avulla](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + +/* No comment provided by engineer. */ +"Thanks to the users – contribute via Weblate!" = "Kiitokset käyttäjille – osallistu Weblaten kautta!"; + +/* No comment provided by engineer. */ +"The 1st platform without any user identifiers – private by design." = "Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi."; + +/* No comment provided by engineer. */ +"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Sovellus voi ilmoittaa sinulle, kun saat viestejä tai yhteydenottopyyntöjä - avaa asetukset ottaaksesi ne käyttöön."; + +/* No comment provided by engineer. */ +"The attempt to change database passphrase was not completed." = "Tietokannan tunnuslauseen muuttamista ei suoritettu loppuun."; + +/* No comment provided by engineer. */ +"The connection you accepted will be cancelled!" = "Hyväksymäsi yhteys peruuntuu!"; + +/* No comment provided by engineer. */ +"The contact you shared this link with will NOT be able to connect!" = "Kontakti, jolle jaoit tämän linkin, EI voi muodostaa yhteyttä!"; + +/* No comment provided by engineer. */ +"The created archive is available via app Settings / Database / Old database archive." = "Luotu arkisto on käytettävissä sovelluksen Asetukset / Tietokanta / Vanha tietokanta-arkisto kautta."; + +/* 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."; + +/* No comment provided by engineer. */ +"The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "Seuraavan viestin tunnus on väärä (pienempi tai yhtä suuri kuin edellisen).\nTämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut."; + +/* No comment provided by engineer. */ +"The message will be deleted for all members." = "Viesti poistetaan kaikilta jäseniltä."; + +/* No comment provided by engineer. */ +"The message will be marked as moderated for all members." = "Viesti merkitään moderoiduksi kaikille jäsenille."; + +/* No comment provided by engineer. */ +"The next generation of private messaging" = "Seuraavan sukupolven yksityisviestit"; + +/* No comment provided by engineer. */ +"The old database was not removed during the migration, it can be deleted." = "Vanhaa tietokantaa ei poistettu siirron aikana, se voidaan kuitenkin poistaa."; + +/* No comment provided by engineer. */ +"The profile is only shared with your contacts." = "Profiili jaetaan vain kontaktiesi kanssa."; + +/* No comment provided by engineer. */ +"The second tick we missed! ✅" = "Toinen kuittaus, joka uupui! ✅"; + +/* No comment provided by engineer. */ +"The sender will NOT be notified" = "Lähettäjälle EI ilmoiteta"; + +/* No comment provided by engineer. */ +"The servers for new connections of your current chat profile **%@**." = "Palvelimet nykyisen keskusteluprofiilisi uusille yhteyksille **%@**."; + +/* No comment provided by engineer. */ +"Theme" = "Teema"; + +/* No comment provided by engineer. */ +"There should be at least one user profile." = "Käyttäjäprofiileja tulee olla vähintään yksi."; + +/* No comment provided by engineer. */ +"There should be at least one visible user profile." = "Näkyviä käyttäjäprofiileja tulee olla vähintään yksi."; + +/* No comment provided by engineer. */ +"These settings are for your current profile **%@**." = "Nämä asetukset koskevat nykyistä profiiliasi **%@**."; + +/* No comment provided by engineer. */ +"They can be overridden in contact and group settings." = "Ne voidaan ohittaa kontakti- ja ryhmäasetuksissa."; + +/* No comment provided by engineer. */ +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Tätä toimintoa ei voi kumota - kaikki vastaanotetut ja lähetetyt tiedostot ja media poistetaan. Matalan resoluution kuvat säilyvät."; + +/* No comment provided by engineer. */ +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja."; + +/* No comment provided by engineer. */ +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Tätä toimintoa ei voi kumota - profiilisi, kontaktisi, viestisi ja tiedostosi poistuvat peruuttamattomasti."; + +/* notification title */ +"this contact" = "tämä kontakti"; + +/* 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ä."; + +/* No comment provided by engineer. */ +"This group no longer exists." = "Tätä ryhmää ei enää ole olemassa."; + +/* No comment provided by engineer. */ +"This setting applies to messages in your current chat profile **%@**." = "Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä *%@**."; + +/* No comment provided by engineer. */ +"To ask any questions and to receive updates:" = "Voit esittää kysymyksiä ja saada päivityksiä:"; + +/* No comment provided by engineer. */ +"To connect, your contact can scan QR code or use the link in the app." = "Kontaktisi voi muodostaa yhteyden skannaamalla QR-koodin tai käyttämällä sovelluksessa olevaa linkkiä."; + +/* No comment provided by engineer. */ +"To make a new connection" = "Uuden yhteyden luominen"; + +/* No comment provided by engineer. */ +"To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." = "Yksityisyyden suojaamiseksi kaikkien muiden alustojen käyttämien käyttäjätunnusten sijaan SimpleX käyttää viestijonojen tunnisteita, jotka ovat kaikille kontakteille erillisiä."; + +/* No comment provided by engineer. */ +"To protect timezone, image/voice files use UTC." = "Aikavyöhykkeen suojaamiseksi kuva-/äänitiedostot käyttävät UTC:tä."; + +/* No comment provided by engineer. */ +"To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Suojaa tietosi ottamalla SimpleX Lock käyttöön.\nSinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus otetaan käyttöön."; + +/* No comment provided by engineer. */ +"To record voice message please grant permission to use Microphone." = "Jos haluat nauhoittaa ääniviestin, anna lupa käyttää mikrofonia."; + +/* No comment provided by engineer. */ +"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Voit paljastaa piilotetun profiilisi syöttämällä koko salasanan hakukenttään **Keskusteluprofiilisi** -sivulla."; + +/* No comment provided by engineer. */ +"To support instant push notifications the chat database has to be migrated." = "Keskustelujen-tietokanta on siirrettävä välittömien push-ilmoitusten tukemiseksi."; + +/* No comment provided by engineer. */ +"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Voit tarkistaa päästä päähän -salauksen kontaktisi kanssa vertaamalla (tai skannaamalla) laitteidenne koodia."; + +/* No comment provided by engineer. */ +"Transport isolation" = "Kuljetuksen eristäminen"; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Yritetään muodostaa yhteyttä palvelimeen, jota käytetään tämän kontaktin viestien vastaanottamiseen (virhe: %@)."; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact." = "Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta."; + +/* No comment provided by engineer. */ +"Turn off" = "Sammuta"; + +/* No comment provided by engineer. */ +"Turn off notifications?" = "Kytke ilmoitukset pois päältä?"; + +/* No comment provided by engineer. */ +"Turn on" = "Kytke päälle"; + +/* No comment provided by engineer. */ +"Unable to record voice message" = "Ääniviestiä ei voi tallentaa"; + +/* item status description */ +"Unexpected error: %@" = "Odottamaton virhe: %@"; + +/* No comment provided by engineer. */ +"Unexpected migration state" = "Odottamaton siirtotila"; + +/* No comment provided by engineer. */ +"Unfav." = "Epäsuotuisa."; + +/* No comment provided by engineer. */ +"Unhide" = "Näytä"; + +/* No comment provided by engineer. */ +"Unhide chat profile" = "Näytä keskusteluprofiili"; + +/* No comment provided by engineer. */ +"Unhide profile" = "Näytä profiili"; + +/* No comment provided by engineer. */ +"Unit" = "Yksikkö"; + +/* connection info */ +"unknown" = "tuntematon"; + +/* callkit banner */ +"Unknown caller" = "Tuntematon soittaja"; + +/* No comment provided by engineer. */ +"Unknown database error: %@" = "Tuntematon tietokantavirhe: %@"; + +/* No comment provided by engineer. */ +"Unknown error" = "Tuntematon virhe"; + +/* No comment provided by engineer. */ +"Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "Ellet käytä iOS:n puhelinkäyttöliittymää, ota Älä häiritse -tila käyttöön keskeytysten välttämiseksi."; + +/* 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." = "Ellei yhteyshenkilösi poistanut yhteyttä tai tämä linkki oli jo käytössä, se voi olla virhe - ilmoita siitä.\nJos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja tarkista, että verkkoyhteytesi on vakaa."; + +/* No comment provided by engineer. */ +"Unlock" = "Avaa"; + +/* authentication reason */ +"Unlock app" = "Avaa sovellus"; + +/* No comment provided by engineer. */ +"Unmute" = "Poista mykistys"; + +/* No comment provided by engineer. */ +"Unread" = "Lukematon"; + +/* No comment provided by engineer. */ +"Update" = "Päivitä"; + +/* No comment provided by engineer. */ +"Update .onion hosts setting?" = "Päivitä .onion-isäntien asetus?"; + +/* No comment provided by engineer. */ +"Update database passphrase" = "Päivitä tietokannan tunnuslause"; + +/* No comment provided by engineer. */ +"Update network settings?" = "Päivitä verkkoasetukset?"; + +/* No comment provided by engineer. */ +"Update transport isolation mode?" = "Päivitä kuljetuksen eristystila?"; + +/* rcv group event chat item */ +"updated group profile" = "päivitetty ryhmäprofiili"; + +/* No comment provided by engineer. */ +"Updating settings will re-connect the client to all servers." = "Asetusten päivittäminen yhdistää asiakkaan uudelleen kaikkiin palvelimiin."; + +/* No comment provided by engineer. */ +"Updating this setting will re-connect the client to all servers." = "Tämän asetuksen päivittäminen yhdistää asiakkaan uudelleen kaikkiin palvelimiin."; + +/* No comment provided by engineer. */ +"Upgrade and open chat" = "Päivitä ja avaa keskustelu"; + +/* server test step */ +"Upload file" = "Lataa tiedosto"; + +/* No comment provided by engineer. */ +"Use .onion hosts" = "Käytä .onion-isäntiä"; + +/* No comment provided by engineer. */ +"Use chat" = "Käytä chattia"; + +/* No comment provided by engineer. */ +"Use current profile" = "Käytä nykyistä profiilia"; + +/* No comment provided by engineer. */ +"Use for new connections" = "Käytä uusiin yhteyksiin"; + +/* No comment provided by engineer. */ +"Use iOS call interface" = "Käytä iOS:n puhelujen käyttöliittymää"; + +/* No comment provided by engineer. */ +"Use new incognito profile" = "Käytä uutta incognito-profiilia"; + +/* No comment provided by engineer. */ +"Use server" = "Käytä palvelinta"; + +/* No comment provided by engineer. */ +"Use SimpleX Chat servers?" = "Käytä SimpleX Chat palvelimia?"; + +/* No comment provided by engineer. */ +"User profile" = "Käyttäjäprofiili"; + +/* No comment provided by engineer. */ +"Using .onion hosts requires compatible VPN provider." = ".onion-isäntien käyttäminen vaatii yhteensopivan VPN-palveluntarjoajan."; + +/* No comment provided by engineer. */ +"Using SimpleX Chat servers." = "Käyttää SimpleX Chat -palvelimia."; + +/* No comment provided by engineer. */ +"v%@ (%@)" = "v%@ (%@)"; + +/* No comment provided by engineer. */ +"Verify connection security" = "Tarkista yhteyden suojaus"; + +/* No comment provided by engineer. */ +"Verify security code" = "Tarkista turvakoodi"; + +/* No comment provided by engineer. */ +"Via browser" = "Selaimella"; + +/* chat list item description */ +"via contact address link" = "kontaktiosoitelinkillä"; + +/* chat list item description */ +"via group link" = "ryhmälinkillä"; + +/* chat list item description */ +"via one-time link" = "kertalinkillä"; + +/* No comment provided by engineer. */ +"via relay" = "releellä"; + +/* No comment provided by engineer. */ +"Video call" = "Videopuhelu"; + +/* No comment provided by engineer. */ +"video call (not e2e encrypted)" = "videopuhelu (ei e2e-salattu)"; + +/* No comment provided by engineer. */ +"Video will be received when your contact completes uploading it." = "Video vastaanotetaan, kun kontaktisi on ladannut sen."; + +/* No comment provided by engineer. */ +"Video will be received when your contact is online, please wait or check later!" = "Video vastaanotetaan, kun kontaktisi on online-tilassa, odota tai tarkista myöhemmin!"; + +/* No comment provided by engineer. */ +"Videos and files up to 1gb" = "Videot ja tiedostot 1 Gt asti"; + +/* No comment provided by engineer. */ +"View security code" = "Näytä turvakoodi"; + +/* No comment provided by engineer. */ +"Voice message…" = "Ääniviesti…"; + +/* chat feature */ +"Voice messages" = "Ääniviestit"; + +/* No comment provided by engineer. */ +"Voice messages are prohibited in this chat." = "Ääniviestit ovat kiellettyjä tässä keskustelussa."; + +/* No comment provided by engineer. */ +"Voice messages are prohibited in this group." = "Ääniviestit ovat kiellettyjä tässä ryhmässä."; + +/* No comment provided by engineer. */ +"Voice messages prohibited!" = "Ääniviestit kielletty!"; + +/* No comment provided by engineer. */ +"waiting for answer…" = "odottaa vastaamista…"; + +/* No comment provided by engineer. */ +"waiting for confirmation…" = "odottaa vahvistusta…"; + +/* No comment provided by engineer. */ +"Waiting for file" = "Odottaa tiedostoa"; + +/* No comment provided by engineer. */ +"Waiting for image" = "Odottaa kuvaa"; + +/* No comment provided by engineer. */ +"Waiting for video" = "Odottaa videota"; + +/* No comment provided by engineer. */ +"wants to connect to you!" = "haluaa olla yhteydessä sinuun!"; + +/* No comment provided by engineer. */ +"Warning: you may lose some data!" = "Varoitus: saatat menettää joitain tietoja!"; + +/* No comment provided by engineer. */ +"WebRTC ICE servers" = "WebRTC ICE -palvelimet"; + +/* time unit */ +"weeks" = "viikkoa"; + +/* No comment provided by engineer. */ +"Welcome %@!" = "Tervetuloa %@!"; + +/* No comment provided by engineer. */ +"Welcome message" = "Tervetuloviesti"; + +/* No comment provided by engineer. */ +"What's new" = "Uusimmat"; + +/* No comment provided by engineer. */ +"When available" = "Kun saatavilla"; + +/* No comment provided by engineer. */ +"When people request to connect, you can accept or reject it." = "Kun ihmiset pyytävät yhteyden muodostamista, voit hyväksyä tai hylätä sen."; + +/* No comment provided by engineer. */ +"When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Kun jaat inkognitoprofiilin jonkun kanssa, tätä profiilia käytetään ryhmissä, joihin tämä sinut kutsuu."; + +/* No comment provided by engineer. */ +"With optional welcome message." = "Valinnaisella tervetuloviestillä."; + +/* No comment provided by engineer. */ +"Wrong database passphrase" = "Väärä tietokannan tunnuslause"; + +/* No comment provided by engineer. */ +"Wrong passphrase!" = "Väärä tunnuslause!"; + +/* No comment provided by engineer. */ +"XFTP servers" = "XFTP-palvelimet"; + +/* pref value */ +"yes" = "kyllä"; + +/* No comment provided by engineer. */ +"You" = "Sinä"; + +/* No comment provided by engineer. */ +"You accepted connection" = "Hyväksyit yhteyden"; + +/* No comment provided by engineer. */ +"You allow" = "Sallit"; + +/* No comment provided by engineer. */ +"You already have a chat profile with the same display name. Please choose another name." = "Sinulla on jo keskusteluprofiili samalla näyttönimellä. Valitse toinen nimi."; + +/* No comment provided by engineer. */ +"You are already connected to %@." = "Olet jo muodostanut yhteyden %@:n kanssa."; + +/* 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."; + +/* No comment provided by engineer. */ +"you are invited to group" = "sinut on kutsuttu ryhmään"; + +/* No comment provided by engineer. */ +"You are invited to group" = "Sinut on kutsuttu ryhmään"; + +/* No comment provided by engineer. */ +"you are observer" = "olet tarkkailija"; + +/* No comment provided by engineer. */ +"You can accept calls from lock screen, without device and app authentication." = "Voit vastaanottaa puheluita lukitusnäytöltä ilman laitteen ja sovelluksen todennusta."; + +/* No comment provided by engineer. */ +"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button." = "Voit myös muodostaa yhteyden klikkaamalla linkkiä. Jos se avautuu selaimessa, napsauta **Avaa mobiilisovelluksessa**-painiketta."; + +/* No comment provided by engineer. */ +"You can create it later" = "Voit luoda sen myöhemmin"; + +/* No comment provided by engineer. */ +"You can enable later via Settings" = "Voit ottaa käyttöön myöhemmin asetusten kautta"; + +/* No comment provided by engineer. */ +"You can enable them later via app Privacy & Security settings." = "Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys & Turvallisuus -asetuksista."; + +/* No comment provided by engineer. */ +"You can hide or mute a user profile - swipe it to the right." = "Voit piilottaa tai mykistää käyttäjäprofiilin pyyhkäisemällä sitä oikealle."; + +/* notification body */ +"You can now send messages to %@" = "Voit nyt lähettää viestejä %@:lle"; + +/* No comment provided by engineer. */ +"You can set lock screen notification preview via settings." = "Voit määrittää lukitusnäytön ilmoituksen esikatselun asetuksista."; + +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Voit jakaa linkin tai QR-koodin - kuka tahansa voi liittyä ryhmään. Et menetä ryhmän jäseniä, jos poistat sen myöhemmin."; + +/* No comment provided by engineer. */ +"You can share this address with your contacts to let them connect with **%@**." = "Voit jakaa tämän osoitteen kontaktiesi kanssa, jotta ne voivat muodostaa yhteyden **%@** kanssa."; + +/* No comment provided by engineer. */ +"You can share your address as a link or QR code - anybody can connect to you." = "Voit jakaa osoitteesi linkkinä tai QR-koodina - kuka tahansa voi muodostaa yhteyden sinuun."; + +/* No comment provided by engineer. */ +"You can start chat via app Settings / Database or by restarting the app" = "Voit aloittaa keskustelun sovelluksen Asetukset / Tietokanta kautta tai käynnistämällä sovelluksen uudelleen"; + +/* No comment provided by engineer. */ +"You can turn on SimpleX Lock via Settings." = "Voit ottaa SimpleX Lockin käyttöön Asetusten kautta."; + +/* No comment provided by engineer. */ +"You can use markdown to format messages:" = "Voit käyttää markdownia viestien muotoiluun:"; + +/* No comment provided by engineer. */ +"You can't send messages!" = "Et voi lähettää viestejä!"; + +/* chat item text */ +"you changed address" = "muutit osoitetta"; + +/* chat item text */ +"you changed address for %@" = "muutit osoitetta %@:ksi"; + +/* snd group event chat item */ +"you changed role for yourself to %@" = "vaihdoit roolin itsellesi %@:ksi"; + +/* snd group event chat item */ +"you changed role of %@ to %@" = "olet vaihtanut %1$@:n roolin %2$@:ksi"; + +/* No comment provided by engineer. */ +"You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." = "Sinä hallitset, minkä palvelim(i)en kautta **viestit vastaanotetaan**, kontaktisi - palvelimet, joita käytät viestien lähettämiseen niille."; + +/* No comment provided by engineer. */ +"You could not be verified; please try again." = "Sinua ei voitu todentaa; yritä uudelleen."; + +/* No comment provided by engineer. */ +"You have no chats" = "Sinulla ei ole keskusteluja"; + +/* No comment provided by engineer. */ +"You have to enter passphrase every time the app starts - it is not stored on the device." = "Sinun on annettava tunnuslause aina, kun sovellus käynnistyy - sitä ei tallenneta laitteeseen."; + +/* No comment provided by engineer. */ +"You invited a contact" = "Kutsuit kontaktin"; + +/* No comment provided by engineer. */ +"You joined this group" = "Liityit tähän ryhmään"; + +/* No comment provided by engineer. */ +"You joined this group. Connecting to inviting group member." = "Liityit tähän ryhmään. Muodostetaan yhteyttä ryhmän jäsenten kutsumiseksi."; + +/* snd group event chat item */ +"you left" = "lähdit"; + +/* No comment provided by engineer. */ +"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Sinun tulee käyttää keskustelujen-tietokannan uusinta versiota AINOSTAAN yhdessä laitteessa, muuten saatat lakata vastaanottamasta viestejä joiltakin kontakteilta."; + +/* No comment provided by engineer. */ +"You need to allow your contact to send voice messages to be able to send them." = "Sinun on sallittava kontaktiesi lähettää ääniviestejä, jotta voit lähettää niitä."; + +/* No comment provided by engineer. */ +"You rejected group invitation" = "Hylkäsit ryhmäkutsun"; + +/* snd group event chat item */ +"you removed %@" = "poistit %@"; + +/* No comment provided by engineer. */ +"You sent group invitation" = "Lähetit ryhmäkutsun"; + +/* chat list item description */ +"you shared one-time link" = "jaoit kertalinkin"; + +/* chat list item description */ +"you shared one-time link incognito" = "jaoit kertalinkin incognito-tilassa"; + +/* No comment provided by engineer. */ +"You will be connected to group when the group host's device is online, please wait or check later!" = "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 your connection request is accepted, please wait or check later!" = "Sinut yhdistetään, kun yhteyspyyntösi on hyväksytty, odota tai tarkista myöhemmin!"; + +/* No comment provided by engineer. */ +"You will be connected when your contact's device is online, please wait or check later!" = "Sinut yhdistetään, kun kontaktisi laite on online-tilassa, odota tai tarkista myöhemmin!"; + +/* 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."; + +/* No comment provided by engineer. */ +"You will stop receiving messages from this group. Chat history will be preserved." = "Et enää saa viestejä tästä ryhmästä. Keskusteluhistoria säilytetään."; + +/* No comment provided by engineer. */ +"You won't lose your contacts if you later delete your address." = "Et menetä kontaktejasi, jos poistat osoitteesi myöhemmin."; + +/* No comment provided by engineer. */ +"you: " = "sinä: "; + +/* No comment provided by engineer. */ +"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Yrität kutsua kontaktia, jonka kanssa olet jakanut inkognito-profiilin, ryhmään, jossa käytät pääprofiiliasi"; + +/* No comment provided by engineer. */ +"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Käytät tässä ryhmässä incognito-profiilia. Kontaktien kutsuminen ei ole sallittua, jotta pääprofiilisi ei tule jaetuksi"; + +/* No comment provided by engineer. */ +"Your %@ servers" = "%@-palvelimesi"; + +/* No comment provided by engineer. */ +"Your calls" = "Puhelusi"; + +/* No comment provided by engineer. */ +"Your chat database" = "Keskustelut-tietokantasi"; + +/* 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"; + +/* No comment provided by engineer. */ +"Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)." = "Kontaktin tulee olla online-tilassa, jotta yhteys voidaan muodostaa.\nVoit peruuttaa tämän yhteyden ja poistaa kontaktin (ja yrittää myöhemmin uudella linkillä)."; + +/* No comment provided by engineer. */ +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Yhteyshenkilösi lähetti tiedoston, joka on suurempi kuin tällä hetkellä tuettu enimmäiskoko (%@)."; + +/* No comment provided by engineer. */ +"Your contacts can allow full message deletion." = "Kontaktisi voivat sallia viestien täydellisen poistamisen."; + +/* No comment provided by engineer. */ +"Your contacts in SimpleX will see it.\nYou can change it in Settings." = "Kontaktisi SimpleX:ssä näkevät sen.\nVoit muuttaa sitä Asetuksista."; + +/* No comment provided by engineer. */ +"Your contacts will remain connected." = "Kontaktisi pysyvät yhdistettyinä."; + +/* No comment provided by engineer. */ +"Your current chat database will be DELETED and REPLACED with the imported one." = "Nykyinen keskustelut-tietokantasi poistetaan ja korvataan tuodulla tietokannalla."; + +/* No comment provided by engineer. */ +"Your current profile" = "Nykyinen profiilisi"; + +/* No comment provided by engineer. */ +"Your ICE servers" = "ICE-palvelimesi"; + +/* No comment provided by engineer. */ +"Your preferences" = "Asetuksesi"; + +/* No comment provided by engineer. */ +"Your privacy" = "Yksityisyytesi"; + +/* No comment provided by engineer. */ +"Your profile **%@** will be shared." = "Profiilisi **%@** jaetaan."; + +/* No comment provided by engineer. */ +"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa.\nSimpleX-palvelimet eivät näe profiiliasi."; + +/* No comment provided by engineer. */ +"Your profile, contacts and delivered messages are stored on your device." = "Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi."; + +/* No comment provided by engineer. */ +"Your random profile" = "Satunnainen profiilisi"; + +/* No comment provided by engineer. */ +"Your server" = "Palvelimesi"; + +/* No comment provided by engineer. */ +"Your server address" = "Palvelimesi osoite"; + +/* No comment provided by engineer. */ +"Your settings" = "Asetuksesi"; + +/* No comment provided by engineer. */ +"Your SimpleX address" = "SimpleX-osoitteesi"; + +/* No comment provided by engineer. */ +"Your SMP servers" = "SMP-palvelimesi"; + +/* No comment provided by engineer. */ +"Your XFTP servers" = "XFTP-palvelimesi"; + diff --git a/apps/ios/fi.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/fi.lproj/SimpleX--iOS--InfoPlist.strings new file mode 100644 index 000000000..969e43e44 --- /dev/null +++ b/apps/ios/fi.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,15 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; + +/* Privacy - Camera Usage Description */ +"NSCameraUsageDescription" = "SimpleX tarvitsee pääsyn kameraan, jotta se voi skannata QR-koodeja muodostaakseen yhteyden muihin käyttäjiin ja videopuheluita varten."; + +/* Privacy - Face ID Usage Description */ +"NSFaceIDUsageDescription" = "SimpleX käyttää Face ID:tä paikalliseen todennukseen"; + +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX tarvitsee mikrofonia ääni- ja videopuheluita ja ääniviestien tallentamista varten."; + +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX tarvitsee pääsyn valokuvakirjastoon kuvattujen ja vastaanotettujen medioiden tallentamista varten"; + diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings new file mode 100644 index 000000000..a9213527c --- /dev/null +++ b/apps/ios/uk.lproj/Localizable.strings @@ -0,0 +1,3675 @@ +/* No comment provided by engineer. */ +"\n" = "\n"; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" (" = " ("; + +/* No comment provided by engineer. */ +" (can be copied)" = " (можна скопіювати)"; + +/* No comment provided by engineer. */ +"_italic_" = "\\_курсив_"; + +/* No comment provided by engineer. */ +"- more stable message delivery.\n- a bit better groups.\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- історія редагування."; + +/* No comment provided by engineer. */ +", " = ", "; + +/* No comment provided by engineer. */ +": " = ": "; + +/* No comment provided by engineer. */ +"!1 colored!" = "!1 кольоровий!"; + +/* No comment provided by engineer. */ +"." = "."; + +/* No comment provided by engineer. */ +"(" = "("; + +/* No comment provided by engineer. */ +")" = ")"; + +/* No comment provided by engineer. */ +"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Внесок](https://github.com/simplex-chat/simplex-chat#contribute)"; + +/* No comment provided by engineer. */ +"[Send us email](mailto:chat@simplex.chat)" = "[Напишіть нам електронною поштою](mailto:chat@simplex.chat)"; + +/* No comment provided by engineer. */ +"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Зірка на GitHub](https://github.com/simplex-chat/simplex-chat)"; + +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Додати новий контакт**: щоб створити одноразовий QR-код або посилання для свого контакту."; + +/* No comment provided by engineer. */ +"**Create link / QR code** for your contact to use." = "**Створіть посилання / QR-код** для використання вашим контактом."; + +/* No comment provided by engineer. */ +"**e2e encrypted** audio call" = "**e2e encrypted** аудіодзвінок"; + +/* No comment provided by engineer. */ +"**e2e encrypted** video call" = "**e2e encrypted** відеодзвінок"; + +/* No comment provided by engineer. */ +"**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." = "**Більш приватний**: перевіряти нові повідомлення кожні 20 хвилин. Серверу SimpleX Chat передається токен пристрою, але не кількість контактів або повідомлень, які ви маєте."; + +/* No comment provided by engineer. */ +"**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." = "**Найбільш приватний**: не використовуйте сервер сповіщень SimpleX Chat, періодично перевіряйте повідомлення у фоновому режимі (залежить від того, як часто ви користуєтесь додатком)."; + +/* No comment provided by engineer. */ +"**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставте отримане посилання** або відкрийте його в браузері і натисніть **Відкрити в мобільному додатку**."; + +/* No comment provided by engineer. */ +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Зверніть увагу: ви НЕ зможете відновити або змінити пароль, якщо втратите його."; + +/* No comment provided by engineer. */ +"**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." = "**Рекомендується**: токен пристрою та сповіщення надсилаються на сервер сповіщень SimpleX Chat, але не вміст повідомлення, його розмір або від кого воно надійшло."; + +/* No comment provided by engineer. */ +"**Scan QR code**: to connect to your contact in person or via video call." = "**Відскануйте QR-код**: щоб з'єднатися з вашим контактом особисто або за допомогою відеодзвінка."; + +/* No comment provided by engineer. */ +"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Попередження**: Для отримання миттєвих пуш-сповіщень потрібна парольна фраза, збережена у брелоку."; + +/* No comment provided by engineer. */ +"*bold*" = "\\*жирний*"; + +/* copied message info title, # <title> */ +"# %@" = "# %@"; + +/* copied message info */ +"## History" = "## Історія"; + +/* copied message info */ +"## In reply to" = "## У відповідь на"; + +/* No comment provided by engineer. */ +"#secret#" = "#секрет#"; + +/* No comment provided by engineer. */ +"%@" = "%@"; + +/* No comment provided by engineer. */ +"%@ (current)" = "%@ (поточний)"; + +/* copied message info */ +"%@ (current):" = "%@ (поточний):"; + +/* No comment provided by engineer. */ +"%@ / %@" = "%@ / %@"; + +/* No comment provided by engineer. */ +"%@ %@" = "%@ %@"; + +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ і %@ підключено"; + +/* copied message info, <sender> at <time> */ +"%@ at %@:" = "%1$@ за %2$@:"; + +/* notification title */ +"%@ is connected!" = "%@ підключено!"; + +/* No comment provided by engineer. */ +"%@ is not verified" = "%@ не перевірено"; + +/* No comment provided by engineer. */ +"%@ is verified" = "%@ перевірено"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ сервери"; + +/* notification title */ +"%@ wants to connect!" = "%@ хоче підключитися!"; + +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "%@, %@ та %lld інші підключені учасники"; + +/* copied message info */ +"%@:" = "%@:"; + +/* time interval */ +"%d days" = "%d днів"; + +/* time interval */ +"%d hours" = "%d годин"; + +/* time interval */ +"%d min" = "%d хв"; + +/* time interval */ +"%d months" = "%d місяців"; + +/* time interval */ +"%d sec" = "%d сек"; + +/* integrity error chat item */ +"%d skipped message(s)" = "%d пропущено повідомлення(ь)"; + +/* time interval */ +"%d weeks" = "%d тижнів"; + +/* No comment provided by engineer. */ +"%lld" = "%lld"; + +/* No comment provided by engineer. */ +"%lld %@" = "%lld %@"; + +/* No comment provided by engineer. */ +"%lld contact(s) selected" = "%lld контакт(и) вибрані"; + +/* No comment provided by engineer. */ +"%lld file(s) with total size of %@" = "%lld файл(и) загальним розміром %@"; + +/* No comment provided by engineer. */ +"%lld members" = "%lld учасників"; + +/* No comment provided by engineer. */ +"%lld minutes" = "%lld хвилин"; + +/* No comment provided by engineer. */ +"%lld second(s)" = "%lld секунд(и)"; + +/* No comment provided by engineer. */ +"%lld seconds" = "%lld секунд"; + +/* No comment provided by engineer. */ +"%lldd" = "%lldd"; + +/* No comment provided by engineer. */ +"%lldh" = "%lldh"; + +/* No comment provided by engineer. */ +"%lldk" = "%lldk"; + +/* No comment provided by engineer. */ +"%lldm" = "%lldm"; + +/* No comment provided by engineer. */ +"%lldmth" = "%lldmth"; + +/* No comment provided by engineer. */ +"%llds" = "%llds"; + +/* No comment provided by engineer. */ +"%lldw" = "%lldw"; + +/* No comment provided by engineer. */ +"%u messages failed to decrypt." = "%u повідомлень не вдалося розшифрувати."; + +/* No comment provided by engineer. */ +"%u messages skipped." = "%u повідомлень пропущено."; + +/* No comment provided by engineer. */ +"`a + b`" = "\\`a + b`"; + +/* email text */ +"<p>Hi!</p>\n<p><a href=\"%@\">Connect to me via SimpleX Chat</a></p>" = "<p>Привіт!</p>\n<p><a href=\"%@\"> Зв'яжіться зі мною через SimpleX Chat</a></p>"; + +/* No comment provided by engineer. */ +"~strike~" = "\\~закреслити~"; + +/* No comment provided by engineer. */ +"0s" = "0с"; + +/* time interval */ +"1 day" = "1 день"; + +/* time interval */ +"1 hour" = "1 година"; + +/* No comment provided by engineer. */ +"1 minute" = "1 хвилина"; + +/* time interval */ +"1 month" = "1 місяць"; + +/* time interval */ +"1 week" = "1 тиждень"; + +/* No comment provided by engineer. */ +"1-time link" = "1-разове посилання"; + +/* No comment provided by engineer. */ +"5 minutes" = "5 хвилин"; + +/* No comment provided by engineer. */ +"6" = "6"; + +/* No comment provided by engineer. */ +"30 seconds" = "30 секунд"; + +/* No comment provided by engineer. */ +"A few more things" = "Ще кілька речей"; + +/* notification title */ +"A new contact" = "Новий контакт"; + +/* No comment provided by engineer. */ +"A new random profile will be shared." = "Буде створено новий випадковий профіль."; + +/* No comment provided by engineer. */ +"A separate TCP connection will be used **for each chat profile you have in the app**." = "Для кожного профілю чату, який ви маєте в додатку, буде використовуватися окреме TCP-з'єднання."; + +/* No comment provided by engineer. */ +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Для кожного контакту та учасника групи буде використовуватися окреме TCP-з'єднання.\n**Зверніть увагу: якщо у вас багато з'єднань, споживання заряду акумулятора і трафіку може бути значно вищим, а деякі з'єднання можуть обірватися."; + +/* No comment provided by engineer. */ +"Abort" = "Скасувати"; + +/* No comment provided by engineer. */ +"Abort changing address" = "Скасувати зміну адреси"; + +/* No comment provided by engineer. */ +"Abort changing address?" = "Скасувати зміну адреси?"; + +/* No comment provided by engineer. */ +"About SimpleX" = "Про SimpleX"; + +/* No comment provided by engineer. */ +"About SimpleX address" = "Про адресу SimpleX"; + +/* No comment provided by engineer. */ +"About SimpleX Chat" = "Про чат SimpleX"; + +/* No comment provided by engineer. */ +"above, then choose:" = "вище, а потім обирайте:"; + +/* No comment provided by engineer. */ +"Accent color" = "Акцентний колір"; + +/* accept contact request via notification + accept incoming call via notification */ +"Accept" = "Прийняти"; + +/* No comment provided by engineer. */ +"Accept connection request?" = "Прийняти запит на підключення?"; + +/* notification body */ +"Accept contact request from %@?" = "Прийняти запит на контакт від %@?"; + +/* accept contact request via notification */ +"Accept incognito" = "Прийняти інкогніто"; + +/* call status */ +"accepted call" = "прийнято виклик"; + +/* No comment provided by engineer. */ +"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Додайте адресу до свого профілю, щоб ваші контакти могли поділитися нею з іншими людьми. Повідомлення про оновлення профілю буде надіслано вашим контактам."; + +/* No comment provided by engineer. */ +"Add preset servers" = "Додавання попередньо встановлених серверів"; + +/* No comment provided by engineer. */ +"Add profile" = "Додати профіль"; + +/* No comment provided by engineer. */ +"Add server…" = "Додати сервер…"; + +/* No comment provided by engineer. */ +"Add servers by scanning QR codes." = "Додайте сервери, відсканувавши QR-код."; + +/* No comment provided by engineer. */ +"Add to another device" = "Додати до іншого пристрою"; + +/* No comment provided by engineer. */ +"Add welcome message" = "Додати вітальне повідомлення"; + +/* No comment provided by engineer. */ +"Address" = "Адреса"; + +/* No comment provided by engineer. */ +"Address change will be aborted. Old receiving address will be used." = "Зміна адреси буде скасована. Буде використано стару адресу отримання."; + +/* member role */ +"admin" = "адмін"; + +/* No comment provided by engineer. */ +"Admins can create the links to join groups." = "Адміни можуть створювати посилання для приєднання до груп."; + +/* No comment provided by engineer. */ +"Advanced network settings" = "Розширені налаштування мережі"; + +/* chat item text */ +"agreeing encryption for %@…" = "узгодження шифрування для %@…"; + +/* chat item text */ +"agreeing encryption…" = "узгодження шифрування…"; + +/* No comment provided by engineer. */ +"All app data is deleted." = "Всі дані програми видаляються."; + +/* No comment provided by engineer. */ +"All chats and messages will be deleted - this cannot be undone!" = "Всі чати та повідомлення будуть видалені - це неможливо скасувати!"; + +/* No comment provided by engineer. */ +"All data is erased when it is entered." = "Всі дані стираються при введенні."; + +/* No comment provided by engineer. */ +"All group members will remain connected." = "Всі учасники групи залишаться на зв'язку."; + +/* No comment provided by engineer. */ +"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Всі повідомлення будуть видалені - це неможливо скасувати! Повідомлення будуть видалені ТІЛЬКИ для вас."; + +/* No comment provided by engineer. */ +"All your contacts will remain connected." = "Всі ваші контакти залишаться на зв'язку."; + +/* No comment provided by engineer. */ +"All your contacts will remain connected. Profile update will be sent to your contacts." = "Всі ваші контакти залишаться на зв'язку. Повідомлення про оновлення профілю буде надіслано вашим контактам."; + +/* No comment provided by engineer. */ +"Allow" = "Дозволити"; + +/* No comment provided by engineer. */ +"Allow calls only if your contact allows them." = "Дозволяйте дзвінки, тільки якщо ваш контакт дозволяє їх."; + +/* No comment provided by engineer. */ +"Allow disappearing messages only if your contact allows it to you." = "Дозволяйте зникати повідомленням, тільки якщо контакт дозволяє вам це робити."; + +/* No comment provided by engineer. */ +"Allow irreversible message deletion only if your contact allows it to you." = "Дозволяйте безповоротне видалення повідомлень, тільки якщо контакт дозволяє вам це зробити."; + +/* No comment provided by engineer. */ +"Allow message reactions only if your contact allows them." = "Дозволяйте реакції на повідомлення, тільки якщо ваш контакт дозволяє їх."; + +/* No comment provided by engineer. */ +"Allow message reactions." = "Дозволити реакцію на повідомлення."; + +/* No comment provided by engineer. */ +"Allow sending direct messages to members." = "Дозволяє надсилати прямі повідомлення користувачам."; + +/* No comment provided by engineer. */ +"Allow sending disappearing messages." = "Дозволити надсилання зникаючих повідомлень."; + +/* No comment provided by engineer. */ +"Allow to irreversibly delete sent messages." = "Дозволяє безповоротно видаляти надіслані повідомлення."; + +/* No comment provided by engineer. */ +"Allow to send files and media." = "Дозволяє надсилати файли та медіа."; + +/* No comment provided by engineer. */ +"Allow to send voice messages." = "Дозволити надсилати голосові повідомлення."; + +/* No comment provided by engineer. */ +"Allow voice messages only if your contact allows them." = "Дозволяйте голосові повідомлення, тільки якщо ваш контакт дозволяє їх."; + +/* No comment provided by engineer. */ +"Allow voice messages?" = "Дозволити голосові повідомлення?"; + +/* No comment provided by engineer. */ +"Allow your contacts adding message reactions." = "Дозвольте вашим контактам додавати реакції на повідомлення."; + +/* No comment provided by engineer. */ +"Allow your contacts to call you." = "Дозвольте вашим контактам телефонувати вам."; + +/* No comment provided by engineer. */ +"Allow your contacts to irreversibly delete sent messages." = "Дозвольте вашим контактам безповоротно видаляти надіслані повідомлення."; + +/* No comment provided by engineer. */ +"Allow your contacts to send disappearing messages." = "Дозвольте своїм контактам надсилати зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Allow your contacts to send voice messages." = "Дозвольте своїм контактам надсилати голосові повідомлення."; + +/* No comment provided by engineer. */ +"Already connected?" = "Вже підключено?"; + +/* pref value */ +"always" = "завжди"; + +/* No comment provided by engineer. */ +"Always use relay" = "Завжди використовуйте реле"; + +/* No comment provided by engineer. */ +"An empty chat profile with the provided name is created, and the app opens as usual." = "Створюється порожній профіль чату з вказаним ім'ям, і додаток відкривається у звичайному режимі."; + +/* No comment provided by engineer. */ +"Answer call" = "Відповісти на дзвінок"; + +/* No comment provided by engineer. */ +"App build: %@" = "Збірка програми: %@"; + +/* No comment provided by engineer. */ +"App icon" = "Іконка програми"; + +/* No comment provided by engineer. */ +"App passcode" = "Пароль додатку"; + +/* No comment provided by engineer. */ +"App passcode is replaced with self-destruct passcode." = "Пароль програми замінено на пароль самознищення."; + +/* No comment provided by engineer. */ +"App version" = "Версія програми"; + +/* No comment provided by engineer. */ +"App version: v%@" = "Версія програми: v%@"; + +/* No comment provided by engineer. */ +"Appearance" = "Зовнішній вигляд"; + +/* No comment provided by engineer. */ +"Attach" = "Прикріпити"; + +/* No comment provided by engineer. */ +"Audio & video calls" = "Аудіо та відео дзвінки"; + +/* No comment provided by engineer. */ +"Audio and video calls" = "Аудіо та відеодзвінки"; + +/* No comment provided by engineer. */ +"audio call (not e2e encrypted)" = "аудіовиклик (без шифрування e2e)"; + +/* chat feature */ +"Audio/video calls" = "Аудіо/відео дзвінки"; + +/* No comment provided by engineer. */ +"Audio/video calls are prohibited." = "Аудіо/відео дзвінки заборонені."; + +/* PIN entry */ +"Authentication cancelled" = "Аутентифікацію скасовано"; + +/* No comment provided by engineer. */ +"Authentication failed" = "Не вдалося пройти автентифікацію"; + +/* No comment provided by engineer. */ +"Authentication is required before the call is connected, but you may miss calls." = "Перед з'єднанням дзвінка потрібно пройти автентифікацію, але ви можете пропустити дзвінки."; + +/* No comment provided by engineer. */ +"Authentication unavailable" = "Автентифікація недоступна"; + +/* No comment provided by engineer. */ +"Auto-accept" = "Автоприйняття"; + +/* No comment provided by engineer. */ +"Auto-accept contact requests" = "Автоматичне прийняття запитів на контакт"; + +/* No comment provided by engineer. */ +"Auto-accept images" = "Автоматичне прийняття зображень"; + +/* No comment provided by engineer. */ +"Back" = "Назад"; + +/* integrity error chat item */ +"bad message hash" = "невірний хеш повідомлення"; + +/* No comment provided by engineer. */ +"Bad message hash" = "Поганий хеш повідомлення"; + +/* integrity error chat item */ +"bad message ID" = "невірний ідентифікатор повідомлення"; + +/* No comment provided by engineer. */ +"Bad message ID" = "Неправильний ідентифікатор повідомлення"; + +/* No comment provided by engineer. */ +"Better messages" = "Кращі повідомлення"; + +/* No comment provided by engineer. */ +"bold" = "жирний"; + +/* No comment provided by engineer. */ +"Both you and your contact can add message reactions." = "Реакції на повідомлення можете додавати як ви, так і ваш контакт."; + +/* No comment provided by engineer. */ +"Both you and your contact can irreversibly delete sent messages." = "І ви, і ваш контакт можете безповоротно видалити надіслані повідомлення."; + +/* No comment provided by engineer. */ +"Both you and your contact can make calls." = "Дзвонити можете як ви, так і ваш контакт."; + +/* No comment provided by engineer. */ +"Both you and your contact can send disappearing messages." = "Ви і ваш контакт можете надсилати зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Both you and your contact can send voice messages." = "Надсилати голосові повідомлення можете як ви, так і ваш контакт."; + +/* 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)."; + +/* No comment provided by engineer. */ +"Call already ended!" = "Дзвінок вже закінчився!"; + +/* call status */ +"call error" = "помилка дзвінка"; + +/* call status */ +"call in progress" = "виклик у процесі"; + +/* call status */ +"calling…" = "дзвоніть…"; + +/* No comment provided by engineer. */ +"Calls" = "Дзвінки"; + +/* No comment provided by engineer. */ +"Can't delete user profile!" = "Не можу видалити профіль користувача!"; + +/* No comment provided by engineer. */ +"Can't invite contact!" = "Не вдається запросити контакт!"; + +/* No comment provided by engineer. */ +"Can't invite contacts!" = "Неможливо запросити контакти!"; + +/* No comment provided by engineer. */ +"Cancel" = "Скасувати"; + +/* feature offered item */ +"cancelled %@" = "скасовано %@"; + +/* No comment provided by engineer. */ +"Cannot access keychain to save database password" = "Не вдається отримати доступ до зв'язки ключів для збереження пароля до бази даних"; + +/* No comment provided by engineer. */ +"Cannot receive file" = "Не вдається отримати файл"; + +/* No comment provided by engineer. */ +"Change" = "Зміна"; + +/* No comment provided by engineer. */ +"Change database passphrase?" = "Змінити пароль до бази даних?"; + +/* authentication reason */ +"Change lock mode" = "Зміна режиму блокування"; + +/* No comment provided by engineer. */ +"Change member role?" = "Змінити роль учасника?"; + +/* authentication reason */ +"Change passcode" = "Змінити пароль"; + +/* No comment provided by engineer. */ +"Change receiving address" = "Змінити адресу отримання"; + +/* No comment provided by engineer. */ +"Change receiving address?" = "Змінити адресу отримання?"; + +/* No comment provided by engineer. */ +"Change role" = "Змінити роль"; + +/* authentication reason */ +"Change self-destruct mode" = "Змінити режим самознищення"; + +/* authentication reason + set passcode view */ +"Change self-destruct passcode" = "Змінити пароль самознищення"; + +/* chat item text */ +"changed address for you" = "змінили для вас адресу"; + +/* rcv group event chat item */ +"changed role of %@ to %@" = "змінено роль %1$@ на %2$@"; + +/* rcv group event chat item */ +"changed your role to %@" = "змінили свою роль на %@"; + +/* chat item text */ +"changing address for %@…" = "зміна адреси для %@…"; + +/* chat item text */ +"changing address…" = "змінює адресу…"; + +/* No comment provided by engineer. */ +"Chat archive" = "Архів чату"; + +/* No comment provided by engineer. */ +"Chat console" = "Консоль чату"; + +/* No comment provided by engineer. */ +"Chat database" = "База даних чату"; + +/* No comment provided by engineer. */ +"Chat database deleted" = "Видалено базу даних чату"; + +/* No comment provided by engineer. */ +"Chat database imported" = "Імпорт бази даних чату"; + +/* No comment provided by engineer. */ +"Chat is running" = "Чат запущено"; + +/* No comment provided by engineer. */ +"Chat is stopped" = "Чат зупинено"; + +/* No comment provided by engineer. */ +"Chat preferences" = "Налаштування чату"; + +/* No comment provided by engineer. */ +"Chats" = "Чати"; + +/* No comment provided by engineer. */ +"Check server address and try again." = "Перевірте адресу сервера та спробуйте ще раз."; + +/* No comment provided by engineer. */ +"Chinese and Spanish interface" = "Інтерфейс китайською та іспанською мовами"; + +/* No comment provided by engineer. */ +"Choose file" = "Виберіть файл"; + +/* No comment provided by engineer. */ +"Choose from library" = "Виберіть з бібліотеки"; + +/* No comment provided by engineer. */ +"Clear" = "Чисто"; + +/* No comment provided by engineer. */ +"Clear conversation" = "Ясна розмова"; + +/* No comment provided by engineer. */ +"Clear conversation?" = "Відверта розмова?"; + +/* No comment provided by engineer. */ +"Clear verification" = "Очистити перевірку"; + +/* No comment provided by engineer. */ +"colored" = "кольоровий"; + +/* No comment provided by engineer. */ +"Colors" = "Кольори"; + +/* server test step */ +"Compare file" = "Порівняти файл"; + +/* No comment provided by engineer. */ +"Compare security codes with your contacts." = "Порівняйте коди безпеки зі своїми контактами."; + +/* No comment provided by engineer. */ +"complete" = "завершено"; + +/* No comment provided by engineer. */ +"Configure ICE servers" = "Налаштування серверів ICE"; + +/* No comment provided by engineer. */ +"Confirm" = "Підтвердити"; + +/* No comment provided by engineer. */ +"Confirm database upgrades" = "Підтвердити оновлення бази даних"; + +/* No comment provided by engineer. */ +"Confirm new passphrase…" = "Підтвердіть нову парольну фразу…"; + +/* No comment provided by engineer. */ +"Confirm Passcode" = "Підтвердити пароль"; + +/* No comment provided by engineer. */ +"Confirm password" = "Підтвердити пароль"; + +/* 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" = "Підключіться за посиланням"; + +/* No comment provided by engineer. */ +"Connect via link / QR code" = "Підключитися за посиланням / QR-кодом"; + +/* No comment provided by engineer. */ +"Connect via one-time link" = "Під'єднатися за одноразовим посиланням"; + +/* No comment provided by engineer. */ +"connected" = "з'єднаний"; + +/* No comment provided by engineer. */ +"connecting" = "з'єднання"; + +/* No comment provided by engineer. */ +"connecting (accepted)" = "з'єднання (прийнято)"; + +/* No comment provided by engineer. */ +"connecting (announced)" = "з'єднання (оголошено)"; + +/* No comment provided by engineer. */ +"connecting (introduced)" = "з'єднання (введено)"; + +/* No comment provided by engineer. */ +"connecting (introduction invitation)" = "з'єднання (вступне запрошення)"; + +/* call status */ +"connecting call" = "підключення дзвінка…"; + +/* No comment provided by engineer. */ +"Connecting server…" = "Підключення до сервера…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "Підключення до сервера... (помилка: %@)"; + +/* chat list item title */ +"connecting…" = "з'єднання…"; + +/* No comment provided by engineer. */ +"Connection" = "Підключення"; + +/* No comment provided by engineer. */ +"Connection error" = "Помилка підключення"; + +/* No comment provided by engineer. */ +"Connection error (AUTH)" = "Помилка підключення (AUTH)"; + +/* chat list item title (it should not be shown */ +"connection established" = "з'єднання встановлене"; + +/* No comment provided by engineer. */ +"Connection request sent!" = "Запит на підключення відправлено!"; + +/* No comment provided by engineer. */ +"Connection timeout" = "Тайм-аут з'єднання"; + +/* connection information */ +"connection:%@" = "з'єднання:%@"; + +/* No comment provided by engineer. */ +"Contact allows" = "Контакт дозволяє"; + +/* 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"; + +/* No comment provided by engineer. */ +"contact has no e2e encryption" = "контакт не має шифрування e2e"; + +/* notification */ +"Contact hidden:" = "Контакт приховано:"; + +/* notification */ +"Contact is connected" = "Контакт підключений"; + +/* No comment provided by engineer. */ +"Contact is not connected yet!" = "Контакт ще не підключено!"; + +/* No comment provided by engineer. */ +"Contact name" = "Ім'я контактної особи"; + +/* No comment provided by engineer. */ +"Contact preferences" = "Налаштування контактів"; + +/* No comment provided by engineer. */ +"Contacts" = "Контакти"; + +/* No comment provided by engineer. */ +"Contacts can mark messages for deletion; you will be able to view them." = "Контакти можуть позначати повідомлення для видалення; ви зможете їх переглянути."; + +/* No comment provided by engineer. */ +"Continue" = "Продовжуйте"; + +/* chat item action */ +"Copy" = "Копіювати"; + +/* No comment provided by engineer. */ +"Core version: v%@" = "Основна версія: v%@"; + +/* No comment provided by engineer. */ +"Create" = "Створити"; + +/* No comment provided by engineer. */ +"Create an address to let people connect with you." = "Створіть адресу, щоб люди могли з вами зв'язатися."; + +/* server test step */ +"Create file" = "Створити файл"; + +/* No comment provided by engineer. */ +"Create group link" = "Створити групове посилання"; + +/* No comment provided by engineer. */ +"Create link" = "Створити посилання"; + +/* No comment provided by engineer. */ +"Create one-time invitation link" = "Створіть одноразове посилання-запрошення"; + +/* server test step */ +"Create queue" = "Створити чергу"; + +/* No comment provided by engineer. */ +"Create secret group" = "Створити секретну групу"; + +/* No comment provided by engineer. */ +"Create SimpleX address" = "Створіть адресу SimpleX"; + +/* No comment provided by engineer. */ +"Create your profile" = "Створіть свій профіль"; + +/* No comment provided by engineer. */ +"Created on %@" = "Створено %@"; + +/* No comment provided by engineer. */ +"creator" = "творець"; + +/* No comment provided by engineer. */ +"Current Passcode" = "Поточний пароль"; + +/* No comment provided by engineer. */ +"Current passphrase…" = "Поточна парольна фраза…"; + +/* No comment provided by engineer. */ +"Currently maximum supported file size is %@." = "Наразі максимальний підтримуваний розмір файлу - %@."; + +/* dropdown time picker choice */ +"custom" = "звичайний"; + +/* No comment provided by engineer. */ +"Custom time" = "Індивідуальний час"; + +/* No comment provided by engineer. */ +"Dark" = "Темний"; + +/* No comment provided by engineer. */ +"Database downgrade" = "Пониження версії бази даних"; + +/* No comment provided by engineer. */ +"Database encrypted!" = "База даних зашифрована!"; + +/* No comment provided by engineer. */ +"Database encryption passphrase will be updated and stored in the keychain.\n" = "Парольна фраза шифрування бази даних буде оновлена та збережена у в’язці ключів.\n"; + +/* No comment provided by engineer. */ +"Database encryption passphrase will be updated.\n" = "Ключову фразу шифрування бази даних буде оновлено.\n"; + +/* No comment provided by engineer. */ +"Database error" = "Помилка в базі даних"; + +/* No comment provided by engineer. */ +"Database ID" = "Ідентифікатор бази даних"; + +/* copied message info */ +"Database ID: %d" = "Ідентифікатор бази даних: %d"; + +/* No comment provided by engineer. */ +"Database IDs and Transport isolation option." = "Ідентифікатори бази даних та опція ізоляції транспорту."; + +/* No comment provided by engineer. */ +"Database is encrypted using a random passphrase, you can change it." = "База даних зашифрована за допомогою випадкової парольної фрази, яку ви можете змінити."; + +/* No comment provided by engineer. */ +"Database is encrypted using a random passphrase. Please change it before exporting." = "База даних зашифрована за допомогою випадкової парольної фрази. Будь ласка, змініть його перед експортом."; + +/* No comment provided by engineer. */ +"Database passphrase" = "Ключова фраза бази даних"; + +/* No comment provided by engineer. */ +"Database passphrase & export" = "Ключова фраза бази даних та експорт"; + +/* No comment provided by engineer. */ +"Database passphrase is different from saved in the keychain." = "Парольна фраза бази даних відрізняється від збереженої у в’язці ключів."; + +/* No comment provided by engineer. */ +"Database passphrase is required to open chat." = "Для відкриття чату потрібно ввести пароль до бази даних."; + +/* No comment provided by engineer. */ +"Database upgrade" = "Оновлення бази даних"; + +/* No comment provided by engineer. */ +"database version is newer than the app, but no down migration for: %@" = "версія бази даних новіша, ніж додаток, але без міграції вниз для: %@"; + +/* No comment provided by engineer. */ +"Database will be encrypted and the passphrase stored in the keychain.\n" = "База даних буде зашифрована, а парольна фраза збережена у в’язці ключів.\n"; + +/* No comment provided by engineer. */ +"Database will be encrypted.\n" = "База даних буде зашифрована.\n"; + +/* No comment provided by engineer. */ +"Database will be migrated when the app restarts" = "База даних буде перенесена під час перезапуску програми"; + +/* time unit */ +"days" = "днів"; + +/* No comment provided by engineer. */ +"Decentralized" = "Децентралізований"; + +/* message decrypt error item */ +"Decryption error" = "Помилка розшифровки"; + +/* pref value */ +"default (%@)" = "за замовчуванням (%@)"; + +/* No comment provided by engineer. */ +"default (no)" = "за замовчуванням (ні)"; + +/* No comment provided by engineer. */ +"default (yes)" = "за замовчуванням (так)"; + +/* chat item action */ +"Delete" = "Видалити"; + +/* No comment provided by engineer. */ +"Delete address" = "Видалити адресу"; + +/* No comment provided by engineer. */ +"Delete address?" = "Видалити адресу?"; + +/* No comment provided by engineer. */ +"Delete after" = "Видалити після"; + +/* No comment provided by engineer. */ +"Delete all files" = "Видалити всі файли"; + +/* No comment provided by engineer. */ +"Delete archive" = "Видалити архів"; + +/* No comment provided by engineer. */ +"Delete chat archive?" = "Видалити архів чату?"; + +/* No comment provided by engineer. */ +"Delete chat profile" = "Видалити профіль чату"; + +/* No comment provided by engineer. */ +"Delete chat profile?" = "Видалити профіль чату?"; + +/* No comment provided by engineer. */ +"Delete connection" = "Видалити підключення"; + +/* No comment provided by engineer. */ +"Delete contact" = "Видалити контакт"; + +/* No comment provided by engineer. */ +"Delete Contact" = "Видалити контакт"; + +/* No comment provided by engineer. */ +"Delete contact?" = "Видалити контакт?"; + +/* No comment provided by engineer. */ +"Delete database" = "Видалити базу даних"; + +/* server test step */ +"Delete file" = "Видалити файл"; + +/* No comment provided by engineer. */ +"Delete files and media?" = "Видаляти файли та медіа?"; + +/* No comment provided by engineer. */ +"Delete files for all chat profiles" = "Видалення файлів для всіх профілів чату"; + +/* chat feature */ +"Delete for everyone" = "Видалити для всіх"; + +/* No comment provided by engineer. */ +"Delete for me" = "Видалити для мене"; + +/* No comment provided by engineer. */ +"Delete group" = "Видалити групу"; + +/* No comment provided by engineer. */ +"Delete group?" = "Видалити групу?"; + +/* No comment provided by engineer. */ +"Delete invitation" = "Видалити запрошення"; + +/* No comment provided by engineer. */ +"Delete link" = "Видалити посилання"; + +/* No comment provided by engineer. */ +"Delete link?" = "Видалити посилання?"; + +/* No comment provided by engineer. */ +"Delete member message?" = "Видалити повідомлення учасника?"; + +/* No comment provided by engineer. */ +"Delete message?" = "Видалити повідомлення?"; + +/* No comment provided by engineer. */ +"Delete messages" = "Видалити повідомлення"; + +/* No comment provided by engineer. */ +"Delete messages after" = "Видаляйте повідомлення після"; + +/* No comment provided by engineer. */ +"Delete old database" = "Видалення старої бази даних"; + +/* No comment provided by engineer. */ +"Delete old database?" = "Видалити стару базу даних?"; + +/* No comment provided by engineer. */ +"Delete pending connection" = "Видалити очікуване з'єднання"; + +/* No comment provided by engineer. */ +"Delete pending connection?" = "Видалити очікуване з'єднання?"; + +/* No comment provided by engineer. */ +"Delete profile" = "Видалити профіль"; + +/* server test step */ +"Delete queue" = "Видалити чергу"; + +/* No comment provided by engineer. */ +"Delete user profile?" = "Видалити профіль користувача?"; + +/* deleted chat item */ +"deleted" = "видалено"; + +/* No comment provided by engineer. */ +"Deleted at" = "Видалено за"; + +/* copied message info */ +"Deleted at: %@" = "Видалено за: %@"; + +/* rcv group event chat item */ +"deleted group" = "видалено групу"; + +/* No comment provided by engineer. */ +"Delivery" = "Доставка"; + +/* No comment provided by engineer. */ +"Delivery receipts are disabled!" = "Квитанції про доставку відключені!"; + +/* No comment provided by engineer. */ +"Delivery receipts!" = "Квитанції про доставку!"; + +/* No comment provided by engineer. */ +"Description" = "Опис"; + +/* No comment provided by engineer. */ +"Develop" = "Розробник"; + +/* No comment provided by engineer. */ +"Developer tools" = "Інструменти для розробників"; + +/* No comment provided by engineer. */ +"Device" = "Пристрій"; + +/* No comment provided by engineer. */ +"Device authentication is disabled. Turning off SimpleX Lock." = "Автентифікацію пристрою вимкнено. Вимкнення SimpleX Lock."; + +/* No comment provided by engineer. */ +"Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "Автентифікація пристрою не ввімкнена. Ви можете увімкнути SimpleX Lock у Налаштуваннях, коли увімкнете автентифікацію пристрою."; + +/* No comment provided by engineer. */ +"different migration in the app/database: %@ / %@" = "різна міграція в додатку/базі даних: %@ / %@"; + +/* No comment provided by engineer. */ +"Different names, avatars and transport isolation." = "Різні імена, аватарки та транспортна ізоляція."; + +/* connection level description */ +"direct" = "прямо"; + +/* chat feature */ +"Direct messages" = "Прямі повідомлення"; + +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this group." = "У цій групі заборонені прямі повідомлення між учасниками."; + +/* No comment provided by engineer. */ +"Disable (keep overrides)" = "Вимкнути (зберегти перевизначення)"; + +/* No comment provided by engineer. */ +"Disable for all" = "Вимкнути для всіх"; + +/* authentication reason */ +"Disable SimpleX Lock" = "Вимкнути SimpleX Lock"; + +/* No comment provided by engineer. */ +"disabled" = "вимкнено"; + +/* No comment provided by engineer. */ +"Disappearing message" = "Зникаюче повідомлення"; + +/* chat feature */ +"Disappearing messages" = "Зникаючі повідомлення"; + +/* No comment provided by engineer. */ +"Disappearing messages are prohibited in this chat." = "Зникаючі повідомлення в цьому чаті заборонені."; + +/* No comment provided by engineer. */ +"Disappearing messages are prohibited in this group." = "У цій групі заборонено зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Disappears at" = "Зникає за"; + +/* copied message info */ +"Disappears at: %@" = "Зникає за: %@"; + +/* 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" = "Зробіть це пізніше"; + +/* No comment provided by engineer. */ +"Do NOT use SimpleX for emergency calls." = "НЕ використовуйте SimpleX для екстрених викликів."; + +/* No comment provided by engineer. */ +"Don't create address" = "Не створювати адресу"; + +/* No comment provided by engineer. */ +"Don't enable" = "Не вмикати"; + +/* No comment provided by engineer. */ +"Don't show again" = "Більше не показувати"; + +/* No comment provided by engineer. */ +"Downgrade and open chat" = "Пониження та відкритий чат"; + +/* server test step */ +"Download file" = "Завантажити файл"; + +/* No comment provided by engineer. */ +"Duplicate display name!" = "Дублююче ім'я користувача!"; + +/* integrity error chat item */ +"duplicate message" = "дублююче повідомлення"; + +/* No comment provided by engineer. */ +"Duration" = "Тривалість"; + +/* No comment provided by engineer. */ +"e2e encrypted" = "e2e зашифрований"; + +/* chat item action */ +"Edit" = "Редагувати"; + +/* No comment provided by engineer. */ +"Edit group profile" = "Редагування профілю групи"; + +/* No comment provided by engineer. */ +"Enable" = "Увімкнути"; + +/* No comment provided by engineer. */ +"Enable (keep overrides)" = "Увімкнути (зберегти перевизначення)"; + +/* No comment provided by engineer. */ +"Enable automatic message deletion?" = "Увімкнути автоматичне видалення повідомлень?"; + +/* No comment provided by engineer. */ +"Enable for all" = "Увімкнути для всіх"; + +/* No comment provided by engineer. */ +"Enable instant notifications?" = "Увімкнути миттєві сповіщення?"; + +/* No comment provided by engineer. */ +"Enable lock" = "Увімкнути блокування"; + +/* No comment provided by engineer. */ +"Enable notifications" = "Увімкнути сповіщення"; + +/* No comment provided by engineer. */ +"Enable periodic notifications?" = "Увімкнути періодичні сповіщення?"; + +/* No comment provided by engineer. */ +"Enable self-destruct" = "Увімкнути самознищення"; + +/* set passcode view */ +"Enable self-destruct passcode" = "Увімкнути пароль самознищення"; + +/* authentication reason */ +"Enable SimpleX Lock" = "Увімкнути SimpleX Lock"; + +/* No comment provided by engineer. */ +"Enable TCP keep-alive" = "Увімкнути TCP keep-alive"; + +/* enabled status */ +"enabled" = "увімкнено"; + +/* enabled status */ +"enabled for contact" = "увімкнено для контакту"; + +/* enabled status */ +"enabled for you" = "увімкнено для вас"; + +/* No comment provided by engineer. */ +"Encrypt" = "Зашифрувати"; + +/* No comment provided by engineer. */ +"Encrypt database?" = "Зашифрувати базу даних?"; + +/* No comment provided by engineer. */ +"Encrypted database" = "Зашифрована база даних"; + +/* notification */ +"Encrypted message or another event" = "Зашифроване повідомлення або інша подія"; + +/* notification */ +"Encrypted message: database error" = "Зашифроване повідомлення: помилка бази даних"; + +/* notification */ +"Encrypted message: database migration error" = "Зашифроване повідомлення: помилка міграції бази даних"; + +/* notification */ +"Encrypted message: keychain error" = "Зашифроване повідомлення: помилка ланцюжка ключів"; + +/* notification */ +"Encrypted message: no passphrase" = "Зашифроване повідомлення: без ключової фрази"; + +/* notification */ +"Encrypted message: unexpected error" = "Зашифроване повідомлення: несподівана помилка"; + +/* chat item text */ +"encryption agreed" = "узгоджено шифрування"; + +/* chat item text */ +"encryption agreed for %@" = "узгоджене шифрування для %@"; + +/* chat item text */ +"encryption ok" = "шифрування ok"; + +/* chat item text */ +"encryption ok for %@" = "шифрування ok для %@"; + +/* chat item text */ +"encryption re-negotiation allowed" = "переузгодження шифрування дозволено"; + +/* chat item text */ +"encryption re-negotiation allowed for %@" = "переузгодження шифрування дозволено для %@"; + +/* chat item text */ +"encryption re-negotiation required" = "потрібне повторне узгодження шифрування"; + +/* chat item text */ +"encryption re-negotiation required for %@" = "для %@ потрібне повторне узгодження шифрування"; + +/* No comment provided by engineer. */ +"ended" = "закінчився"; + +/* call status */ +"ended call %@" = "закінчився виклик %@"; + +/* No comment provided by engineer. */ +"Enter correct passphrase." = "Введіть правильну парольну фразу."; + +/* No comment provided by engineer. */ +"Enter Passcode" = "Введіть пароль"; + +/* No comment provided by engineer. */ +"Enter passphrase…" = "Введіть пароль…"; + +/* No comment provided by engineer. */ +"Enter password above to show!" = "Введіть пароль вище, щоб показати!"; + +/* No comment provided by engineer. */ +"Enter server manually" = "Увійдіть на сервер вручну"; + +/* placeholder */ +"Enter welcome message…" = "Введіть вітальне повідомлення…"; + +/* placeholder */ +"Enter welcome message… (optional)" = "Введіть вітальне повідомлення... (необов'язково)"; + +/* No comment provided by engineer. */ +"error" = "помилка"; + +/* No comment provided by engineer. */ +"Error" = "Помилка"; + +/* No comment provided by engineer. */ +"Error aborting address change" = "Помилка скасування зміни адреси"; + +/* No comment provided by engineer. */ +"Error accepting contact request" = "Помилка при прийнятті запиту на контакт"; + +/* No comment provided by engineer. */ +"Error accessing database file" = "Помилка доступу до файлу бази даних"; + +/* No comment provided by engineer. */ +"Error adding member(s)" = "Помилка додавання користувача(ів)"; + +/* No comment provided by engineer. */ +"Error changing address" = "Помилка зміни адреси"; + +/* No comment provided by engineer. */ +"Error changing role" = "Помилка зміни ролі"; + +/* No comment provided by engineer. */ +"Error changing setting" = "Помилка зміни налаштування"; + +/* No comment provided by engineer. */ +"Error creating address" = "Помилка створення адреси"; + +/* No comment provided by engineer. */ +"Error creating group" = "Помилка створення групи"; + +/* No comment provided by engineer. */ +"Error creating group link" = "Помилка створення посилання на групу"; + +/* No comment provided by engineer. */ +"Error creating profile!" = "Помилка створення профілю!"; + +/* No comment provided by engineer. */ +"Error deleting chat database" = "Помилка видалення бази даних чату"; + +/* No comment provided by engineer. */ +"Error deleting chat!" = "Помилка видалення чату!"; + +/* No comment provided by engineer. */ +"Error deleting connection" = "Помилка видалення з'єднання"; + +/* No comment provided by engineer. */ +"Error deleting contact" = "Помилка видалення контакту"; + +/* No comment provided by engineer. */ +"Error deleting database" = "Помилка видалення бази даних"; + +/* No comment provided by engineer. */ +"Error deleting old database" = "Помилка видалення старої бази даних"; + +/* No comment provided by engineer. */ +"Error deleting token" = "Помилка видалення токена"; + +/* No comment provided by engineer. */ +"Error deleting user profile" = "Помилка видалення профілю користувача"; + +/* No comment provided by engineer. */ +"Error enabling delivery receipts!" = "Помилка активації підтвердження доставлення!"; + +/* No comment provided by engineer. */ +"Error enabling notifications" = "Помилка увімкнення сповіщень"; + +/* No comment provided by engineer. */ +"Error encrypting database" = "Помилка шифрування бази даних"; + +/* No comment provided by engineer. */ +"Error exporting chat database" = "Помилка експорту бази даних чату"; + +/* No comment provided by engineer. */ +"Error importing chat database" = "Помилка імпорту бази даних чату"; + +/* No comment provided by engineer. */ +"Error joining group" = "Помилка приєднання до групи"; + +/* No comment provided by engineer. */ +"Error loading %@ servers" = "Помилка завантаження %@ серверів"; + +/* No comment provided by engineer. */ +"Error receiving file" = "Помилка отримання файлу"; + +/* No comment provided by engineer. */ +"Error removing member" = "Помилка видалення учасника"; + +/* No comment provided by engineer. */ +"Error saving %@ servers" = "Помилка збереження %@ серверів"; + +/* No comment provided by engineer. */ +"Error saving group profile" = "Помилка збереження профілю групи"; + +/* No comment provided by engineer. */ +"Error saving ICE servers" = "Помилка збереження серверів ICE"; + +/* No comment provided by engineer. */ +"Error saving passcode" = "Помилка збереження пароля"; + +/* No comment provided by engineer. */ +"Error saving passphrase to keychain" = "Помилка збереження пароля на keychain"; + +/* No comment provided by engineer. */ +"Error saving user password" = "Помилка збереження пароля користувача"; + +/* No comment provided by engineer. */ +"Error sending email" = "Помилка надсилання електронного листа"; + +/* No comment provided by engineer. */ +"Error sending message" = "Помилка надсилання повідомлення"; + +/* No comment provided by engineer. */ +"Error setting delivery receipts!" = "Помилка встановлення підтвердження доставлення!"; + +/* No comment provided by engineer. */ +"Error starting chat" = "Помилка запуску чату"; + +/* No comment provided by engineer. */ +"Error stopping chat" = "Помилка зупинки чату"; + +/* No comment provided by engineer. */ +"Error switching profile!" = "Помилка перемикання профілю!"; + +/* No comment provided by engineer. */ +"Error synchronizing connection" = "Помилка синхронізації з'єднання"; + +/* No comment provided by engineer. */ +"Error updating group link" = "Помилка оновлення посилання на групу"; + +/* No comment provided by engineer. */ +"Error updating message" = "Повідомлення про помилку оновлення"; + +/* No comment provided by engineer. */ +"Error updating settings" = "Помилка оновлення налаштувань"; + +/* No comment provided by engineer. */ +"Error updating user privacy" = "Помилка оновлення конфіденційності користувача"; + +/* No comment provided by engineer. */ +"Error: " = "Помилка: "; + +/* No comment provided by engineer. */ +"Error: %@" = "Помилка: %@"; + +/* No comment provided by engineer. */ +"Error: no database file" = "Помилка: немає файлу бази даних"; + +/* No comment provided by engineer. */ +"Error: URL is invalid" = "Помилка: URL-адреса невірна"; + +/* No comment provided by engineer. */ +"Even when disabled in the conversation." = "Навіть коли вимкнений у розмові."; + +/* No comment provided by engineer. */ +"event happened" = "відбулася подія"; + +/* No comment provided by engineer. */ +"Exit without saving" = "Вихід без збереження"; + +/* No comment provided by engineer. */ +"Export database" = "Експорт бази даних"; + +/* No comment provided by engineer. */ +"Export error:" = "Помилка експорту:"; + +/* No comment provided by engineer. */ +"Exported database archive." = "Експортований архів бази даних."; + +/* No comment provided by engineer. */ +"Exporting database archive…" = "Експорт архіву бази даних…"; + +/* No comment provided by engineer. */ +"Failed to remove passphrase" = "Не вдалося видалити парольну фразу"; + +/* No comment provided by engineer. */ +"Fast and no wait until the sender is online!" = "Швидко і без очікування, поки відправник буде онлайн!"; + +/* No comment provided by engineer. */ +"Favorite" = "Улюблений"; + +/* No comment provided by engineer. */ +"File will be deleted from servers." = "Файл буде видалено з серверів."; + +/* No comment provided by engineer. */ +"File will be received when your contact completes uploading it." = "Файл буде отримано, коли ваш контакт завершить завантаження."; + +/* No comment provided by engineer. */ +"File will be received when your contact is online, please wait or check later!" = "Файл буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше!"; + +/* No comment provided by engineer. */ +"File: %@" = "Файл: %@"; + +/* No comment provided by engineer. */ +"Files & media" = "Файли та медіа"; + +/* chat feature */ +"Files and media" = "Файли і медіа"; + +/* No comment provided by engineer. */ +"Files and media are prohibited in this group." = "Файли та медіа в цій групі заборонені."; + +/* No comment provided by engineer. */ +"Files and media prohibited!" = "Файли та медіа заборонені!"; + +/* No comment provided by engineer. */ +"Filter unread and favorite chats." = "Фільтруйте непрочитані та улюблені чати."; + +/* No comment provided by engineer. */ +"Finally, we have them! 🚀" = "Нарешті, вони у нас є! 🚀"; + +/* No comment provided by engineer. */ +"Find chats faster" = "Швидше знаходьте чати"; + +/* No comment provided by engineer. */ +"Fix" = "Виправити"; + +/* No comment provided by engineer. */ +"Fix connection" = "Виправити з'єднання"; + +/* No comment provided by engineer. */ +"Fix connection?" = "Полагодити зв'язок?"; + +/* No comment provided by engineer. */ +"Fix encryption after restoring backups." = "Виправити шифрування після відновлення резервних копій."; + +/* No comment provided by engineer. */ +"Fix not supported by contact" = "Виправлення не підтримується контактом"; + +/* No comment provided by engineer. */ +"Fix not supported by group member" = "Виправлення не підтримується учасником групи"; + +/* No comment provided by engineer. */ +"For console" = "Для консолі"; + +/* No comment provided by engineer. */ +"French interface" = "Французький інтерфейс"; + +/* No comment provided by engineer. */ +"Full link" = "Повне посилання"; + +/* No comment provided by engineer. */ +"Full name (optional)" = "Повне ім'я (необов'язково)"; + +/* No comment provided by engineer. */ +"Full name:" = "Повне ім'я:"; + +/* No comment provided by engineer. */ +"Fully re-implemented - work in background!" = "Повністю перероблено - робота у фоновому режимі!"; + +/* No comment provided by engineer. */ +"Further reduced battery usage" = "Подальше зменшення використання акумулятора"; + +/* No comment provided by engineer. */ +"GIFs and stickers" = "GIF-файли та наклейки"; + +/* No comment provided by engineer. */ +"Group" = "Група"; + +/* No comment provided by engineer. */ +"group deleted" = "групу видалено"; + +/* No comment provided by engineer. */ +"Group display name" = "Назва групи для відображення"; + +/* No comment provided by engineer. */ +"Group full name (optional)" = "Повна назва групи (необов'язково)"; + +/* No comment provided by engineer. */ +"Group image" = "Зображення групи"; + +/* No comment provided by engineer. */ +"Group invitation" = "Групове запрошення"; + +/* No comment provided by engineer. */ +"Group invitation expired" = "Термін дії групового запрошення закінчився"; + +/* No comment provided by engineer. */ +"Group invitation is no longer valid, it was removed by sender." = "Групове запрошення більше не дійсне, воно було видалено відправником."; + +/* No comment provided by engineer. */ +"Group link" = "Посилання на групу"; + +/* No comment provided by engineer. */ +"Group links" = "Групові посилання"; + +/* No comment provided by engineer. */ +"Group members can add message reactions." = "Учасники групи можуть додавати реакції на повідомлення."; + +/* No comment provided by engineer. */ +"Group members can irreversibly delete sent messages." = "Учасники групи можуть безповоротно видаляти надіслані повідомлення."; + +/* No comment provided by engineer. */ +"Group members can send direct messages." = "Учасники групи можуть надсилати прямі повідомлення."; + +/* No comment provided by engineer. */ +"Group members can send disappearing messages." = "Учасники групи можуть надсилати зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Group members can send files and media." = "Учасники групи можуть надсилати файли та медіа."; + +/* No comment provided by engineer. */ +"Group members can send voice messages." = "Учасники групи можуть надсилати голосові повідомлення."; + +/* notification */ +"Group message:" = "Групове повідомлення:"; + +/* No comment provided by engineer. */ +"Group moderation" = "Модерація груп"; + +/* No comment provided by engineer. */ +"Group preferences" = "Параметри груп"; + +/* No comment provided by engineer. */ +"Group profile" = "Профіль групи"; + +/* No comment provided by engineer. */ +"Group profile is stored on members' devices, not on the servers." = "Профіль групи зберігається на пристроях учасників, а не на серверах."; + +/* snd group event chat item */ +"group profile updated" = "оновлено профіль групи"; + +/* No comment provided by engineer. */ +"Group welcome message" = "Привітальне повідомлення групи"; + +/* No comment provided by engineer. */ +"Group will be deleted for all members - this cannot be undone!" = "Група буде видалена для всіх учасників - це неможливо скасувати!"; + +/* No comment provided by engineer. */ +"Group will be deleted for you - this cannot be undone!" = "Група буде видалена для вас - це не може бути скасовано!"; + +/* No comment provided by engineer. */ +"Help" = "Довідка"; + +/* No comment provided by engineer. */ +"Hidden" = "Приховано"; + +/* No comment provided by engineer. */ +"Hidden chat profiles" = "Приховані профілі чату"; + +/* No comment provided by engineer. */ +"Hidden profile password" = "Прихований пароль до профілю"; + +/* chat item action */ +"Hide" = "Приховати"; + +/* No comment provided by engineer. */ +"Hide app screen in the recent apps." = "Приховати екран програми в останніх програмах."; + +/* No comment provided by engineer. */ +"Hide profile" = "Приховати профіль"; + +/* No comment provided by engineer. */ +"Hide:" = "Приховати:"; + +/* No comment provided by engineer. */ +"History" = "Історія"; + +/* time unit */ +"hours" = "години"; + +/* No comment provided by engineer. */ +"How it works" = "Як це працює"; + +/* No comment provided by engineer. */ +"How SimpleX works" = "Як працює SimpleX"; + +/* No comment provided by engineer. */ +"How to" = "Як зробити"; + +/* No comment provided by engineer. */ +"How to use it" = "Як ним користуватися"; + +/* No comment provided by engineer. */ +"How to use your servers" = "Як користуватися вашими серверами"; + +/* No comment provided by engineer. */ +"ICE servers (one per line)" = "Сервери ICE (по одному на лінію)"; + +/* No comment provided by engineer. */ +"If you can't meet in person, show QR code in a video call, or share the link." = "Якщо ви не можете зустрітися особисто, покажіть QR-код у відеодзвінку або поділіться посиланням."; + +/* No comment provided by engineer. */ +"If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link." = "Якщо ви не можете зустрітися особисто, ви можете **сканувати QR-код у відеодзвінку**, або ваш контакт може поділитися посиланням на запрошення."; + +/* No comment provided by engineer. */ +"If you enter this passcode when opening the app, all app data will be irreversibly removed!" = "Якщо ви введете цей пароль при відкритті програми, всі дані програми будуть безповоротно видалені!"; + +/* No comment provided by engineer. */ +"If you enter your self-destruct passcode while opening the app:" = "Якщо ви введете пароль самознищення під час відкриття програми:"; + +/* No comment provided by engineer. */ +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Якщо вам потрібно скористатися чатом зараз, натисніть **Зробити це пізніше** нижче (вам буде запропоновано перенести базу даних при перезапуску програми)."; + +/* No comment provided by engineer. */ +"Ignore" = "Ігнорувати"; + +/* No comment provided by engineer. */ +"Image will be received when your contact completes uploading it." = "Зображення буде отримано, коли ваш контакт завершить завантаження."; + +/* No comment provided by engineer. */ +"Image will be received when your contact is online, please wait or check later!" = "Зображення буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше!"; + +/* No comment provided by engineer. */ +"Immediately" = "Негайно"; + +/* No comment provided by engineer. */ +"Immune to spam and abuse" = "Імунітет до спаму та зловживань"; + +/* No comment provided by engineer. */ +"Import" = "Імпорт"; + +/* No comment provided by engineer. */ +"Import chat database?" = "Імпортувати базу даних чату?"; + +/* No comment provided by engineer. */ +"Import database" = "Імпорт бази даних"; + +/* No comment provided by engineer. */ +"Improved privacy and security" = "Покращена конфіденційність та безпека"; + +/* No comment provided by engineer. */ +"Improved server configuration" = "Покращена конфігурація сервера"; + +/* No comment provided by engineer. */ +"In reply to" = "У відповідь на"; + +/* No comment provided by engineer. */ +"Incognito" = "Інкогніто"; + +/* No comment provided by engineer. */ +"Incognito mode" = "Режим інкогніто"; + +/* No comment provided by engineer. */ +"Incognito mode protects your privacy by using a new random profile for each contact." = "Режим інкогніто захищає вашу конфіденційність, використовуючи новий випадковий профіль для кожного контакту."; + +/* chat list item description */ +"incognito via contact address link" = "інкогніто за посиланням на контактну адресу"; + +/* chat list item description */ +"incognito via group link" = "інкогніто через групове посилання"; + +/* chat list item description */ +"incognito via one-time link" = "інкогніто за одноразовим посиланням"; + +/* notification */ +"Incoming audio call" = "Вхідний аудіовиклик"; + +/* notification */ +"Incoming call" = "Вхідний дзвінок"; + +/* notification */ +"Incoming video call" = "Вхідний відеодзвінок"; + +/* No comment provided by engineer. */ +"Incompatible database version" = "Несумісна версія бази даних"; + +/* PIN entry */ +"Incorrect passcode" = "Неправильний пароль"; + +/* No comment provided by engineer. */ +"Incorrect security code!" = "Неправильний код безпеки!"; + +/* connection level description */ +"indirect (%d)" = "непрямий (%d)"; + +/* chat item action */ +"Info" = "Інформація"; + +/* No comment provided by engineer. */ +"Initial role" = "Початкова роль"; + +/* No comment provided by engineer. */ +"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Встановіть [SimpleX Chat для терміналу](https://github.com/simplex-chat/simplex-chat)"; + +/* No comment provided by engineer. */ +"Instant push notifications will be hidden!\n" = "Миттєві пуш-сповіщення будуть приховані!\n"; + +/* No comment provided by engineer. */ +"Instantly" = "Миттєво"; + +/* No comment provided by engineer. */ +"Interface" = "Інтерфейс"; + +/* invalid chat data */ +"invalid chat" = "недійсний чат"; + +/* No comment provided by engineer. */ +"invalid chat data" = "невірні дані чату"; + +/* No comment provided by engineer. */ +"Invalid connection link" = "Неправильне посилання для підключення"; + +/* invalid chat item */ +"invalid data" = "невірні дані"; + +/* No comment provided by engineer. */ +"Invalid server address!" = "Неправильна адреса сервера!"; + +/* item status text */ +"Invalid status" = "Недійсний статус"; + +/* No comment provided by engineer. */ +"Invitation expired!" = "Термін дії запрошення закінчився!"; + +/* group name */ +"invitation to group %@" = "запрошення до групи %@"; + +/* No comment provided by engineer. */ +"Invite friends" = "Запросити друзів"; + +/* No comment provided by engineer. */ +"Invite members" = "Запросити учасників"; + +/* No comment provided by engineer. */ +"Invite to group" = "Запросити до групи"; + +/* No comment provided by engineer. */ +"invited" = "запрошені"; + +/* rcv group event chat item */ +"invited %@" = "запрошений %@"; + +/* chat list item title */ +"invited to connect" = "запрошуємо приєднатися"; + +/* rcv group event chat item */ +"invited via your group link" = "запрошені за посиланням у вашій групі"; + +/* No comment provided by engineer. */ +"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "iOS Keychain використовується для безпечного зберігання пароля - це дає змогу отримувати миттєві повідомлення."; + +/* No comment provided by engineer. */ +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль бази даних буде безпечно збережено в iOS Keychain після запуску чату або зміни пароля - це дасть змогу отримувати миттєві повідомлення."; + +/* No comment provided by engineer. */ +"Irreversible message deletion" = "Безповоротне видалення повідомлення"; + +/* No comment provided by engineer. */ +"Irreversible message deletion is prohibited in this chat." = "У цьому чаті заборонено безповоротне видалення повідомлень."; + +/* No comment provided by engineer. */ +"Irreversible message deletion is prohibited in this group." = "У цій групі заборонено безповоротне видалення повідомлень."; + +/* No comment provided by engineer. */ +"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Це дозволяє мати багато анонімних з'єднань без будь-яких спільних даних між ними в одному профілі чату."; + +/* No comment provided by engineer. */ +"It can happen when you or your connection used the old database backup." = "Це може статися, якщо ви або ваше з'єднання використовували стару резервну копію бази даних."; + +/* 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." = "Це може статися, коли:\n1. Термін дії повідомлень закінчився в клієнті-відправнику через 2 дні або на сервері через 30 днів.\n2. Не вдалося розшифрувати повідомлення, тому що ви або ваш контакт використовували стару резервну копію бази даних.\n3. З'єднання було скомпрометовано."; + +/* 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 (%@)." = "Схоже, що ви вже підключені за цим посиланням. Якщо це не так, сталася помилка (%@)."; + +/* No comment provided by engineer. */ +"Italian interface" = "Італійський інтерфейс"; + +/* No comment provided by engineer. */ +"italic" = "курсив"; + +/* No comment provided by engineer. */ +"Japanese interface" = "Японський інтерфейс"; + +/* No comment provided by engineer. */ +"Join" = "Приєднуйтесь"; + +/* No comment provided by engineer. */ +"join as %@" = "приєднатися як %@"; + +/* No comment provided by engineer. */ +"Join group" = "Приєднуйтесь до групи"; + +/* No comment provided by engineer. */ +"Join incognito" = "Приєднуйтесь інкогніто"; + +/* No comment provided by engineer. */ +"Joining group" = "Приєднання до групи"; + +/* No comment provided by engineer. */ +"Keep your connections" = "Зберігайте свої зв'язки"; + +/* No comment provided by engineer. */ +"Keychain error" = "помилка KeyChain"; + +/* No comment provided by engineer. */ +"KeyChain error" = "помилка KeyChain"; + +/* No comment provided by engineer. */ +"Large file!" = "Великий файл!"; + +/* No comment provided by engineer. */ +"Learn more" = "Дізнайтеся більше"; + +/* No comment provided by engineer. */ +"Leave" = "Залишити"; + +/* No comment provided by engineer. */ +"Leave group" = "Покинути групу"; + +/* No comment provided by engineer. */ +"Leave group?" = "Покинути групу?"; + +/* rcv group event chat item */ +"left" = "ліворуч"; + +/* email subject */ +"Let's talk in SimpleX Chat" = "Поговоримо в чаті SimpleX"; + +/* No comment provided by engineer. */ +"Light" = "Світлий"; + +/* No comment provided by engineer. */ +"Limitations" = "Обмеження"; + +/* No comment provided by engineer. */ +"LIVE" = "НАЖИВО"; + +/* No comment provided by engineer. */ +"Live message!" = "Живе повідомлення!"; + +/* No comment provided by engineer. */ +"Live messages" = "Живі повідомлення"; + +/* No comment provided by engineer. */ +"Local name" = "Місцева назва"; + +/* No comment provided by engineer. */ +"Local profile data only" = "Тільки локальні дані профілю"; + +/* No comment provided by engineer. */ +"Lock after" = "Блокування після"; + +/* No comment provided by engineer. */ +"Lock mode" = "Режим блокування"; + +/* No comment provided by engineer. */ +"Make a private connection" = "Створіть приватне з'єднання"; + +/* No comment provided by engineer. */ +"Make one message disappear" = "Зробити так, щоб одне повідомлення зникло"; + +/* No comment provided by engineer. */ +"Make profile private!" = "Зробіть профіль приватним!"; + +/* No comment provided by engineer. */ +"Make sure %@ server addresses are in correct format, line separated and are not duplicated (%@)." = "Переконайтеся, що адреси серверів %@ мають правильний формат, розділені рядками і не дублюються (%@)."; + +/* No comment provided by engineer. */ +"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Переконайтеся, що адреси серверів WebRTC ICE мають правильний формат, розділені рядками і не дублюються."; + +/* No comment provided by engineer. */ +"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Багато людей запитували: *якщо SimpleX не має ідентифікаторів користувачів, як він може доставляти повідомлення?*"; + +/* No comment provided by engineer. */ +"Mark deleted for everyone" = "Позначити видалено для всіх"; + +/* No comment provided by engineer. */ +"Mark read" = "Позначити прочитано"; + +/* No comment provided by engineer. */ +"Mark verified" = "Позначити перевірено"; + +/* No comment provided by engineer. */ +"Markdown in messages" = "Виправлення в повідомленнях"; + +/* marked deleted chat item preview text */ +"marked deleted" = "з позначкою видалено"; + +/* No comment provided by engineer. */ +"Max 30 seconds, received instantly." = "Максимум 30 секунд, отримується миттєво."; + +/* member role */ +"member" = "учасник"; + +/* No comment provided by engineer. */ +"Member" = "Учасник"; + +/* rcv group event chat item */ +"member connected" = "з'єднаний"; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All group members will be notified." = "Роль учасника буде змінено на \"%@\". Всі учасники групи будуть повідомлені про це."; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль учасника буде змінено на \"%@\". Учасник отримає нове запрошення."; + +/* No comment provided by engineer. */ +"Member will be removed from group - this cannot be undone!" = "Учасник буде видалений з групи - це неможливо скасувати!"; + +/* item status text */ +"Message delivery error" = "Помилка доставки повідомлення"; + +/* No comment provided by engineer. */ +"Message delivery receipts!" = "Підтвердження доставки повідомлення!"; + +/* No comment provided by engineer. */ +"Message draft" = "Чернетка повідомлення"; + +/* chat feature */ +"Message reactions" = "Реакції на повідомлення"; + +/* No comment provided by engineer. */ +"Message reactions are prohibited in this chat." = "Реакції на повідомлення в цьому чаті заборонені."; + +/* No comment provided by engineer. */ +"Message reactions are prohibited in this group." = "Реакції на повідомлення в цій групі заборонені."; + +/* notification */ +"message received" = "повідомлення отримано"; + +/* No comment provided by engineer. */ +"Message text" = "Текст повідомлення"; + +/* No comment provided by engineer. */ +"Messages" = "Повідомлення"; + +/* No comment provided by engineer. */ +"Messages & files" = "Повідомлення та файли"; + +/* No comment provided by engineer. */ +"Migrating database archive…" = "Перенесення архіву бази даних…"; + +/* No comment provided by engineer. */ +"Migration error:" = "Помилка міграції:"; + +/* No comment provided by engineer. */ +"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Міграція не вдалася. Натисніть **Пропустити** нижче, щоб продовжити використовувати поточну базу даних. Будь ласка, повідомте про проблему розробникам програми через чат або електронну пошту [chat@simplex.chat](mailto:chat@simplex.chat)."; + +/* No comment provided by engineer. */ +"Migration is completed" = "Міграцію завершено"; + +/* No comment provided by engineer. */ +"Migrations: %@" = "Міграції: %@"; + +/* time unit */ +"minutes" = "хвилини"; + +/* call status */ +"missed call" = "пропущений дзвінок"; + +/* chat item action */ +"Moderate" = "Модерується"; + +/* moderated chat item */ +"moderated" = "модерується"; + +/* No comment provided by engineer. */ +"Moderated at" = "Модерується на"; + +/* copied message info */ +"Moderated at: %@" = "Модерується за: %@"; + +/* No comment provided by engineer. */ +"moderated by %@" = "модерується %@"; + +/* time unit */ +"months" = "місяців"; + +/* No comment provided by engineer. */ +"More improvements are coming soon!" = "Незабаром буде ще більше покращень!"; + +/* item status description */ +"Most likely this connection is deleted." = "Швидше за все, це з'єднання видалено."; + +/* No comment provided by engineer. */ +"Most likely this contact has deleted the connection with you." = "Швидше за все, цей контакт видалив зв'язок з вами."; + +/* No comment provided by engineer. */ +"Multiple chat profiles" = "Кілька профілів чату"; + +/* No comment provided by engineer. */ +"Mute" = "Вимкнути звук"; + +/* No comment provided by engineer. */ +"Muted when inactive!" = "Вимкнено, коли неактивний!"; + +/* No comment provided by engineer. */ +"Name" = "Ім'я"; + +/* No comment provided by engineer. */ +"Network & servers" = "Мережа та сервери"; + +/* No comment provided by engineer. */ +"Network settings" = "Налаштування мережі"; + +/* No comment provided by engineer. */ +"Network status" = "Стан мережі"; + +/* No comment provided by engineer. */ +"never" = "ніколи"; + +/* notification */ +"New contact request" = "Новий запит на контакт"; + +/* notification */ +"New contact:" = "Новий контакт:"; + +/* No comment provided by engineer. */ +"New database archive" = "Новий архів бази даних"; + +/* No comment provided by engineer. */ +"New display name" = "Нове ім'я відображення"; + +/* No comment provided by engineer. */ +"New in %@" = "Нове в %@"; + +/* No comment provided by engineer. */ +"New member role" = "Нова роль учасника"; + +/* notification */ +"new message" = "нове повідомлення"; + +/* notification */ +"New message" = "Нове повідомлення"; + +/* No comment provided by engineer. */ +"New Passcode" = "Новий пароль"; + +/* No comment provided by engineer. */ +"New passphrase…" = "Новий пароль…"; + +/* pref value */ +"no" = "ні"; + +/* No comment provided by engineer. */ +"No" = "Ні"; + +/* Authentication unavailable */ +"No app password" = "Немає пароля програми"; + +/* No comment provided by engineer. */ +"No contacts selected" = "Не вибрано жодного контакту"; + +/* No comment provided by engineer. */ +"No contacts to add" = "Немає контактів для додавання"; + +/* No comment provided by engineer. */ +"No delivery information" = "Немає інформації про доставку"; + +/* No comment provided by engineer. */ +"No device token!" = "Токен пристрою відсутній!"; + +/* No comment provided by engineer. */ +"no e2e encryption" = "без шифрування e2e"; + +/* No comment provided by engineer. */ +"No filtered chats" = "Немає фільтрованих чатів"; + +/* No comment provided by engineer. */ +"No group!" = "Групу не знайдено!"; + +/* No comment provided by engineer. */ +"No history" = "Немає історії"; + +/* No comment provided by engineer. */ +"No permission to record voice message" = "Немає дозволу на запис голосового повідомлення"; + +/* No comment provided by engineer. */ +"No received or sent files" = "Немає отриманих або відправлених файлів"; + +/* copied message info in history */ +"no text" = "без тексту"; + +/* No comment provided by engineer. */ +"Notifications" = "Сповіщення"; + +/* No comment provided by engineer. */ +"Notifications are disabled!" = "Сповіщення вимкнено!"; + +/* No comment provided by engineer. */ +"Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Тепер адміністратори можуть\n- видаляти повідомлення користувачів.\n- відключати користувачів (роль \"спостерігач\")"; + +/* member role */ +"observer" = "спостерігач"; + +/* enabled status + group pref value */ +"off" = "вимкнено"; + +/* No comment provided by engineer. */ +"Off" = "Вимкнено"; + +/* No comment provided by engineer. */ +"Off (Local)" = "Вимкнено (локально)"; + +/* feature offered item */ +"offered %@" = "запропоновано %@"; + +/* feature offered item */ +"offered %@: %@" = "запропонував %1$@: %2$@"; + +/* No comment provided by engineer. */ +"Ok" = "Гаразд"; + +/* No comment provided by engineer. */ +"Old database" = "Стара база даних"; + +/* No comment provided by engineer. */ +"Old database archive" = "Старий архів бази даних"; + +/* group pref value */ +"on" = "увімкнено"; + +/* No comment provided by engineer. */ +"One-time invitation link" = "Посилання на одноразове запрошення"; + +/* No comment provided by engineer. */ +"Onion hosts will be required for connection. Requires enabling VPN." = "Для підключення будуть потрібні хости onion. Потрібно увімкнути VPN."; + +/* No comment provided by engineer. */ +"Onion hosts will be used when available. Requires enabling VPN." = "Onion хости будуть використовуватися, коли вони будуть доступні. Потрібно увімкнути VPN."; + +/* No comment provided by engineer. */ +"Onion hosts will not be used." = "Onion хости не будуть використовуватися."; + +/* No comment provided by engineer. */ +"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Тільки клієнтські пристрої зберігають профілі користувачів, контакти, групи та повідомлення, надіслані за допомогою **2-шарового наскрізного шифрування**."; + +/* No comment provided by engineer. */ +"Only group owners can change group preferences." = "Тільки власники груп можуть змінювати налаштування групи."; + +/* No comment provided by engineer. */ +"Only group owners can enable files and media." = "Тільки власники груп можуть вмикати файли та медіа."; + +/* No comment provided by engineer. */ +"Only group owners can enable voice messages." = "Тільки власники груп можуть вмикати голосові повідомлення."; + +/* No comment provided by engineer. */ +"Only you can add message reactions." = "Тільки ви можете додавати реакції на повідомлення."; + +/* No comment provided by engineer. */ +"Only you can irreversibly delete messages (your contact can mark them for deletion)." = "Тільки ви можете безповоротно видалити повідомлення (ваш контакт може позначити їх для видалення)."; + +/* No comment provided by engineer. */ +"Only you can make calls." = "Дзвонити можете тільки ви."; + +/* No comment provided by engineer. */ +"Only you can send disappearing messages." = "Тільки ви можете надсилати зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Only you can send voice messages." = "Тільки ви можете надсилати голосові повідомлення."; + +/* No comment provided by engineer. */ +"Only your contact can add message reactions." = "Тільки ваш контакт може додавати реакції на повідомлення."; + +/* No comment provided by engineer. */ +"Only your contact can irreversibly delete messages (you can mark them for deletion)." = "Тільки ваш контакт може безповоротно видалити повідомлення (ви можете позначити їх для видалення)."; + +/* No comment provided by engineer. */ +"Only your contact can make calls." = "Тільки ваш контакт може здійснювати дзвінки."; + +/* No comment provided by engineer. */ +"Only your contact can send disappearing messages." = "Тільки ваш контакт може надсилати зникаючі повідомлення."; + +/* No comment provided by engineer. */ +"Only your contact can send voice messages." = "Тільки ваш контакт може надсилати голосові повідомлення."; + +/* No comment provided by engineer. */ +"Open chat" = "Відкритий чат"; + +/* authentication reason */ +"Open chat console" = "Відкрийте консоль чату"; + +/* No comment provided by engineer. */ +"Open Settings" = "Відкрийте Налаштування"; + +/* authentication reason */ +"Open user profiles" = "Відкрити профілі користувачів"; + +/* No comment provided by engineer. */ +"Open-source protocol and code – anybody can run the servers." = "Протокол і код з відкритим вихідним кодом - будь-хто може запускати сервери."; + +/* 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" = "власник"; + +/* No comment provided by engineer. */ +"Passcode" = "Пароль"; + +/* No comment provided by engineer. */ +"Passcode changed!" = "Пароль змінено!"; + +/* No comment provided by engineer. */ +"Passcode entry" = "Введення пароля"; + +/* No comment provided by engineer. */ +"Passcode not changed!" = "Пароль не змінено!"; + +/* No comment provided by engineer. */ +"Passcode set!" = "Пароль встановлено!"; + +/* No comment provided by engineer. */ +"Password to show" = "Показати пароль"; + +/* No comment provided by engineer. */ +"Paste" = "Вставити"; + +/* No comment provided by engineer. */ +"Paste image" = "Вставити зображення"; + +/* No comment provided by engineer. */ +"Paste received link" = "Вставте отримане посилання"; + +/* placeholder */ +"Paste the link you received to connect with your contact." = "Вставте отримане посилання для зв'язку з вашим контактом."; + +/* No comment provided by engineer. */ +"peer-to-peer" = "одноранговий"; + +/* No comment provided by engineer. */ +"People can connect to you only via the links you share." = "Люди можуть зв'язатися з вами лише за посиланнями, якими ви ділитеся."; + +/* No comment provided by engineer. */ +"Periodically" = "Періодично"; + +/* message decrypt error item */ +"Permanent decryption error" = "Постійна помилка розшифрування"; + +/* No comment provided by engineer. */ +"PING count" = "Кількість PING"; + +/* No comment provided by engineer. */ +"PING interval" = "Інтервал PING"; + +/* No comment provided by engineer. */ +"Please ask your contact to enable sending voice messages." = "Будь ласка, попросіть вашого контакту увімкнути відправку голосових повідомлень."; + +/* No comment provided by engineer. */ +"Please check that you used the correct link or ask your contact to send you another one." = "Будь ласка, перевірте, чи ви скористалися правильним посиланням, або попросіть контактну особу надіслати вам інше."; + +/* No comment provided by engineer. */ +"Please check your network connection with %@ and try again." = "Будь ласка, перевірте підключення до мережі за допомогою %@ і спробуйте ще раз."; + +/* No comment provided by engineer. */ +"Please check yours and your contact preferences." = "Будь ласка, перевірте свої та контактні налаштування."; + +/* No comment provided by engineer. */ +"Please contact group admin." = "Зверніться до адміністратора групи."; + +/* No comment provided by engineer. */ +"Please enter correct current passphrase." = "Будь ласка, введіть правильний поточний пароль."; + +/* No comment provided by engineer. */ +"Please enter the previous password after restoring database backup. This action can not be undone." = "Будь ласка, введіть попередній пароль після відновлення резервної копії бази даних. Ця дія не може бути скасована."; + +/* No comment provided by engineer. */ +"Please remember or store it securely - there is no way to recover a lost passcode!" = "Будь ласка, запам'ятайте або надійно зберігайте його - втрачений пароль неможливо відновити!"; + +/* No comment provided by engineer. */ +"Please report it to the developers." = "Будь ласка, повідомте про це розробникам."; + +/* No comment provided by engineer. */ +"Please restart the app and migrate the database to enable push notifications." = "Будь ласка, перезапустіть додаток і перенесіть базу даних, щоб увімкнути push-сповіщення."; + +/* No comment provided by engineer. */ +"Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Будь ласка, зберігайте пароль надійно, ви НЕ зможете отримати доступ до чату, якщо втратите його."; + +/* No comment provided by engineer. */ +"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Будь ласка, зберігайте пароль надійно, ви НЕ зможете змінити його, якщо втратите."; + +/* No comment provided by engineer. */ +"Polish interface" = "Польський інтерфейс"; + +/* server test error */ +"Possibly, certificate fingerprint in server address is incorrect" = "Можливо, в адресі сервера неправильно вказано відбиток сертифіката"; + +/* No comment provided by engineer. */ +"Preserve the last message draft, with attachments." = "Зберегти чернетку останнього повідомлення з вкладеннями."; + +/* No comment provided by engineer. */ +"Preset server" = "Попередньо встановлений сервер"; + +/* No comment provided by engineer. */ +"Preset server address" = "Попередньо встановлена адреса сервера"; + +/* No comment provided by engineer. */ +"Preview" = "Попередній перегляд"; + +/* No comment provided by engineer. */ +"Privacy & security" = "Конфіденційність і безпека"; + +/* No comment provided by engineer. */ +"Privacy redefined" = "Конфіденційність переглянута"; + +/* No comment provided by engineer. */ +"Private filenames" = "Приватні імена файлів"; + +/* No comment provided by engineer. */ +"Profile and server connections" = "З'єднання профілю та сервера"; + +/* No comment provided by engineer. */ +"Profile image" = "Зображення профілю"; + +/* No comment provided by engineer. */ +"Profile password" = "Пароль до профілю"; + +/* No comment provided by engineer. */ +"Profile update will be sent to your contacts." = "Оновлення профілю буде надіслано вашим контактам."; + +/* No comment provided by engineer. */ +"Prohibit audio/video calls." = "Заборонити аудіо/відеодзвінки."; + +/* No comment provided by engineer. */ +"Prohibit irreversible message deletion." = "Заборонити незворотне видалення повідомлень."; + +/* No comment provided by engineer. */ +"Prohibit message reactions." = "Заборонити реакцію на повідомлення."; + +/* No comment provided by engineer. */ +"Prohibit messages reactions." = "Заборонити реакції на повідомлення."; + +/* No comment provided by engineer. */ +"Prohibit sending direct messages to members." = "Заборонити надсилати прямі повідомлення учасникам."; + +/* No comment provided by engineer. */ +"Prohibit sending disappearing messages." = "Заборонити надсилання зникаючих повідомлень."; + +/* No comment provided by engineer. */ +"Prohibit sending files and media." = "Заборонити надсилання файлів і медіа."; + +/* No comment provided by engineer. */ +"Prohibit sending voice messages." = "Заборонити надсилання голосових повідомлень."; + +/* No comment provided by engineer. */ +"Protect app screen" = "Захистіть екран програми"; + +/* No comment provided by engineer. */ +"Protect your chat profiles with a password!" = "Захистіть свої профілі чату паролем!"; + +/* No comment provided by engineer. */ +"Protocol timeout" = "Тайм-аут протоколу"; + +/* No comment provided by engineer. */ +"Protocol timeout per KB" = "Тайм-аут протоколу на КБ"; + +/* No comment provided by engineer. */ +"Push notifications" = "Push-повідомлення"; + +/* No comment provided by engineer. */ +"Rate the app" = "Оцініть додаток"; + +/* chat item menu */ +"React…" = "Реагуй…"; + +/* No comment provided by engineer. */ +"Read" = "Читати"; + +/* No comment provided by engineer. */ +"Read more" = "Читати далі"; + +/* No comment provided by engineer. */ +"Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address)." = "Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address)."; + +/* No comment provided by engineer. */ +"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; + +/* No comment provided by engineer. */ +"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Читайте більше в нашому [GitHub репозиторії](https://github.com/simplex-chat/simplex-chat#readme)."; + +/* No comment provided by engineer. */ +"Read more in our GitHub repository." = "Читайте більше в нашому репозиторії на GitHub."; + +/* No comment provided by engineer. */ +"Receipts are disabled" = "Підтвердження виключені"; + +/* No comment provided by engineer. */ +"received answer…" = "отримали відповідь…"; + +/* No comment provided by engineer. */ +"Received at" = "Отримано за"; + +/* copied message info */ +"Received at: %@" = "Отримано за: %@"; + +/* No comment provided by engineer. */ +"received confirmation…" = "отримали підтвердження…"; + +/* notification */ +"Received file event" = "Подія отримання файлу"; + +/* message info title */ +"Received message" = "Отримано повідомлення"; + +/* No comment provided by engineer. */ +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "Адреса отримувача буде змінена на інший сервер. Зміна адреси завершиться після того, як відправник з'явиться в мережі."; + +/* No comment provided by engineer. */ +"Receiving file will be stopped." = "Отримання файлу буде зупинено."; + +/* No comment provided by engineer. */ +"Receiving via" = "Отримання через"; + +/* No comment provided by engineer. */ +"Recipients see updates as you type them." = "Одержувачі бачать оновлення, коли ви їх вводите."; + +/* No comment provided by engineer. */ +"Reconnect all connected servers to force message delivery. It uses additional traffic." = "Перепідключіть всі підключені сервери, щоб примусово доставити повідомлення. Це використовує додатковий трафік."; + +/* No comment provided by engineer. */ +"Reconnect servers?" = "Перепідключити сервери?"; + +/* No comment provided by engineer. */ +"Record updated at" = "Запис оновлено за"; + +/* copied message info */ +"Record updated at: %@" = "Запис оновлено за: %@"; + +/* No comment provided by engineer. */ +"Reduced battery usage" = "Зменшення використання акумулятора"; + +/* reject incoming call via notification */ +"Reject" = "Відхилити"; + +/* No comment provided by engineer. */ +"Reject (sender NOT notified)" = "Відхилити (відправника НЕ повідомлено)"; + +/* No comment provided by engineer. */ +"Reject contact request" = "Відхилити запит на контакт"; + +/* call status */ +"rejected call" = "відхилений виклик"; + +/* No comment provided by engineer. */ +"Relay server is only used if necessary. Another party can observe your IP address." = "Релейний сервер використовується тільки в разі потреби. Інша сторона може бачити вашу IP-адресу."; + +/* No comment provided by engineer. */ +"Relay server protects your IP address, but it can observe the duration of the call." = "Сервер ретрансляції захищає вашу IP-адресу, але він може спостерігати за тривалістю дзвінка."; + +/* No comment provided by engineer. */ +"Remove" = "Видалити"; + +/* No comment provided by engineer. */ +"Remove member" = "Видалити учасника"; + +/* No comment provided by engineer. */ +"Remove member?" = "Видалити учасника?"; + +/* No comment provided by engineer. */ +"Remove passphrase from keychain?" = "Видалити парольну фразу з брелока?"; + +/* No comment provided by engineer. */ +"removed" = "видалено"; + +/* rcv group event chat item */ +"removed %@" = "видалено %@"; + +/* rcv group event chat item */ +"removed you" = "прибрали вас"; + +/* No comment provided by engineer. */ +"Renegotiate" = "Переузгодьте"; + +/* No comment provided by engineer. */ +"Renegotiate encryption" = "Переузгодьте шифрування"; + +/* No comment provided by engineer. */ +"Renegotiate encryption?" = "Переузгодьте шифрування?"; + +/* chat item action */ +"Reply" = "Відповісти"; + +/* No comment provided by engineer. */ +"Required" = "Потрібно"; + +/* No comment provided by engineer. */ +"Reset" = "Перезавантаження"; + +/* No comment provided by engineer. */ +"Reset colors" = "Скинути кольори"; + +/* No comment provided by engineer. */ +"Reset to defaults" = "Відновити налаштування за замовчуванням"; + +/* No comment provided by engineer. */ +"Restart the app to create a new chat profile" = "Перезапустіть програму, щоб створити новий профіль чату"; + +/* No comment provided by engineer. */ +"Restart the app to use imported chat database" = "Перезапустіть програму, щоб використовувати імпортовану базу даних чату"; + +/* No comment provided by engineer. */ +"Restore" = "Відновити"; + +/* No comment provided by engineer. */ +"Restore database backup" = "Відновлення резервної копії бази даних"; + +/* No comment provided by engineer. */ +"Restore database backup?" = "Відновити резервну копію бази даних?"; + +/* No comment provided by engineer. */ +"Restore database error" = "Відновлення помилки бази даних"; + +/* chat item action */ +"Reveal" = "Показувати"; + +/* No comment provided by engineer. */ +"Revert" = "Повернутися"; + +/* No comment provided by engineer. */ +"Revoke" = "Відкликати"; + +/* cancel file action */ +"Revoke file" = "Відкликати файл"; + +/* No comment provided by engineer. */ +"Revoke file?" = "Відкликати файл?"; + +/* No comment provided by engineer. */ +"Role" = "Роль"; + +/* No comment provided by engineer. */ +"Run chat" = "Запустити чат"; + +/* chat item action */ +"Save" = "Зберегти"; + +/* No comment provided by engineer. */ +"Save (and notify contacts)" = "Зберегти (і повідомити контактам)"; + +/* No comment provided by engineer. */ +"Save and notify contact" = "Зберегти та повідомити контакт"; + +/* No comment provided by engineer. */ +"Save and notify group members" = "Зберегти та повідомити учасників групи"; + +/* No comment provided by engineer. */ +"Save and update group profile" = "Збереження та оновлення профілю групи"; + +/* No comment provided by engineer. */ +"Save archive" = "Зберегти архів"; + +/* No comment provided by engineer. */ +"Save auto-accept settings" = "Зберегти налаштування автоприйому"; + +/* No comment provided by engineer. */ +"Save group profile" = "Зберегти профіль групи"; + +/* No comment provided by engineer. */ +"Save passphrase and open chat" = "Збережіть пароль і відкрийте чат"; + +/* No comment provided by engineer. */ +"Save passphrase in Keychain" = "Збережіть парольну фразу в Keychain"; + +/* No comment provided by engineer. */ +"Save preferences?" = "Зберегти налаштування?"; + +/* No comment provided by engineer. */ +"Save profile password" = "Зберегти пароль профілю"; + +/* No comment provided by engineer. */ +"Save servers" = "Зберегти сервери"; + +/* No comment provided by engineer. */ +"Save servers?" = "Зберегти сервери?"; + +/* No comment provided by engineer. */ +"Save settings?" = "Зберегти налаштування?"; + +/* No comment provided by engineer. */ +"Save welcome message?" = "Зберегти вітальне повідомлення?"; + +/* No comment provided by engineer. */ +"Saved WebRTC ICE servers will be removed" = "Збережені сервери WebRTC ICE буде видалено"; + +/* No comment provided by engineer. */ +"Scan code" = "Сканувати код"; + +/* No comment provided by engineer. */ +"Scan QR code" = "Відскануйте QR-код"; + +/* No comment provided by engineer. */ +"Scan security code from your contact's app." = "Відскануйте код безпеки з додатку вашого контакту."; + +/* No comment provided by engineer. */ +"Scan server QR code" = "Відскануйте QR-код сервера"; + +/* No comment provided by engineer. */ +"Search" = "Пошук"; + +/* network option */ +"sec" = "сек"; + +/* time unit */ +"seconds" = "секунди"; + +/* No comment provided by engineer. */ +"secret" = "таємниця"; + +/* server test step */ +"Secure queue" = "Безпечна черга"; + +/* No comment provided by engineer. */ +"Security assessment" = "Оцінка безпеки"; + +/* No comment provided by engineer. */ +"Security code" = "Код безпеки"; + +/* chat item text */ +"security code changed" = "змінено код безпеки"; + +/* No comment provided by engineer. */ +"Select" = "Виберіть"; + +/* No comment provided by engineer. */ +"Self-destruct" = "Самознищення"; + +/* No comment provided by engineer. */ +"Self-destruct passcode" = "Пароль самознищення"; + +/* No comment provided by engineer. */ +"Self-destruct passcode changed!" = "Пароль самознищення змінено!"; + +/* No comment provided by engineer. */ +"Self-destruct passcode enabled!" = "Пароль самознищення ввімкнено!"; + +/* No comment provided by engineer. */ +"Send" = "Надіслати"; + +/* No comment provided by engineer. */ +"Send a live message - it will update for the recipient(s) as you type it" = "Надішліть повідомлення в реальному часі - воно буде оновлюватися для одержувача (одержувачів), поки ви його вводите"; + +/* No comment provided by engineer. */ +"Send delivery receipts to" = "Надсилання звітів про доставку"; + +/* No comment provided by engineer. */ +"Send direct message" = "Надішліть пряме повідомлення"; + +/* No comment provided by engineer. */ +"Send disappearing message" = "Надіслати зникаюче повідомлення"; + +/* No comment provided by engineer. */ +"Send link previews" = "Надіслати попередній перегляд за посиланням"; + +/* No comment provided by engineer. */ +"Send live message" = "Надіслати живе повідомлення"; + +/* No comment provided by engineer. */ +"Send notifications" = "Надсилати сповіщення"; + +/* No comment provided by engineer. */ +"Send notifications:" = "Надсилати сповіщення:"; + +/* No comment provided by engineer. */ +"Send questions and ideas" = "Надсилайте запитання та ідеї"; + +/* No comment provided by engineer. */ +"Send receipts" = "Надіслати підтвердження"; + +/* No comment provided by engineer. */ +"Send them from gallery or custom keyboards." = "Надсилайте їх із галереї чи власних клавіатур."; + +/* No comment provided by engineer. */ +"Sender cancelled file transfer." = "Відправник скасував передачу файлу."; + +/* No comment provided by engineer. */ +"Sender may have deleted the connection request." = "Можливо, відправник видалив запит на підключення."; + +/* No comment provided by engineer. */ +"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "Надсилання підтверджень доставки буде ввімкнено для всіх контактів у всіх видимих профілях чату."; + +/* No comment provided by engineer. */ +"Sending delivery receipts will be enabled for all contacts." = "Надсилання підтверджень доставки буде ввімкнено для всіх контактів."; + +/* No comment provided by engineer. */ +"Sending file will be stopped." = "Надсилання файлу буде зупинено."; + +/* No comment provided by engineer. */ +"Sending receipts is disabled for %lld contacts" = "Надсилання підтвердження вимкнено для контактів %lld"; + +/* No comment provided by engineer. */ +"Sending receipts is disabled for %lld groups" = "Відправлення підтверджень вимкнено для груп %lld"; + +/* No comment provided by engineer. */ +"Sending receipts is enabled for %lld contacts" = "Для контактів %lld увімкнено надсилання підтвердження"; + +/* No comment provided by engineer. */ +"Sending receipts is enabled for %lld groups" = "Для груп %lld увімкнено надсилання підтвердження"; + +/* No comment provided by engineer. */ +"Sending via" = "Надсилання через"; + +/* No comment provided by engineer. */ +"Sent at" = "Надіслано за"; + +/* copied message info */ +"Sent at: %@" = "Надіслано за: %@"; + +/* notification */ +"Sent file event" = "Подія надісланого файлу"; + +/* message info title */ +"Sent message" = "Надіслано повідомлення"; + +/* No comment provided by engineer. */ +"Sent messages will be deleted after set time." = "Надіслані повідомлення будуть видалені через встановлений час."; + +/* server test error */ +"Server requires authorization to create queues, check password" = "Сервер вимагає авторизації для створення черг, перевірте пароль"; + +/* server test error */ +"Server requires authorization to upload, check password" = "Сервер вимагає авторизації для завантаження, перевірте пароль"; + +/* No comment provided by engineer. */ +"Server test failed!" = "Тест сервера завершився невдало!"; + +/* No comment provided by engineer. */ +"Servers" = "Сервери"; + +/* No comment provided by engineer. */ +"Set 1 day" = "Встановити 1 день"; + +/* No comment provided by engineer. */ +"Set contact name…" = "Встановити ім'я контакту…"; + +/* No comment provided by engineer. */ +"Set group preferences" = "Встановіть налаштування групи"; + +/* No comment provided by engineer. */ +"Set it instead of system authentication." = "Встановіть його замість аутентифікації системи."; + +/* No comment provided by engineer. */ +"Set passcode" = "Встановити пароль"; + +/* No comment provided by engineer. */ +"Set passphrase to export" = "Встановити ключову фразу для експорту"; + +/* No comment provided by engineer. */ +"Set the message shown to new members!" = "Налаштуйте повідомлення, яке показуватиметься новим користувачам!"; + +/* No comment provided by engineer. */ +"Set timeouts for proxy/VPN" = "Встановлення таймаутів для проксі/VPN"; + +/* No comment provided by engineer. */ +"Settings" = "Налаштування"; + +/* chat item action */ +"Share" = "Поділіться"; + +/* No comment provided by engineer. */ +"Share 1-time link" = "Поділитися 1-разовим посиланням"; + +/* No comment provided by engineer. */ +"Share address" = "Поділитися адресою"; + +/* No comment provided by engineer. */ +"Share address with contacts?" = "Поділіться адресою з контактами?"; + +/* No comment provided by engineer. */ +"Share link" = "Поділіться посиланням"; + +/* No comment provided by engineer. */ +"Share one-time invitation link" = "Поділіться посиланням на одноразове запрошення"; + +/* No comment provided by engineer. */ +"Share with contacts" = "Поділіться з контактами"; + +/* No comment provided by engineer. */ +"Show calls in phone history" = "Показувати дзвінки в історії дзвінків"; + +/* No comment provided by engineer. */ +"Show developer options" = "Показати опції розробника"; + +/* No comment provided by engineer. */ +"Show last messages" = "Показати останні повідомлення"; + +/* No comment provided by engineer. */ +"Show preview" = "Показати попередній перегляд"; + +/* No comment provided by engineer. */ +"Show:" = "Показати:"; + +/* No comment provided by engineer. */ +"SimpleX address" = "Адреса SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX Address" = "Адреса SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX Chat security was audited by Trail of Bits." = "Безпека SimpleX Chat була перевірена компанією Trail of Bits."; + +/* simplex link type */ +"SimpleX contact address" = "Контактна адреса SimpleX"; + +/* notification */ +"SimpleX encrypted message or connection event" = "Зашифроване повідомлення SimpleX або подія підключення"; + +/* simplex link type */ +"SimpleX group link" = "Посилання на групу SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX links" = "Посилання SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX Lock" = "SimpleX Lock"; + +/* No comment provided by engineer. */ +"SimpleX Lock mode" = "Режим SimpleX Lock"; + +/* No comment provided by engineer. */ +"SimpleX Lock not enabled!" = "SimpleX Lock не ввімкнено!"; + +/* No comment provided by engineer. */ +"SimpleX Lock turned on" = "SimpleX Lock увімкнено"; + +/* simplex link type */ +"SimpleX one-time invitation" = "Одноразове запрошення SimpleX"; + +/* No comment provided by engineer. */ +"Skip" = "Пропустити"; + +/* No comment provided by engineer. */ +"Skipped messages" = "Пропущені повідомлення"; + +/* No comment provided by engineer. */ +"Small groups (max 20)" = "Невеликі групи (максимум 20 осіб)"; + +/* No comment provided by engineer. */ +"SMP servers" = "Сервери SMP"; + +/* No comment provided by engineer. */ +"Some non-fatal errors occurred during import - you may see Chat console for more details." = "Під час імпорту виникли деякі нефатальні помилки – ви можете переглянути консоль чату, щоб дізнатися більше."; + +/* notification title */ +"Somebody" = "Хтось"; + +/* No comment provided by engineer. */ +"Start a new chat" = "Почніть новий чат"; + +/* No comment provided by engineer. */ +"Start chat" = "Почати чат"; + +/* No comment provided by engineer. */ +"Start migration" = "Почати міграцію"; + +/* No comment provided by engineer. */ +"starting…" = "починаючи…"; + +/* No comment provided by engineer. */ +"Stop" = "Зупинити"; + +/* No comment provided by engineer. */ +"Stop chat to enable database actions" = "Зупиніть чат, щоб увімкнути дії з базою даних"; + +/* No comment provided by engineer. */ +"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Зупиніть чат, щоб експортувати, імпортувати або видалити базу даних чату. Ви не зможете отримувати та надсилати повідомлення, поки чат зупинено."; + +/* No comment provided by engineer. */ +"Stop chat?" = "Зупинити чат?"; + +/* cancel file action */ +"Stop file" = "Зупинити файл"; + +/* No comment provided by engineer. */ +"Stop receiving file?" = "Припинити отримання файлу?"; + +/* No comment provided by engineer. */ +"Stop sending file?" = "Припинити надсилання файлу?"; + +/* No comment provided by engineer. */ +"Stop sharing" = "Припиніть ділитися"; + +/* No comment provided by engineer. */ +"Stop sharing address?" = "Припинити ділитися адресою?"; + +/* authentication reason */ +"Stop SimpleX" = "Зупинити SimpleX"; + +/* No comment provided by engineer. */ +"strike" = "закреслено"; + +/* No comment provided by engineer. */ +"Submit" = "Надіслати"; + +/* No comment provided by engineer. */ +"Support SimpleX Chat" = "Підтримка чату SimpleX"; + +/* No comment provided by engineer. */ +"System" = "Система"; + +/* No comment provided by engineer. */ +"System authentication" = "Автентифікація системи"; + +/* No comment provided by engineer. */ +"Take picture" = "Сфотографуйте"; + +/* No comment provided by engineer. */ +"Tap button " = "Натисніть кнопку "; + +/* No comment provided by engineer. */ +"Tap to activate profile." = "Натисніть, щоб активувати профіль."; + +/* No comment provided by engineer. */ +"Tap to join" = "Натисніть, щоб приєднатися"; + +/* No comment provided by engineer. */ +"Tap to join incognito" = "Натисніть, щоб приєднатися інкогніто"; + +/* No comment provided by engineer. */ +"Tap to start a new chat" = "Натисніть, щоб почати новий чат"; + +/* No comment provided by engineer. */ +"TCP connection timeout" = "Тайм-аут TCP-з'єднання"; + +/* No comment provided by engineer. */ +"TCP_KEEPCNT" = "TCP_KEEPCNT"; + +/* No comment provided by engineer. */ +"TCP_KEEPIDLE" = "TCP_KEEPIDLE"; + +/* No comment provided by engineer. */ +"TCP_KEEPINTVL" = "TCP_KEEPINTVL"; + +/* server test failure */ +"Test failed at step %@." = "Тест завершився невдало на кроці %@."; + +/* No comment provided by engineer. */ +"Test server" = "Тестовий сервер"; + +/* No comment provided by engineer. */ +"Test servers" = "Тестові сервери"; + +/* No comment provided by engineer. */ +"Tests failed!" = "Тести не пройшли!"; + +/* No comment provided by engineer. */ +"Thank you for installing SimpleX Chat!" = "Дякуємо, що встановили SimpleX Chat!"; + +/* No comment provided by engineer. */ +"Thanks to the users – [contribute via 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. */ +"Thanks to the users – contribute via Weblate!" = "Дякуємо користувачам - зробіть свій внесок через Weblate!"; + +/* No comment provided by engineer. */ +"The 1st platform without any user identifiers – private by design." = "Перша платформа без жодних ідентифікаторів користувачів – приватна за дизайном."; + +/* No comment provided by engineer. */ +"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Додаток може сповіщати вас, коли ви отримуєте повідомлення або запити на контакт - будь ласка, відкрийте налаштування, щоб увімкнути цю функцію."; + +/* No comment provided by engineer. */ +"The attempt to change database passphrase was not completed." = "Спроба змінити пароль до бази даних не була завершена."; + +/* No comment provided by engineer. */ +"The connection you accepted will be cancelled!" = "Прийняте вами з'єднання буде скасовано!"; + +/* No comment provided by engineer. */ +"The contact you shared this link with will NOT be able to connect!" = "Контакт, з яким ви поділилися цим посиланням, НЕ зможе підключитися!"; + +/* No comment provided by engineer. */ +"The created archive is available via app Settings / Database / Old database archive." = "Створений архів доступний через Налаштування програми / База даних / Старий архів бази даних."; + +/* 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." = "Хеш попереднього повідомлення відрізняється."; + +/* No comment provided by engineer. */ +"The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "Ідентифікатор наступного повідомлення неправильний (менше або дорівнює попередньому).\nЦе може статися через помилку або коли з'єднання скомпрометовано."; + +/* No comment provided by engineer. */ +"The message will be deleted for all members." = "Повідомлення буде видалено для всіх учасників."; + +/* No comment provided by engineer. */ +"The message will be marked as moderated for all members." = "Повідомлення буде позначено як модероване для всіх учасників."; + +/* No comment provided by engineer. */ +"The next generation of private messaging" = "Наступне покоління приватних повідомлень"; + +/* No comment provided by engineer. */ +"The old database was not removed during the migration, it can be deleted." = "Стара база даних не була видалена під час міграції, її можна видалити."; + +/* No comment provided by engineer. */ +"The profile is only shared with your contacts." = "Профіль доступний лише вашим контактам."; + +/* No comment provided by engineer. */ +"The second tick we missed! ✅" = "Другу галочку ми пропустили! ✅"; + +/* No comment provided by engineer. */ +"The sender will NOT be notified" = "Відправник НЕ буде повідомлений"; + +/* No comment provided by engineer. */ +"The servers for new connections of your current chat profile **%@**." = "Сервери для нових підключень вашого поточного профілю чату **%@**."; + +/* No comment provided by engineer. */ +"Theme" = "Тема"; + +/* No comment provided by engineer. */ +"There should be at least one user profile." = "Повинен бути принаймні один профіль користувача."; + +/* No comment provided by engineer. */ +"There should be at least one visible user profile." = "Повинен бути принаймні один видимий профіль користувача."; + +/* No comment provided by engineer. */ +"These settings are for your current profile **%@**." = "Ці налаштування стосуються вашого поточного профілю **%@**."; + +/* No comment provided by engineer. */ +"They can be overridden in contact and group settings." = "Їх можна перевизначити в налаштуваннях контактів і груп."; + +/* No comment provided by engineer. */ +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Цю дію неможливо скасувати - всі отримані та надіслані файли і медіа будуть видалені. Зображення з низькою роздільною здатністю залишаться."; + +/* No comment provided by engineer. */ +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Цю дію неможливо скасувати - повідомлення, надіслані та отримані раніше, ніж вибрані, будуть видалені. Це може зайняти кілька хвилин."; + +/* No comment provided by engineer. */ +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Цю дію неможливо скасувати - ваш профіль, контакти, повідомлення та файли будуть безповоротно втрачені."; + +/* notification title */ +"this contact" = "цей контакт"; + +/* No comment provided by engineer. */ +"This group has over %lld members, delivery receipts are not sent." = "У цій групі більше %lld учасників, підтвердження доставки не надсилаються."; + +/* No comment provided by engineer. */ +"This group no longer exists." = "Цієї групи більше не існує."; + +/* No comment provided by engineer. */ +"This setting applies to messages in your current chat profile **%@**." = "Це налаштування застосовується до повідомлень у вашому поточному профілі чату **%@**."; + +/* No comment provided by engineer. */ +"To ask any questions and to receive updates:" = "Задати будь-які питання та отримувати новини:"; + +/* No comment provided by engineer. */ +"To connect, your contact can scan QR code or use the link in the app." = "Щоб підключитися, ваш контакт може відсканувати QR-код або скористатися посиланням у додатку."; + +/* No comment provided by engineer. */ +"To make a new connection" = "Щоб створити нове з'єднання"; + +/* No comment provided by engineer. */ +"To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." = "Щоб захистити конфіденційність, замість ідентифікаторів користувачів, які використовуються на всіх інших платформах, SimpleX має ідентифікатори для черг повідомлень, окремі для кожного з ваших контактів."; + +/* No comment provided by engineer. */ +"To protect timezone, image/voice files use UTC." = "Для захисту часового поясу у файлах зображень/голосу використовується UTC."; + +/* No comment provided by engineer. */ +"To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Щоб захистити вашу інформацію, увімкніть SimpleX Lock.\nПеред увімкненням цієї функції вам буде запропоновано пройти автентифікацію."; + +/* No comment provided by engineer. */ +"To record voice message please grant permission to use Microphone." = "Щоб записати голосове повідомлення, будь ласка, надайте дозвіл на використання мікрофону."; + +/* No comment provided by engineer. */ +"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Щоб відкрити свій прихований профіль, введіть повний пароль у поле пошуку на сторінці **Ваші профілі чату**."; + +/* No comment provided by engineer. */ +"To support instant push notifications the chat database has to be migrated." = "Для підтримки миттєвих push-повідомлень необхідно перенести базу даних чату."; + +/* 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. */ +"Transport isolation" = "Транспортна ізоляція"; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту (помилка: %@)."; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact." = "Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту."; + +/* No comment provided by engineer. */ +"Turn off" = "Вимкнути"; + +/* No comment provided by engineer. */ +"Turn off notifications?" = "Вимкнути сповіщення?"; + +/* No comment provided by engineer. */ +"Turn on" = "Ввімкнути"; + +/* No comment provided by engineer. */ +"Unable to record voice message" = "Не вдається записати голосове повідомлення"; + +/* item status description */ +"Unexpected error: %@" = "Неочікувана помилка: %@"; + +/* No comment provided by engineer. */ +"Unexpected migration state" = "Неочікуваний стан міграції"; + +/* No comment provided by engineer. */ +"Unfav." = "Нелюб."; + +/* No comment provided by engineer. */ +"Unhide" = "Показати"; + +/* No comment provided by engineer. */ +"Unhide chat profile" = "Показати профіль чату"; + +/* No comment provided by engineer. */ +"Unhide profile" = "Показати профіль"; + +/* No comment provided by engineer. */ +"Unit" = "Одиниця"; + +/* connection info */ +"unknown" = "невідомий"; + +/* callkit banner */ +"Unknown caller" = "Невідомий абонент"; + +/* No comment provided by engineer. */ +"Unknown database error: %@" = "Невідома помилка бази даних: %@"; + +/* No comment provided by engineer. */ +"Unknown error" = "Невідома помилка"; + +/* No comment provided by engineer. */ +"Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "Якщо ви не користуєтеся інтерфейсом виклику iOS, увімкніть режим \"Не турбувати\", щоб уникнути переривань."; + +/* 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." = "Якщо ваш контакт не видалив з'єднання або якщо це посилання вже використовувалося, це може бути помилкою - будь ласка, повідомте про це.\nЩоб підключитися, попросіть вашого контакта створити інше посилання і перевірте, чи маєте ви стабільне з'єднання з мережею."; + +/* No comment provided by engineer. */ +"Unlock" = "Розблокувати"; + +/* authentication reason */ +"Unlock app" = "Розблокувати додаток"; + +/* No comment provided by engineer. */ +"Unmute" = "Увімкнути звук"; + +/* No comment provided by engineer. */ +"Unread" = "Непрочитане"; + +/* No comment provided by engineer. */ +"Update" = "Оновлення"; + +/* No comment provided by engineer. */ +"Update .onion hosts setting?" = "Оновити налаштування хостів .onion?"; + +/* No comment provided by engineer. */ +"Update database passphrase" = "Оновити парольну фразу бази даних"; + +/* No comment provided by engineer. */ +"Update network settings?" = "Оновити налаштування мережі?"; + +/* No comment provided by engineer. */ +"Update transport isolation mode?" = "Оновити режим транспортної ізоляції?"; + +/* rcv group event chat item */ +"updated group profile" = "оновлений профіль групи"; + +/* No comment provided by engineer. */ +"Updating settings will re-connect the client to all servers." = "Оновлення налаштувань призведе до перепідключення клієнта до всіх серверів."; + +/* No comment provided by engineer. */ +"Updating this setting will re-connect the client to all servers." = "Оновлення цього параметра призведе до перепідключення клієнта до всіх серверів."; + +/* No comment provided by engineer. */ +"Upgrade and open chat" = "Оновлення та відкритий чат"; + +/* server test step */ +"Upload file" = "Завантажити файл"; + +/* No comment provided by engineer. */ +"Use .onion hosts" = "Використовуйте хости .onion"; + +/* No comment provided by engineer. */ +"Use chat" = "Використовуйте чат"; + +/* No comment provided by engineer. */ +"Use current profile" = "Використовувати поточний профіль"; + +/* No comment provided by engineer. */ +"Use for new connections" = "Використовуйте для нових з'єднань"; + +/* No comment provided by engineer. */ +"Use iOS call interface" = "Використовуйте інтерфейс виклику iOS"; + +/* No comment provided by engineer. */ +"Use new incognito profile" = "Використовуйте новий профіль інкогніто"; + +/* No comment provided by engineer. */ +"Use server" = "Використовувати сервер"; + +/* No comment provided by engineer. */ +"Use SimpleX Chat servers?" = "Використовувати сервери SimpleX Chat?"; + +/* No comment provided by engineer. */ +"User profile" = "Профіль користувача"; + +/* No comment provided by engineer. */ +"Using .onion hosts requires compatible VPN provider." = "Для використання хостів .onion потрібен сумісний VPN-провайдер."; + +/* No comment provided by engineer. */ +"Using SimpleX Chat servers." = "Використання серверів SimpleX Chat."; + +/* No comment provided by engineer. */ +"v%@ (%@)" = "v%@ (%@)"; + +/* No comment provided by engineer. */ +"Verify connection security" = "Перевірте безпеку з'єднання"; + +/* No comment provided by engineer. */ +"Verify security code" = "Підтвердіть код безпеки"; + +/* No comment provided by engineer. */ +"Via browser" = "Через браузер"; + +/* chat list item description */ +"via contact address link" = "за посиланням на контактну адресу"; + +/* chat list item description */ +"via group link" = "за посиланням на групу"; + +/* chat list item description */ +"via one-time link" = "за одноразовим посиланням"; + +/* No comment provided by engineer. */ +"via relay" = "за допомогою ретранслятора"; + +/* No comment provided by engineer. */ +"Video call" = "Відеодзвінок"; + +/* No comment provided by engineer. */ +"video call (not e2e encrypted)" = "відеодзвінок (без шифрування e2e)"; + +/* No comment provided by engineer. */ +"Video will be received when your contact completes uploading it." = "Відео буде отримано, коли ваш контакт завершить завантаження."; + +/* No comment provided by engineer. */ +"Video will be received when your contact is online, please wait or check later!" = "Відео буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше!"; + +/* No comment provided by engineer. */ +"Videos and files up to 1gb" = "Відео та файли до 1 Гб"; + +/* No comment provided by engineer. */ +"View security code" = "Переглянути код безпеки"; + +/* No comment provided by engineer. */ +"Voice message…" = "Голосове повідомлення…"; + +/* chat feature */ +"Voice messages" = "Голосові повідомлення"; + +/* No comment provided by engineer. */ +"Voice messages are prohibited in this chat." = "Голосові повідомлення в цьому чаті заборонені."; + +/* No comment provided by engineer. */ +"Voice messages are prohibited in this group." = "Голосові повідомлення в цій групі заборонені."; + +/* No comment provided by engineer. */ +"Voice messages prohibited!" = "Голосові повідомлення заборонені!"; + +/* No comment provided by engineer. */ +"waiting for answer…" = "в очікуванні відповіді…"; + +/* No comment provided by engineer. */ +"waiting for confirmation…" = "чекаємо на підтвердження…"; + +/* No comment provided by engineer. */ +"Waiting for file" = "Очікування файлу"; + +/* No comment provided by engineer. */ +"Waiting for image" = "Очікування зображення"; + +/* No comment provided by engineer. */ +"Waiting for video" = "Чекаємо на відео"; + +/* No comment provided by engineer. */ +"wants to connect to you!" = "хоче зв'язатися з вами!"; + +/* No comment provided by engineer. */ +"Warning: you may lose some data!" = "Попередження: ви можете втратити деякі дані!"; + +/* No comment provided by engineer. */ +"WebRTC ICE servers" = "Сервери WebRTC ICE"; + +/* time unit */ +"weeks" = "тижнів"; + +/* No comment provided by engineer. */ +"Welcome %@!" = "Ласкаво просимо %@!"; + +/* No comment provided by engineer. */ +"Welcome message" = "Вітальне повідомлення"; + +/* No comment provided by engineer. */ +"What's new" = "Що нового"; + +/* No comment provided by engineer. */ +"When available" = "За наявності"; + +/* No comment provided by engineer. */ +"When people request to connect, you can accept or reject it." = "Коли люди звертаються із запитом на підключення, ви можете прийняти або відхилити його."; + +/* No comment provided by engineer. */ +"When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Коли ви ділитеся з кимось своїм профілем інкогніто, цей профіль буде використовуватися для груп, до яких вас запрошують."; + +/* No comment provided by engineer. */ +"With optional welcome message." = "З необов'язковим вітальним повідомленням."; + +/* No comment provided by engineer. */ +"Wrong database passphrase" = "Неправильний пароль до бази даних"; + +/* No comment provided by engineer. */ +"Wrong passphrase!" = "Неправильний пароль!"; + +/* No comment provided by engineer. */ +"XFTP servers" = "Сервери XFTP"; + +/* pref value */ +"yes" = "так"; + +/* No comment provided by engineer. */ +"You" = "Ти"; + +/* No comment provided by engineer. */ +"You accepted connection" = "Ви прийняли підключення"; + +/* No comment provided by engineer. */ +"You allow" = "Ви дозволяєте"; + +/* No comment provided by engineer. */ +"You already have a chat profile with the same display name. Please choose another name." = "Ви вже маєте профіль у чаті з таким самим іменем. Будь ласка, виберіть інше ім'я."; + +/* No comment provided by engineer. */ +"You are already connected to %@." = "Ви вже підключені до %@."; + +/* No comment provided by engineer. */ +"You are connected to the server used to receive messages from this contact." = "Ви підключені до сервера, який використовується для отримання повідомлень від цього контакту."; + +/* No comment provided by engineer. */ +"you are invited to group" = "вас запрошують до групи"; + +/* No comment provided by engineer. */ +"You are invited to group" = "Запрошуємо вас до групи"; + +/* No comment provided by engineer. */ +"you are observer" = "ви спостерігач"; + +/* No comment provided by engineer. */ +"You can accept calls from lock screen, without device and app authentication." = "Ви можете приймати дзвінки з екрана блокування без автентифікації пристрою та програми."; + +/* No comment provided by engineer. */ +"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button." = "Ви також можете підключитися за посиланням. Якщо воно відкриється в браузері, натисніть кнопку **Відкрити в мобільному додатку**."; + +/* No comment provided by engineer. */ +"You can create it later" = "Ви можете створити його пізніше"; + +/* No comment provided by engineer. */ +"You can enable later via Settings" = "Ви можете увімкнути пізніше в Налаштуваннях"; + +/* No comment provided by engineer. */ +"You can enable them later via app Privacy & Security settings." = "Ви можете увімкнути їх пізніше в налаштуваннях конфіденційності та безпеки програми."; + +/* No comment provided by engineer. */ +"You can hide or mute a user profile - swipe it to the right." = "Ви можете приховати або вимкнути звук профілю користувача - проведіть по ньому вправо."; + +/* notification body */ +"You can now send messages to %@" = "Тепер ви можете надсилати повідомлення на адресу %@"; + +/* No comment provided by engineer. */ +"You can set lock screen notification preview via settings." = "Ви можете налаштувати попередній перегляд сповіщень на екрані блокування за допомогою налаштувань."; + +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Ви можете поділитися посиланням або QR-кодом - будь-хто зможе приєднатися до групи. Ви не втратите учасників групи, якщо згодом видалите її."; + +/* No comment provided by engineer. */ +"You can share this address with your contacts to let them connect with **%@**." = "Ви можете поділитися цією адресою зі своїми контактами, щоб вони могли зв'язатися з **%@**."; + +/* No comment provided by engineer. */ +"You can share your address as a link or QR code - anybody can connect to you." = "Ви можете поділитися своєю адресою у вигляді посилання або QR-коду - будь-хто зможе зв'язатися з вами."; + +/* No comment provided by engineer. */ +"You can start chat via app Settings / Database or by restarting the app" = "Запустити чат можна через Налаштування програми / База даних або перезапустивши програму"; + +/* No comment provided by engineer. */ +"You can turn on SimpleX Lock via Settings." = "Увімкнути SimpleX Lock можна в Налаштуваннях."; + +/* No comment provided by engineer. */ +"You can use markdown to format messages:" = "Ви можете використовувати розмітку для форматування повідомлень:"; + +/* No comment provided by engineer. */ +"You can't send messages!" = "Ви не можете надсилати повідомлення!"; + +/* chat item text */ +"you changed address" = "ви змінили адресу"; + +/* chat item text */ +"you changed address for %@" = "ви змінили адресу на %@"; + +/* snd group event chat item */ +"you changed role for yourself to %@" = "ви змінили роль для себе на %@"; + +/* snd group event chat item */ +"you changed role of %@ to %@" = "ви змінили роль %1$@ на %2$@"; + +/* No comment provided by engineer. */ +"You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." = "Ви контролюєте, через який(і) сервер(и) **отримувати** повідомлення, ваші контакти - сервери, які ви використовуєте для надсилання їм повідомлень."; + +/* No comment provided by engineer. */ +"You could not be verified; please try again." = "Вас не вдалося верифікувати, спробуйте ще раз."; + +/* No comment provided by engineer. */ +"You have no chats" = "У вас немає чатів"; + +/* No comment provided by engineer. */ +"You have to enter passphrase every time the app starts - it is not stored on the device." = "Вам доведеться вводити парольну фразу щоразу під час запуску програми - вона не зберігається на пристрої."; + +/* No comment provided by engineer. */ +"You invited a contact" = "Ви запросили контакт"; + +/* No comment provided by engineer. */ +"You joined this group" = "Ви приєдналися до цієї групи"; + +/* No comment provided by engineer. */ +"You joined this group. Connecting to inviting group member." = "Ви приєдналися до цієї групи. Підключення до запрошеного учасника групи."; + +/* snd group event chat item */ +"you left" = "ти пішов"; + +/* No comment provided by engineer. */ +"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Ви повинні використовувати найновішу версію бази даних чату ТІЛЬКИ на одному пристрої, інакше ви можете перестати отримувати повідомлення від деяких контактів."; + +/* No comment provided by engineer. */ +"You need to allow your contact to send voice messages to be able to send them." = "Щоб мати змогу надсилати голосові повідомлення, вам потрібно дозволити контакту надсилати їх."; + +/* No comment provided by engineer. */ +"You rejected group invitation" = "Ви відхилили запрошення до групи"; + +/* snd group event chat item */ +"you removed %@" = "ви видалили %@"; + +/* No comment provided by engineer. */ +"You sent group invitation" = "Ви надіслали запрошення до групи"; + +/* chat list item description */ +"you shared one-time link" = "ви поділилися одноразовим посиланням"; + +/* chat list item description */ +"you shared one-time link incognito" = "ви поділилися одноразовим посиланням інкогніто"; + +/* No comment provided by engineer. */ +"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 your connection request is accepted, please wait or check later!" = "Ви будете підключені, коли ваш запит на підключення буде прийнято, будь ласка, зачекайте або перевірте пізніше!"; + +/* No comment provided by engineer. */ +"You will be connected when your contact's device is online, please wait or check later!" = "Ви будете з'єднані, коли пристрій вашого контакту буде онлайн, будь ласка, зачекайте або перевірте пізніше!"; + +/* 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." = "Ви все одно отримуватимете дзвінки та сповіщення від вимкнених профілів, якщо вони активні."; + +/* No comment provided by engineer. */ +"You will stop receiving messages from this group. Chat history will be preserved." = "Ви перестанете отримувати повідомлення від цієї групи. Історія чату буде збережена."; + +/* No comment provided by engineer. */ +"You won't lose your contacts if you later delete your address." = "Ви не втратите свої контакти, якщо згодом видалите свою адресу."; + +/* No comment provided by engineer. */ +"you: " = "ти: "; + +/* No comment provided by engineer. */ +"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Ви намагаєтеся запросити контакт, з яким ви поділилися профілем інкогніто, до групи, в якій ви використовуєте свій основний профіль"; + +/* No comment provided by engineer. */ +"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Ви використовуєте профіль інкогніто для цієї групи - щоб запобігти поширенню вашого основного профілю, запрошення контактів заборонено"; + +/* No comment provided by engineer. */ +"Your %@ servers" = "Ваші сервери %@"; + +/* No comment provided by engineer. */ +"Your calls" = "Твої дзвінки"; + +/* No comment provided by engineer. */ +"Your chat database" = "Ваша база даних чату"; + +/* 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" = "Ваші профілі чату"; + +/* No comment provided by engineer. */ +"Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)." = "Для завершення з'єднання ваш контакт має бути онлайн.\nВи можете скасувати це з'єднання і видалити контакт (і спробувати пізніше з новим посиланням)."; + +/* No comment provided by engineer. */ +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Ваш контакт надіслав файл, розмір якого перевищує підтримуваний на цей момент максимальний розмір (%@)."; + +/* No comment provided by engineer. */ +"Your contacts can allow full message deletion." = "Ваші контакти можуть дозволити повне видалення повідомлень."; + +/* No comment provided by engineer. */ +"Your contacts in SimpleX will see it.\nYou can change it in Settings." = "Ваші контакти в SimpleX побачать це.\nВи можете змінити його в Налаштуваннях."; + +/* No comment provided by engineer. */ +"Your contacts will remain connected." = "Ваші контакти залишаться на зв'язку."; + +/* No comment provided by engineer. */ +"Your current chat database will be DELETED and REPLACED with the imported one." = "Ваша поточна база даних чату буде ВИДАЛЕНА і ЗАМІНЕНА імпортованою."; + +/* No comment provided by engineer. */ +"Your current profile" = "Ваш поточний профіль"; + +/* No comment provided by engineer. */ +"Your ICE servers" = "Ваші сервери ICE"; + +/* No comment provided by engineer. */ +"Your preferences" = "Ваші уподобання"; + +/* No comment provided by engineer. */ +"Your privacy" = "Ваша конфіденційність"; + +/* No comment provided by engineer. */ +"Your profile **%@** will be shared." = "Ваш профіль **%@** буде опублікований."; + +/* No comment provided by engineer. */ +"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ваш профіль зберігається на вашому пристрої і доступний лише вашим контактам.\nСервери SimpleX не бачать ваш профіль."; + +/* No comment provided by engineer. */ +"Your profile, contacts and delivered messages are stored on your device." = "Ваш профіль, контакти та доставлені повідомлення зберігаються на вашому пристрої."; + +/* No comment provided by engineer. */ +"Your random profile" = "Ваш випадковий профіль"; + +/* No comment provided by engineer. */ +"Your server" = "Ваш сервер"; + +/* No comment provided by engineer. */ +"Your server address" = "Адреса вашого сервера"; + +/* No comment provided by engineer. */ +"Your settings" = "Ваші налаштування"; + +/* No comment provided by engineer. */ +"Your SimpleX address" = "Ваша адреса SimpleX"; + +/* No comment provided by engineer. */ +"Your SMP servers" = "Ваші SMP-сервери"; + +/* No comment provided by engineer. */ +"Your XFTP servers" = "Ваші XFTP-сервери"; + diff --git a/apps/ios/uk.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/uk.lproj/SimpleX--iOS--InfoPlist.strings new file mode 100644 index 000000000..2e3c6b893 --- /dev/null +++ b/apps/ios/uk.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,15 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; + +/* Privacy - Camera Usage Description */ +"NSCameraUsageDescription" = "SimpleX потребує доступу до камери, щоб сканувати QR-коди для з'єднання з іншими користувачами та для відеодзвінків."; + +/* Privacy - Face ID Usage Description */ +"NSFaceIDUsageDescription" = "SimpleX використовує Face ID для локальної автентифікації"; + +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX потребує доступу до мікрофона для аудіо та відео дзвінків, а також для запису голосових повідомлень."; + +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX потребує доступу до фототеки для збереження захоплених та отриманих медіафайлів"; + diff --git a/scripts/ios/export-localizations.sh b/scripts/ios/export-localizations.sh index ee97415bc..df880e269 100755 --- a/scripts/ios/export-localizations.sh +++ b/scripts/ios/export-localizations.sh @@ -2,7 +2,7 @@ set -e -langs=( en cs de es fr it ja nl pl ru zh-Hans ) +langs=( en cs de es fi fr it ja nl pl ru uk zh-Hans ) for lang in "${langs[@]}"; do echo "***" diff --git a/scripts/ios/import-localizations.sh b/scripts/ios/import-localizations.sh index 40f9d944b..542c3a7f6 100755 --- a/scripts/ios/import-localizations.sh +++ b/scripts/ios/import-localizations.sh @@ -2,7 +2,7 @@ set -e -langs=( en cs de es fr it ja nl pl ru th zh-Hans ) +langs=( en cs de es fi fr it ja nl pl ru th uk zh-Hans ) for lang in "${langs[@]}"; do echo "***" From 272b02b6863b7fd18b0ea5cae4d34e5691422d51 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 10 Sep 2023 10:45:30 +0100 Subject: [PATCH 35/41] docs: readme follow updates section, add rel=me for mastodon link --- README.md | 21 ++++++++++++++++++--- website/src/_includes/footer.html | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 561a322c6..403bb1efa 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## Welcome to SimpleX Chat! 1. 📲 [Install the app](#install-the-app). -2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups). +2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates). 3. 🤝 [Make a private connection](#make-a-private-connection) with a friend. 4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat). 5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations). @@ -40,14 +40,22 @@ - 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**! - 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows. -## Connect to the team via the app +## Connect to the team + +You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). Please connect to: - to ask any questions - to suggest any improvements - to share anything relevant +We are replying the questions manually, so it is not instant – it can take up to 24 hours. + +If you are interested in helping us to integrate open-source language models, and in [joining our team](./docs/JOIN_TEAM.md), please get in touch. + ## Join user groups +You can join the groups created by other users via the new [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups. + **Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only. You also can: @@ -79,7 +87,14 @@ There are groups in other languages, that we have the apps interface translated You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code. -You can also join the group created by other users by searching for them via the [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups. +## Follow our updates + +We publish our updates and releases via: + +- [Reddit](https://www.reddit.com/r/SimpleXChat/), [Twitter](https://twitter.com/SimpleXChat), [Lemmy](https://lemmy.ml/c/simplex), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://snort.social/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828). +- SimpleX Chat [team profile](#connect-to-the-team). +- [blog](https://simplex.chat/blog/) and [RSS feed](https://simplex.chat/feed.rss). +- [mailing list](https://simplex.chat/#join-simplex), very rarely. ## Make a private connection diff --git a/website/src/_includes/footer.html b/website/src/_includes/footer.html index d6b2372c3..1c2380676 100644 --- a/website/src/_includes/footer.html +++ b/website/src/_includes/footer.html @@ -63,7 +63,7 @@ <path fill-rule="evenodd" clip-rule="evenodd" d="M256 512C397.385 512 512 397.385 512 256C512 114.615 397.385 0 256 0C114.615 0 0 114.615 0 256C0 397.385 114.615 512 256 512ZM348.077 268.251C347.047 271.254 346.018 274.257 344.263 277.017C337.633 287.302 327.867 292.139 315.221 292.951C302.173 293.789 289.233 295.487 276.333 297.437C274.064 297.779 271.793 298.108 269.521 298.438C258.687 300.005 247.858 301.572 237.36 304.645C189.946 318.517 148.927 341.344 115.048 374.257C109.197 379.941 103.451 385.688 98.1836 391.819C95.7632 394.637 93.0278 397.201 89.1831 398.521L89.1304 398.538C87.9731 398.938 86.7466 399.359 85.6548 398.491C84.7729 397.79 84.9731 396.874 85.1665 395.992C85.1948 395.862 85.2231 395.733 85.248 395.605C85.6963 393.277 86.7407 391.14 88.1821 389.194C90.146 386.544 92.084 383.878 94.022 381.213C99.7344 373.356 105.446 365.5 111.82 358.036C121.707 346.457 133.167 336.2 144.787 326.072C168.56 305.352 195.75 289.087 225.833 276.646C228.757 275.438 231.732 274.327 234.707 273.217C236.528 272.538 238.349 271.858 240.158 271.155C240.361 271.076 240.574 271.007 240.789 270.938C241.635 270.663 242.518 270.377 243.024 269.423C242.177 268.706 241.15 268.783 240.177 268.857C239.968 268.873 239.762 268.889 239.561 268.896C217.297 269.744 195.694 273.47 174.884 280.725C167.117 283.432 159.668 286.729 152.295 290.172C149.855 291.311 147.46 292.248 145.208 290.121C142.917 287.958 143.432 285.598 144.912 283.182C154.453 267.61 165.746 253.295 179.889 240.66C197.997 224.483 219.395 213.954 244.032 208.49C276.996 201.18 306.323 208.416 332.639 226.84L333.331 227.326C337.818 230.48 342.154 233.529 348.424 232.938C356.094 232.213 362.589 229.362 367.853 224.391C376.977 215.774 378.435 205.5 372.124 194.314C365.358 182.325 356.674 171.414 347.996 160.512L347.97 160.479C344.757 156.442 341.94 152.257 340.075 147.559C337.776 141.771 339.282 136.798 343.771 132.374C346.562 129.623 349.879 127.444 353.499 125.654C352.84 124.302 351.792 124.372 350.828 124.437C350.692 124.445 350.558 124.454 350.427 124.459C349.885 124.479 349.345 124.565 348.806 124.651C348.214 124.745 347.624 124.839 347.031 124.847C346.607 124.852 346.152 124.89 345.689 124.929C343.945 125.074 342.084 125.23 341.357 123.684C340.391 121.631 341.399 119.227 343.191 117.554C347.403 113.623 352.741 112.595 358.621 113.133C364.851 113.703 370.044 116.374 374.959 119.561C377.904 121.469 380.753 123.504 383.548 125.587C386.107 127.494 385.938 128.352 382.863 129.358C378.374 130.828 373.854 132.237 369.158 133.088C368.043 133.29 366.97 133.568 365.925 133.915C365.13 134.178 364.353 134.481 363.586 134.82C356.43 137.985 354.645 142.229 357.784 148.742C360.525 154.429 364.434 159.469 368.344 164.509C369.887 166.498 371.431 168.487 372.902 170.517C373.603 171.483 374.306 172.449 375.008 173.415C379.468 179.541 383.926 185.666 387.914 192.062C400.479 212.217 392.457 231.471 374.209 243.009C372.334 244.194 370.384 245.277 368.434 246.36C365.527 247.975 362.621 249.589 359.962 251.543C354.495 255.563 350.765 260.588 348.648 266.601C348.455 267.149 348.266 267.7 348.077 268.251ZM364.513 333.922C365.149 335.249 364.924 336.668 364.888 338.06C364.714 340.459 365.214 342.489 363.518 344.059C362.183 344.074 361.458 343.352 360.753 342.65C360.563 342.461 360.375 342.273 360.177 342.103C356.286 338.75 352.875 334.988 348.808 331.769C341.752 326.184 333.477 324.483 324.302 324.521C315.342 324.559 307.177 327.164 299.004 329.771C297.717 330.183 296.429 330.594 295.138 330.995L289.747 332.666C278.586 336.121 267.42 339.577 256.422 343.413C246.833 346.757 238.413 339.996 239.31 331.924C240.31 322.924 243.14 314.443 250.956 307.944C254.783 304.762 259.324 303.146 264.323 302.431C267.762 301.938 271.221 301.553 274.679 301.168C276.062 301.014 277.446 300.859 278.828 300.698C279.451 300.625 280.078 300.607 280.639 301.144C280.923 302.208 280.246 302.979 279.585 303.731C279.429 303.91 279.273 304.088 279.131 304.268C274.998 309.516 270.639 314.606 265.876 319.418C265.727 319.568 265.549 319.722 265.365 319.879C264.61 320.528 263.763 321.258 264.454 322.215C265.352 323.459 266.887 322.692 268.166 322.415C273.744 321.205 279.287 319.864 284.829 318.523C294.818 316.107 304.808 313.69 315.003 312.043C328.089 309.928 340.422 311.512 351.549 318.553C357.728 322.463 361.604 327.85 364.513 333.922Z" /> </svg> </a> - <a href="https://mastodon.social/@simplex" target="_blank"> + <a rel="me" href="https://mastodon.social/@simplex" target="_blank"> <svg class="fill-primary-light dark:fill-primary-dark" width="32" height="32" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" clip-rule="evenodd" d="M256 512C397.385 512 512 397.385 512 256C512 114.615 397.385 0 256 0C114.615 0 0 114.615 0 256C0 397.385 114.615 512 256 512ZM339.329 99.0275C317.654 93.9388 295.564 92.2207 273.231 91.3254C272.186 91.2944 271.157 91.2596 270.14 91.2251C268.091 91.1557 266.089 91.0879 264.086 91.0567C246.35 90.7812 228.654 91.5123 210.994 93.1846C197.065 94.5035 183.244 96.4768 169.675 99.9727C162.199 101.899 154.73 103.994 147.977 107.812C134.904 115.202 125.174 125.999 117.725 138.921C108.323 155.228 103.916 173.02 103.377 191.682C102.882 208.853 102.937 226.049 103.186 243.227C103.396 257.66 104.034 272.095 104.854 286.507C105.678 300.995 107.728 315.348 110.963 329.508C114.411 344.606 119.377 359.13 127.439 372.451C138.635 390.948 154.331 403.859 174.933 410.499C187.604 414.582 200.649 417.051 213.826 418.828C237.461 422.015 261.021 421.897 284.461 417.12C294.296 415.115 303.892 412.325 313.09 408.232C314.413 407.643 314.878 406.946 314.79 405.46C314.549 401.383 314.383 397.303 314.217 393.222C314.143 391.401 314.069 389.581 313.989 387.761C313.915 386.091 313.827 384.422 313.737 382.714C313.695 381.925 313.653 381.128 313.612 380.318C313.426 380.354 313.261 380.382 313.11 380.408C312.825 380.457 312.591 380.497 312.364 380.56C294.125 385.58 275.582 388.162 256.641 387.555C245.877 387.21 235.125 386.735 224.588 384.226C217.159 382.457 210.199 379.654 204.32 374.599C197.016 368.318 193.251 360.094 191.688 350.786C191.199 347.876 190.939 344.927 190.672 341.898C190.548 340.499 190.423 339.084 190.274 337.647C190.638 337.712 190.939 337.764 191.198 337.808C191.658 337.888 191.989 337.945 192.317 338.012C193.433 338.242 194.548 338.478 195.663 338.714C197.9 339.188 200.137 339.662 202.384 340.08C217.874 342.967 233.476 344.947 249.2 346.01C261.24 346.825 273.283 346.962 285.306 346.061C292.653 345.511 299.987 344.776 307.32 344.042C310.694 343.704 314.068 343.366 317.444 343.046C329.993 341.856 342.163 339.055 353.868 334.339C368.08 328.613 381.069 320.958 391.661 309.671C397.704 303.232 402.22 295.971 403.793 287.033C406.787 270.025 408.278 252.868 409.117 235.648C409.506 227.659 409.68 219.659 409.854 211.661C409.877 210.641 409.899 209.622 409.922 208.602C410.225 194.937 409.755 181.331 406.589 167.943C402.18 149.303 393.93 132.763 380.157 119.248C373.506 112.723 366.119 107.246 357.182 104.291C351.293 102.344 345.36 100.443 339.329 99.0275ZM206.301 147.05C215.154 146.705 223.699 148.074 231.686 152.148C239.099 156.066 244.656 161.596 248.834 168.559C250.24 170.904 251.634 173.256 253.028 175.609C253.877 177.042 254.725 178.475 255.577 179.906C255.751 180.198 255.939 180.481 256.169 180.825C256.289 181.005 256.42 181.202 256.567 181.426C256.835 180.985 257.092 180.564 257.34 180.156C257.857 179.31 258.338 178.52 258.815 177.728C259.514 176.567 260.2 175.397 260.885 174.228C262.344 171.739 263.803 169.25 265.39 166.845C274.175 153.529 286.908 147.555 302.51 147.037C311.492 146.739 320.132 148.161 328.153 152.407C340.022 158.69 347.454 168.621 351.49 181.281C353.776 188.451 354.772 195.835 354.782 203.337C354.809 224.399 354.804 245.462 354.8 266.525C354.798 274.145 354.797 281.765 354.796 289.385C354.796 291.539 354.794 291.541 352.618 291.541C345.311 291.541 338.005 291.541 330.699 291.541L322.729 291.541L320.556 291.541V289.22C320.556 282.775 320.555 276.331 320.555 269.886C320.555 249.152 320.555 228.417 320.557 207.683C320.557 203.313 320.182 198.991 318.855 194.802C316.363 186.935 311.094 182.212 302.904 181.058C297.135 180.245 291.436 180.588 286.145 183.355C279.524 186.817 276.269 192.781 274.923 199.777C274.066 204.228 273.672 208.827 273.621 213.366C273.507 223.706 273.527 234.048 273.547 244.39C273.555 248.39 273.563 252.389 273.563 256.389V258.442H239.519C239.519 257.757 239.52 257.127 239.519 256.498C239.515 252.169 239.515 247.841 239.515 243.512C239.515 232.533 239.514 221.553 239.453 210.574C239.422 205.073 238.685 199.648 236.607 194.488C233.57 186.946 228.11 182.277 220.046 181.187C216.643 180.726 213.072 180.737 209.67 181.204C202.089 182.246 197.072 186.701 194.521 193.923C193.001 198.228 192.528 202.699 192.528 207.236C192.526 227.665 192.526 248.094 192.526 268.522C192.526 275.434 192.527 282.345 192.527 289.256V291.425H158.285V289.671C158.285 281.418 158.28 273.166 158.274 264.914C158.259 244.215 158.245 223.516 158.321 202.817C158.362 191.725 160.39 180.983 165.938 171.217C174.826 155.574 188.484 147.745 206.301 147.05Z" /> </svg> From 364d889056ffd93d36bfaac7afeabb8c140e35e0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 10 Sep 2023 10:53:38 +0100 Subject: [PATCH 36/41] docs: add mastodon rel=me to readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 403bb1efa..d4103fd10 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases) [![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases) [![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat) -[![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@simplex) +<a rel="me" href="https://mastodon.social/@simplex">![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)</a> | 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) | From a87aaa50c79c13ca871b4df55dcba66fd302f450 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 10 Sep 2023 11:21:40 +0100 Subject: [PATCH 37/41] website: add nostr.json for NIP-05 --- website/src/.well-known/nostr.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 website/src/.well-known/nostr.json diff --git a/website/src/.well-known/nostr.json b/website/src/.well-known/nostr.json new file mode 100644 index 000000000..8c409fb86 --- /dev/null +++ b/website/src/.well-known/nostr.json @@ -0,0 +1,18 @@ +{ + "names": { + "_": "c998a5739f04f7fff202c54962aa5782b34ecb10d6f915bdfdd7582963bf9171" + }, + "relays": { + "c998a5739f04f7fff202c54962aa5782b34ecb10d6f915bdfdd7582963bf9171": [ + "wss://nostr.orangepill.dev", + "wss://eden.nostr.land", + "wss://relay.damus.io", + "wss://relay.snort.social", + "wss://relay.current.fyi", + "wss://nos.lol", + "wss://relay.nostr.bg", + "wss://nostr-verified.wellorder.net", + "wss://nostr.milou.lol" + ] + } +} From 54e1e10382b1eb9736b151f25e110187070fa69a Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 10 Sep 2023 19:05:12 +0300 Subject: [PATCH 38/41] multiplatform: local file encryption (#3043) * multiplatform: file encryption * setting * fixed sharing * check * fixes, change lock icon * update JNI bindings (crashes on desktop) * fix JNI * fix errors and warnings * fix saving --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../common/platform/RecAndPlay.android.kt | 44 +++++++--- .../simplex/common/platform/Share.android.kt | 36 ++++++-- .../views/chat/item/CIImageView.android.kt | 4 +- .../views/chat/item/ChatItemView.android.kt | 2 +- .../chat/item/ImageFullScreenView.android.kt | 4 +- .../common/views/helpers/Utils.android.kt | 47 ++++++---- .../src/commonMain/cpp/android/simplex-api.c | 80 ++++++++++++++++- .../src/commonMain/cpp/desktop/simplex-api.c | 79 ++++++++++++++++- .../chat/simplex/common/model/ChatModel.kt | 12 ++- .../chat/simplex/common/model/CryptoFile.kt | 59 +++++++++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 6 +- .../chat/simplex/common/platform/AppCommon.kt | 3 +- .../chat/simplex/common/platform/Core.kt | 5 ++ .../chat/simplex/common/platform/Files.kt | 11 +++ .../simplex/common/platform/RecAndPlay.kt | 4 +- .../chat/simplex/common/platform/Share.kt | 3 +- .../simplex/common/views/chat/ChatView.kt | 10 +-- .../simplex/common/views/chat/ComposeView.kt | 21 +++-- .../common/views/chat/ComposeVoiceView.kt | 3 +- .../common/views/chat/item/CIFileView.kt | 17 +++- .../common/views/chat/item/CIImageView.kt | 29 +++--- .../common/views/chat/item/CIMetaView.kt | 13 ++- .../views/chat/item/CIRcvDecryptionError.kt | 4 +- .../common/views/chat/item/CIVoiceView.kt | 16 ++-- .../common/views/chat/item/ChatItemView.kt | 4 +- .../common/views/chat/item/FramedItemView.kt | 2 +- .../views/chat/item/ImageFullScreenView.kt | 6 +- .../common/views/chat/item/TextItemView.kt | 2 +- .../common/views/database/DatabaseView.kt | 2 +- .../simplex/common/views/helpers/Utils.kt | 88 +++++++++++++------ .../simplex/common/views/newchat/QRCode.kt | 3 +- .../views/usersettings/PrivacySettings.kt | 1 + .../views/usersettings/UserProfilesView.kt | 7 +- .../commonMain/resources/MR/base/strings.xml | 1 + .../resources/MR/images/ic_lock_open.svg | 1 - .../MR/images/ic_lock_open_right.svg | 1 + .../common/platform/AppCommon.desktop.kt | 2 + .../common/platform/RecAndPlay.desktop.kt | 4 +- .../simplex/common/platform/Share.desktop.kt | 12 ++- .../views/chat/item/CIImageView.desktop.kt | 2 +- .../views/chat/item/ChatItemView.desktop.kt | 2 +- .../chat/item/ImageFullScreenView.desktop.kt | 9 +- .../common/views/helpers/Utils.desktop.kt | 12 ++- 43 files changed, 522 insertions(+), 151 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt delete mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg 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 c24ade47d..ebc1b416b 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 @@ -1,6 +1,5 @@ package chat.simplex.common.platform -import android.app.Application import android.content.Context import android.media.* import android.media.AudioManager.AudioPlaybackCallback @@ -8,10 +7,10 @@ import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED import android.os.Build import androidx.compose.runtime.* -import chat.simplex.res.MR -import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.* import chat.simplex.common.platform.AudioPlayer.duration import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR import kotlinx.coroutines.* import java.io.* @@ -134,20 +133,25 @@ actual object AudioPlayer: AudioPlayerInterface { } // Returns real duration of the track - private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { - if (!File(filePath).exists()) { - Log.e(TAG, "No such file: $filePath") + private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { + val absoluteFilePath = getAppFilePath(fileSource.filePath) + if (!File(absoluteFilePath).exists()) { + Log.e(TAG, "No such file: ${fileSource.filePath}") return null } VideoPlayer.stopAll() RecorderInterface.stopRecording?.invoke() val current = currentlyPlaying.value - if (current == null || current.first != filePath) { + if (current == null || current.first != fileSource.filePath) { stopListener() player.reset() runCatching { - player.setDataSource(filePath) + if (fileSource.cryptoArgs != null) { + player.setDataSource(CryptoMediaSource(readCryptoFile(absoluteFilePath, fileSource.cryptoArgs))) + } else { + player.setDataSource(absoluteFilePath) + } }.onFailure { Log.e(TAG, it.stackTraceToString()) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message) @@ -162,7 +166,7 @@ actual object AudioPlayer: AudioPlayerInterface { } if (seek != null) player.seekTo(seek) player.start() - currentlyPlaying.value = filePath to onProgressUpdate + currentlyPlaying.value = fileSource.filePath to onProgressUpdate progressJob = CoroutineScope(Dispatchers.Default).launch { onProgressUpdate(player.currentPosition, TrackState.PLAYING) while(isActive && player.isPlaying) { @@ -229,7 +233,7 @@ actual object AudioPlayer: AudioPlayerInterface { } override fun play( - filePath: String?, + fileSource: CryptoFile, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, @@ -238,7 +242,7 @@ actual object AudioPlayer: AudioPlayerInterface { if (progress.value == duration.value) { progress.value = 0 } - val realDuration = start(filePath ?: return, progress.value) { pro, state -> + val realDuration = start(fileSource, progress.value) { pro, state -> if (pro != null) { progress.value = pro } @@ -283,3 +287,21 @@ actual object AudioPlayer: AudioPlayerInterface { } actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer + +class CryptoMediaSource(val data: ByteArray) : MediaDataSource() { + override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { + if (position >= data.size) return -1 + + val endPosition: Int = (position + size).toInt() + var sizeLeft: Int = size + if (endPosition > data.size) { + sizeLeft -= endPosition - data.size + } + + System.arraycopy(data, position.toInt(), buffer, offset, sizeLeft) + return sizeLeft + } + + override fun getSize(): Long = data.size.toLong() + override fun close() {} +} 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 811974b2d..a370bbf40 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,13 +8,15 @@ import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.UriHandler -import chat.simplex.common.helpers.toUri -import chat.simplex.common.model.CIFile -import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.common.views.helpers.getAppFileUri +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 { @@ -28,9 +30,17 @@ actual fun ClipboardManager.shareText(text: String) { androidAppContext.startActivity(shareIntent) } -actual fun shareFile(text: String, filePath: String) { - val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) - val ext = filePath.substringAfterLast(".") +actual fun shareFile(text: String, fileSource: CryptoFile) { + val uri = if (fileSource.cryptoArgs != null) { + val tmpFile = File(tmpDir, fileSource.filePath) + tmpFile.deleteOnExit() + ChatModel.filesToDelete.add(tmpFile) + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(tmpFile.absolutePath)).toURI() + } else { + getAppFileUri(fileSource.filePath) + } + val ext = fileSource.filePath.substringAfterLast(".") val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND @@ -84,8 +94,16 @@ fun saveImage(ciFile: CIFile?) { uri?.let { androidAppContext.contentResolver.openOutputStream(uri)?.let { stream -> val outputStream = BufferedOutputStream(stream) - File(filePath).inputStream().use { it.copyTo(outputStream) } - outputStream.close() + if (ciFile.fileSource?.cryptoArgs != null) { + createTmpFileAndDelete { tmpFile -> + decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + tmpFile.inputStream().use { it.copyTo(outputStream) } + } + outputStream.close() + } else { + File(filePath).inputStream().use { it.copyTo(outputStream) } + outputStream.close() + } showToast(generalGetString(MR.strings.image_saved)) } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt index dc8e9dd54..28c00ec01 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt @@ -19,7 +19,7 @@ import java.net.URI @Composable actual fun SimpleAndAnimatedImageView( - uri: URI, + data: ByteArray, imageBitmap: ImageBitmap, file: CIFile?, imageProvider: () -> ImageGalleryProvider, @@ -27,7 +27,7 @@ actual fun SimpleAndAnimatedImageView( ) { val context = LocalContext.current val imagePainter = rememberAsyncImagePainter( - ImageRequest.Builder(context).data(data = uri.toUri()).size(coil.size.Size.ORIGINAL).build(), + ImageRequest.Builder(context).data(data = data).size(coil.size.Size.ORIGINAL).build(), placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil imageLoader = imageLoader ) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index 15421299a..8bb70c4a0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -26,7 +26,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) { val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) - ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { + 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 -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt index d23ee58db..ade538a04 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt @@ -26,7 +26,7 @@ import dev.icerock.moko.resources.compose.stringResource import java.net.URI @Composable -actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) { +actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) { // I'm making a new instance of imageLoader here because if I use one instance in multiple places // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want val imageLoader = ImageLoader.Builder(LocalContext.current) @@ -40,7 +40,7 @@ actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageB .build() Image( rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current).data(data = uri.toUri()).size(Size.ORIGINAL).build(), + ImageRequest.Builder(LocalContext.current).data(data = data).size(Size.ORIGINAL).build(), placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil imageLoader = imageLoader ), 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 67c41c3d7..e3c857716 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 @@ -1,6 +1,5 @@ package chat.simplex.common.views.helpers -import android.app.Application import android.content.res.Resources import android.graphics.* import android.graphics.Typeface @@ -12,11 +11,8 @@ import android.text.Spanned import android.text.SpannedString import android.text.style.* import android.util.Base64 -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.BaselineShift @@ -159,17 +155,18 @@ actual fun getAppFileUri(fileName: String): URI = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(getAppFilePath(fileName))).toURI() // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -actual fun getLoadedImage(file: CIFile?): ImageBitmap? { +actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? { val filePath = getLoadedFilePath(file) - return if (filePath != null) { + return if (filePath != null && file != null) { try { - val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) - val parcelFileDescriptor = androidAppContext.contentResolver.openFileDescriptor(uri.toUri(), "r") - val fileDescriptor = parcelFileDescriptor?.fileDescriptor - val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000) - parcelFileDescriptor?.close() - image.asImageBitmap() + val data = if (file.fileSource?.cryptoArgs != null) { + readCryptoFile(getAppFilePath(file.fileSource.filePath), file.fileSource.cryptoArgs) + } else { + File(getAppFilePath(file.fileName)).readBytes() + } + decodeSampledBitmapFromByteArray(data, 1000, 1000).asImageBitmap() to data } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) null } } else { @@ -178,17 +175,17 @@ actual fun getLoadedImage(file: CIFile?): ImageBitmap? { } // https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap -private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap { +private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap { // First decode with inJustDecodeBounds=true to check dimensions return BitmapFactory.Options().run { inJustDecodeBounds = true - BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this) + BitmapFactory.decodeByteArray(data, 0, data.size) // Calculate inSampleSize inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) // Decode bitmap with inSampleSize set inJustDecodeBounds = false - BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this) + BitmapFactory.decodeByteArray(data, 0, data.size) } } @@ -254,6 +251,26 @@ actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitma }?.asImageBitmap() } +actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? { + return if (Build.VERSION.SDK_INT >= 31) { + val source = ImageDecoder.createSource(data) + try { + ImageDecoder.decodeBitmap(source) + } catch (e: android.graphics.ImageDecoder.DecodeException) { + Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}") + if (withAlertOnException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.image_decoding_exception_title), + text = generalGetString(MR.strings.image_decoding_exception_desc) + ) + } + null + } + } else { + BitmapFactory.decodeByteArray(data, 0, data.size) + }?.asImageBitmap() +} + actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? { return if (Build.VERSION.SDK_INT >= 28) { val source = ImageDecoder.createSource(androidAppContext.contentResolver, uri.toUri()) 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 7b6c032c8..eb4714710 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -1,5 +1,6 @@ #include <jni.h> -//#include <string.h> +#include <string.h> +#include <stdint.h> //#include <stdlib.h> //#include <android/log.h> @@ -45,6 +46,10 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); +extern char *chat_write_file(const char *path, char *ptr, int length); +extern char *chat_read_file(const char *path, const char *key, const char *nonce); +extern char *chat_encrypt_file(const char *from_path, const char *to_path); +extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); JNIEXPORT jobjectArray JNICALL Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) { @@ -115,3 +120,76 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused (*env)->ReleaseStringUTFChars(env, salt, _salt); return res; } + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { + const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); + jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); + jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); + jstring res = (*env)->NewStringUTF(env, chat_write_file(_path, buff, capacity)); + (*env)->ReleaseStringUTFChars(env, path, _path); + return res; +} + +JNIEXPORT jobjectArray JNICALL +Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, jstring path, jstring key, jstring nonce) { + const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); + const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE); + const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE); + + jbyte *res = chat_read_file(_path, _key, _nonce); + (*env)->ReleaseStringUTFChars(env, path, _path); + (*env)->ReleaseStringUTFChars(env, key, _key); + (*env)->ReleaseStringUTFChars(env, nonce, _nonce); + + jint status = (jint)res[0]; + jbyteArray arr; + if (status == 0) { + union { + uint32_t w; + uint8_t b[4]; + } len; + len.b[0] = (uint8_t)res[1]; + len.b[1] = (uint8_t)res[2]; + len.b[2] = (uint8_t)res[3]; + len.b[3] = (uint8_t)res[4]; + arr = (*env)->NewByteArray(env, len.w); + (*env)->SetByteArrayRegion(env, arr, 0, len.w, res + 5); + } else { + int len = strlen(res + 1); // + 1 offset here is to not include status byte + arr = (*env)->NewByteArray(env, len); + (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1); + } + + jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); + jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"), + (*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Integer"), "<init>", "(I)V"), + status); + (*env)->SetObjectArrayElement(env, ret, 0, statusObj); + (*env)->SetObjectArrayElement(env, ret, 1, arr); + return ret; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) { + const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE); + const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_encrypt_file(_from_path, _to_path)); + (*env)->ReleaseStringUTFChars(env, from_path, _from_path); + (*env)->ReleaseStringUTFChars(env, to_path, _to_path); + return res; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring key, jstring nonce, jstring to_path) { + const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE); + const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE); + const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE); + const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path)); + (*env)->ReleaseStringUTFChars(env, from_path, _from_path); + (*env)->ReleaseStringUTFChars(env, key, _key); + (*env)->ReleaseStringUTFChars(env, nonce, _nonce); + (*env)->ReleaseStringUTFChars(env, to_path, _to_path); + return res; +} diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 8e869ca2d..ddc5c92f9 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -1,6 +1,7 @@ #include <jni.h> #include <string.h> #include <stdlib.h> +#include <stdint.h> // from the RTS void hs_init(int * argc, char **argv[]); @@ -20,7 +21,10 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); - +extern char *chat_write_file(const char *path, char *ptr, int length); +extern char *chat_read_file(const char *path, const char *key, const char *nonce); +extern char *chat_encrypt_file(const char *from_path, const char *to_path); +extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); // As a reference: https://stackoverflow.com/a/60002045 jstring decode_to_utf8_string(JNIEnv *env, char *string) { @@ -128,3 +132,76 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass cl (*env)->ReleaseStringUTFChars(env, salt, _salt); return res; } + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { + const char *_path = encode_to_utf8_chars(env, path); + jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); + jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); + jstring res = decode_to_utf8_string(env, chat_write_file(_path, buff, capacity)); + (*env)->ReleaseStringUTFChars(env, path, _path); + return res; +} + +JNIEXPORT jobjectArray JNICALL +Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, jstring path, jstring key, jstring nonce) { + const char *_path = encode_to_utf8_chars(env, path); + const char *_key = encode_to_utf8_chars(env, key); + const char *_nonce = encode_to_utf8_chars(env, nonce); + + jbyte *res = chat_read_file(_path, _key, _nonce); + (*env)->ReleaseStringUTFChars(env, path, _path); + (*env)->ReleaseStringUTFChars(env, key, _key); + (*env)->ReleaseStringUTFChars(env, nonce, _nonce); + + jint status = (jint)res[0]; + jbyteArray arr; + if (status == 0) { + union { + uint32_t w; + uint8_t b[4]; + } len; + len.b[0] = (uint8_t)res[1]; + len.b[1] = (uint8_t)res[2]; + len.b[2] = (uint8_t)res[3]; + len.b[3] = (uint8_t)res[4]; + arr = (*env)->NewByteArray(env, len.w); + (*env)->SetByteArrayRegion(env, arr, 0, len.w, res + 5); + } else { + int len = strlen(res + 1); // + 1 offset here is to not include status byte + arr = (*env)->NewByteArray(env, len); + (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1); + } + + jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); + jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"), + (*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Integer"), "<init>", "(I)V"), + status); + (*env)->SetObjectArrayElement(env, ret, 0, statusObj); + (*env)->SetObjectArrayElement(env, ret, 1, arr); + return ret; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) { + const char *_from_path = encode_to_utf8_chars(env, from_path); + const char *_to_path = encode_to_utf8_chars(env, to_path); + jstring res = decode_to_utf8_string(env, chat_encrypt_file(_from_path, _to_path)); + (*env)->ReleaseStringUTFChars(env, from_path, _from_path); + (*env)->ReleaseStringUTFChars(env, to_path, _to_path); + return res; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring key, jstring nonce, jstring to_path) { + const char *_from_path = encode_to_utf8_chars(env, from_path); + const char *_key = encode_to_utf8_chars(env, key); + const char *_nonce = encode_to_utf8_chars(env, nonce); + const char *_to_path = encode_to_utf8_chars(env, to_path); + jstring res = decode_to_utf8_string(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path)); + (*env)->ReleaseStringUTFChars(env, from_path, _from_path); + (*env)->ReleaseStringUTFChars(env, key, _key); + (*env)->ReleaseStringUTFChars(env, nonce, _nonce); + (*env)->ReleaseStringUTFChars(env, to_path, _to_path); + return res; +} 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 a0120eb96..fc0867aad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -13,6 +13,7 @@ import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.platform.AudioPlayer +import chat.simplex.common.platform.chatController import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -1394,6 +1395,13 @@ data class ChatItem ( private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID + val encryptedFile: Boolean? = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null + + val encryptLocalFile: Boolean + get() = file?.fileProtocol == FileProtocol.XFTP && + content.msgContent !is MsgContent.MCVideo && + chatController.appPrefs.privacyEncryptLocalFiles.get() + val memberDisplayName: String? get() = if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName else null @@ -2077,7 +2085,7 @@ class CIFile( } @Serializable -class CryptoFile( +data class CryptoFile( val filePath: String, val cryptoArgs: CryptoFileArgs? ) { @@ -2087,7 +2095,7 @@ class CryptoFile( } @Serializable -class CryptoFileArgs(val fileKey: String, val fileNonce: String) +data class CryptoFileArgs(val fileKey: String, val fileNonce: String) class CancelAction( val uiActionId: StringResource, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt new file mode 100644 index 000000000..037d27af3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -0,0 +1,59 @@ +package chat.simplex.common.model + +import chat.simplex.common.platform.* +import kotlinx.serialization.* +import java.nio.ByteBuffer + +@Serializable +sealed class WriteFileResult { + @Serializable @SerialName("result") data class Result(val cryptoArgs: CryptoFileArgs): WriteFileResult() + @Serializable @SerialName("error") data class Error(val writeError: String): WriteFileResult() +} + +/* + fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { + val str = chatWriteFile(path, data) + return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) { + is WriteFileResult.Result -> d.cryptoArgs + is WriteFileResult.Error -> throw Exception(d.writeError) + } +} +* */ + +fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { + val buffer = ByteBuffer.allocateDirect(data.size) + buffer.put(data) + buffer.rewind() + val str = chatWriteFile(path, buffer) + return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) { + is WriteFileResult.Result -> d.cryptoArgs + is WriteFileResult.Error -> throw Exception(d.writeError) + } +} + +fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray { + val res: Array<Any> = chatReadFile(path, cryptoArgs.fileKey, cryptoArgs.fileNonce) + val status = (res[0] as Integer).toInt() + val arr = res[1] as ByteArray + if (status == 0) { + return arr + } else { + throw Exception(String(arr)) + } +} + +fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs { + val str = chatEncryptFile(fromPath, toPath) + val d = json.decodeFromString(WriteFileResult.serializer(), str) + return when (d) { + is WriteFileResult.Result -> d.cryptoArgs + is WriteFileResult.Error -> throw Exception(d.writeError) + } +} + +fun decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) { + val err = chatDecryptFile(fromPath, cryptoArgs.fileKey, cryptoArgs.fileNonce, toPath) + if (err != "") { + throw Exception(err) + } +} 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 612c167bf..0a178ca2f 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 @@ -5,7 +5,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import dev.icerock.moko.resources.compose.painterResource -import chat.simplex.common.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -94,6 +93,7 @@ class AppPreferences { val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true) val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true) val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false) + val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false) val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null) @@ -249,6 +249,7 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews" private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft" private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet" + private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles" const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites" @@ -1413,8 +1414,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))) { - // TODO encrypt images and voice - withApi { receiveFile(r.user, file.fileId, encrypted = false, auto = true) } + withApi { receiveFile(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) 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 fff77ee23..d36a6aec1 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 @@ -1,8 +1,9 @@ package chat.simplex.common.platform import chat.simplex.common.BuildConfigCommon -import chat.simplex.common.model.ChatController +import chat.simplex.common.model.* import chat.simplex.common.ui.theme.DefaultTheme +import java.io.File import java.util.* enum class AppPlatform { 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 341f4e954..801a0270e 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 @@ -4,6 +4,7 @@ import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import kotlinx.serialization.decodeFromString +import java.nio.ByteBuffer // ghc's rts external fun initHS() @@ -19,6 +20,10 @@ external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String external fun chatParseMarkdown(str: String): String external fun chatParseServer(str: String): String external fun chatPasswordHash(pwd: String, salt: String): String +external fun chatWriteFile(path: String, buffer: ByteBuffer): String +external fun chatReadFile(path: String, key: String, nonce: String): Array<Any> +external fun chatEncryptFile(fromPath: String, toPath: String): String +external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String val chatModel: ChatModel get() = chatController.chatModel 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 53b0f8bd9..71a9f204f 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 @@ -2,6 +2,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.Composable import chat.simplex.common.model.CIFile +import chat.simplex.common.model.CryptoFile import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import java.io.* @@ -71,6 +72,16 @@ fun getLoadedFilePath(file: CIFile?): String? { } } +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 + } else { + null + } +} + /** * [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/platform/RecAndPlay.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt index bbc5cbe66..2d6bb2a37 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt @@ -1,7 +1,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.MutableState -import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.* import kotlinx.coroutines.CoroutineScope interface RecorderInterface { @@ -18,7 +18,7 @@ expect class RecorderNative(): RecorderInterface interface AudioPlayerInterface { fun play( - filePath: String?, + fileSource: CryptoFile, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt index 03ad4b544..72bb3caaa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt @@ -2,8 +2,9 @@ package chat.simplex.common.platform import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.UriHandler +import chat.simplex.common.model.CryptoFile expect fun UriHandler.sendEmail(subject: String, body: CharSequence) expect fun ClipboardManager.shareText(text: String) -expect fun shareFile(text: String, filePath: String) +expect fun shareFile(text: String, fileSource: CryptoFile) 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 f6e328afd..c8381cdcb 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 @@ -1117,7 +1117,7 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha } sealed class ProviderMedia { - data class Image(val uri: URI, val image: ImageBitmap): ProviderMedia() + data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia() data class Video(val uri: URI, val preview: String): ProviderMedia() } @@ -1155,11 +1155,11 @@ private fun providerForGallery( val item = item(internalIndex, initialChatId)?.second ?: return null return when (item.content.msgContent) { is MsgContent.MCImage -> { - val imageBitmap: ImageBitmap? = getLoadedImage(item.file) + val res = getLoadedImage(item.file) val filePath = getLoadedFilePath(item.file) - if (imageBitmap != null && filePath != null) { - val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) - ProviderMedia.Image(uri, imageBitmap) + if (res != null && filePath != null) { + val (imageBitmap: ImageBitmap, data: ByteArray) = res + ProviderMedia.Image(data, imageBitmap) } else null } is MsgContent.MCVideo -> { 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 01090705d..4d6bc297f 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 @@ -411,8 +411,8 @@ fun ComposeView( is ComposePreview.MediaPreview -> { preview.content.forEachIndexed { index, it -> val file = when (it) { - is UploadContent.SimpleImage -> saveImage(it.uri) - is UploadContent.AnimatedImage -> saveAnimImage(it.uri) + 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) } if (file != null) { @@ -429,16 +429,21 @@ fun ComposeView( val tmpFile = File(preview.voice) AudioPlayer.stop(tmpFile.absolutePath) val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) - withContext(Dispatchers.IO) { - Files.move(tmpFile.toPath(), actualFile.toPath()) - } - // TODO encrypt voice files - files.add(CryptoFile.plain(actualFile.name)) + 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() msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000)) } is ComposePreview.FilePreview -> { - val file = saveFileFromUri(preview.uri, encrypted = false) + val file = saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) if (file != null) { files.add((file)) msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt index 99d7de96b..a4c90d30d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt @@ -17,6 +17,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.model.CryptoFile import chat.simplex.common.model.durationText import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -52,7 +53,7 @@ fun ComposeVoiceView( IconButton( onClick = { if (!audioPlaying.value) { - AudioPlayer.play(filePath, audioPlaying, progress, duration, false) + AudioPlayer.play(CryptoFile.plain(filePath), audioPlaying, progress, duration, false) } else { AudioPlayer.pause(audioPlaying, progress) } 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 4642600fc..8de805ba5 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 @@ -71,7 +71,8 @@ fun CIFileView( when (file.fileStatus) { is CIFileStatus.RcvInvitation -> { if (fileSizeValid()) { - receiveFile(file.fileId, false) + val encrypted = file.fileProtocol == FileProtocol.XFTP && chatController.appPrefs.privacyEncryptLocalFiles.get() + receiveFile(file.fileId, encrypted) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), @@ -184,9 +185,9 @@ fun CIFileView( ) { fileIndicator() val metaReserve = if (edited) - " " + " " else - " " + " " if (file != null) { Column { Text( @@ -211,7 +212,15 @@ fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = rememberFileChooserLauncher(false, ciFile) { to: URI? -> val filePath = getLoadedFilePath(ciFile) if (filePath != null && to != null) { - copyFileToFile(File(filePath), to) {} + if (ciFile?.fileSource?.cryptoArgs != null) { + createTmpFileAndDelete { tmpFile -> + decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + copyFileToFile(tmpFile, to) {} + tmpFile.delete() + } + } else { + copyFileToFile(File(filePath), to) {} + } } } 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 75d6a9c30..23d1f1d0c 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 @@ -29,6 +29,8 @@ import java.net.URI fun CIImageView( image: String, file: CIFile?, + encryptLocalFile: Boolean, + metaColor: Color, imageProvider: () -> ImageGalleryProvider, showMenu: MutableState<Boolean>, receiveFile: (Long, Boolean) -> Unit @@ -48,7 +50,7 @@ fun CIImageView( icon, stringResource(stringId), Modifier.fillMaxSize(), - tint = Color.White + tint = metaColor ) } @@ -132,28 +134,31 @@ fun CIImageView( return false } - fun imageAndFilePath(file: CIFile?): Pair<ImageBitmap?, String?> { - val imageBitmap: ImageBitmap? = getLoadedImage(file) - val filePath = getLoadedFilePath(file) - return imageBitmap to filePath + fun imageAndFilePath(file: CIFile?): Triple<ImageBitmap, ByteArray, String>? { + val res = getLoadedImage(file) + if (res != null) { + val (imageBitmap: ImageBitmap, data: ByteArray) = res + val filePath = getLoadedFilePath(file)!! + return Triple(imageBitmap, data, filePath) + } + return null } Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), contentAlignment = Alignment.TopEnd ) { - val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) } - if (imageBitmap != null && filePath != null) { - val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) } - SimpleAndAnimatedImageView(uri, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) }) + val res = remember(file) { imageAndFilePath(file) } + if (res != null) { + val (imageBitmap, data, _) = res + SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) }) } else { imageView(base64ToBitmap(image), onClick = { if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation -> if (fileSizeValid()) { - // TODO encrypt image - receiveFile(file.fileId, false) + receiveFile(file.fileId, encryptLocalFile) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), @@ -187,7 +192,7 @@ fun CIImageView( @Composable expect fun SimpleAndAnimatedImageView( - uri: URI, + data: ByteArray, imageBitmap: ImageBitmap, file: CIFile?, imageProvider: () -> ImageGalleryProvider, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index ab121c627..72f7137b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -44,14 +44,14 @@ fun CIMetaView( modifier = Modifier.padding(start = 3.dp) ) } else { - CIMetaText(chatItem.meta, timedMessagesTTL, metaColor, paleMetaColor) + CIMetaText(chatItem.meta, timedMessagesTTL, encrypted = chatItem.encryptedFile, metaColor, paleMetaColor) } } } @Composable // changing this function requires updating reserveSpaceForMeta -private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Color) { +private fun CIMetaText(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?, color: Color, paleColor: Color) { if (meta.itemEdited) { StatusIconText(painterResource(MR.images.ic_edit), color) Spacer(Modifier.width(3.dp)) @@ -77,11 +77,15 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Col StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent) Spacer(Modifier.width(4.dp)) } + if (encrypted != null) { + StatusIconText(painterResource(if (encrypted) MR.images.ic_lock else MR.images.ic_lock_open_right), color) + Spacer(Modifier.width(4.dp)) + } Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } // the conditions in this function should match CIMetaText -fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String { +fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?): String { val iconSpace = " " var res = "" if (meta.itemEdited) res += iconSpace @@ -95,6 +99,9 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String { if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) { res += iconSpace } + if (encrypted != null) { + res += iconSpace + } return res + meta.timestampText } 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 2918d885b..8de309fc8 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 @@ -166,7 +166,7 @@ fun DecryptionErrorItemFixButton( Text( buildAnnotatedString { append(generalGetString(MR.strings.fix_connection)) - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) } + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) } withStyle(reserveTimestampStyle) { append(" ") } // for icon }, color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary @@ -196,7 +196,7 @@ fun DecryptionErrorItem( Text( buildAnnotatedString { withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) } + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) } }, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) ) 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 6ec39bb4f..941bc315b 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 @@ -20,8 +20,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* -import chat.simplex.common.platform.getLoadedFilePath -import chat.simplex.common.platform.AudioPlayer +import chat.simplex.common.platform.* import chat.simplex.res.MR import kotlinx.coroutines.flow.distinctUntilChanged @@ -45,14 +44,16 @@ fun CIVoiceView( ) { if (file != null) { val f = file.fileSource?.filePath - val filePath = remember(f, file.fileStatus) { getLoadedFilePath(file) } + val fileSource = remember(f, file.fileStatus) { 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 = { - AudioPlayer.play(filePath, audioPlaying, progress, duration, true) - brokenAudio = !audioPlaying.value + if (fileSource != null) { + AudioPlayer.play(fileSource, audioPlaying, progress, duration, true) + brokenAudio = !audioPlaying.value + } } val pause = { AudioPlayer.pause(audioPlaying, progress) @@ -67,7 +68,7 @@ fun CIVoiceView( } } VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) { - AudioPlayer.seekTo(it, progress, filePath) + AudioPlayer.seekTo(it, progress, fileSource?.filePath) } } else { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile) @@ -269,8 +270,7 @@ private fun VoiceMsgIndicator( } } else { if (file?.fileStatus is CIFileStatus.RcvInvitation) { - // TODO encrypt voice - PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, false) }, {}, longClick = longClick) + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, chatController.appPrefs.privacyEncryptLocalFiles.get()) }, {}, longClick = longClick) } else if (file?.fileStatus is CIFileStatus.RcvTransfer || file?.fileStatus is CIFileStatus.RcvAccepted ) { 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 cc2d97e3f..60ef7e8cf 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 @@ -191,9 +191,9 @@ fun ChatItemView( } val clipboard = LocalClipboardManager.current ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val filePath = getLoadedFilePath(cItem.file) + val fileSource = getLoadedFileSource(cItem.file) when { - filePath != null -> shareFile(cItem.text, filePath) + fileSource != null -> shareFile(cItem.text, fileSource) else -> clipboard.shareText(cItem.content.text) } showMenu.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 92cf62a85..122e54c3b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -226,7 +226,7 @@ fun FramedItemView( } else { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile) + CIImageView(image = mc.image, file = ci.file, ci.encryptLocalFile, metaColor = metaColor, imageProvider ?: return@PriorityLayout, showMenu, receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 270c671fc..9664cabc4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -123,8 +123,8 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> // LALAL // https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24 if (media is ProviderMedia.Image) { - val (uri: URI, imageBitmap: ImageBitmap) = media - FullScreenImageView(modifier, uri, imageBitmap) + val (data: ByteArray, imageBitmap: ImageBitmap) = media + FullScreenImageView(modifier, data, imageBitmap) } else if (media is ProviderMedia.Video) { val preview = remember(media.uri.path) { base64ToBitmap(media.preview) } VideoView(modifier, media.uri, preview, index == settledCurrentPage) @@ -138,7 +138,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> } @Composable -expect fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) +expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) @Composable private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 64855e319..eabab138b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -76,7 +76,7 @@ fun MarkdownText ( val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) { "\n" } else if (meta != null) { - reserveSpaceForMeta(meta, chatTTL) + reserveSpaceForMeta(meta, chatTTL, null) // LALAL } else { " " } 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 9ecd7dae3..fa0f8f54d 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 @@ -178,7 +178,7 @@ fun DatabaseLayout( SectionView(stringResource(MR.strings.chat_database_section)) { val unencrypted = chatDbEncrypted == false SettingsActionItem( - if (unencrypted) painterResource(MR.images.ic_lock_open) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) + if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_lock), stringResource(MR.strings.database_passphrase), click = showSettingsModal() { DatabaseEncryptionView(it) }, 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 b9eeee12b..6aaf7a9fd 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?): ImageBitmap? +expect fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? expect fun getFileName(uri: URI): String? @@ -77,6 +77,8 @@ expect fun getFileSize(uri: URI): Long? expect fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean = true): ImageBitmap? +expect fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? + expect fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean = true): Any? fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverrides? { @@ -95,31 +97,34 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri return null } -fun saveImage(uri: URI): CryptoFile? { +fun saveImage(uri: URI, encrypted: Boolean): CryptoFile? { val bitmap = getBitmapFromUri(uri) ?: return null - return saveImage(bitmap) + return saveImage(bitmap, encrypted) } -fun saveImage(image: ImageBitmap): CryptoFile? { - // TODO encrypt image +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 fileToSave = generateNewFileName("IMG", ext) - val file = File(getAppFilePath(fileToSave)) - val output = FileOutputStream(file) - dataResized.writeTo(output) - output.flush() - output.close() - CryptoFile.plain(fileToSave) + val destFileName = generateNewFileName("IMG", ext) + val destFile = File(getAppFilePath(destFileName)) + if (encrypted) { + val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray()) + CryptoFile(destFileName, args) + } else { + val output = FileOutputStream(destFile) + dataResized.writeTo(output) + output.flush() + output.close() + CryptoFile.plain(destFileName) + } } catch (e: Exception) { Log.e(TAG, "Util.kt saveImage error: ${e.stackTraceToString()}") null } } -fun saveAnimImage(uri: URI): CryptoFile? { - // TODO encrypt image +fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { return try { val filename = getFileName(uri)?.lowercase() var ext = when { @@ -129,15 +134,15 @@ fun saveAnimImage(uri: URI): CryptoFile? { } // Just in case the image has a strange extension if (ext.length < 3 || ext.length > 4) ext = "gif" - val fileToSave = generateNewFileName("IMG", ext) - val file = File(getAppFilePath(fileToSave)) - val output = FileOutputStream(file) - uri.inputStream().use { input -> - output.use { output -> - input?.copyTo(output) - } + val destFileName = generateNewFileName("IMG", ext) + val destFile = File(getAppFilePath(destFileName)) + if (encrypted) { + val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readAllBytes() ?: return null) + CryptoFile(destFileName, args) + } else { + Files.copy(uri.inputStream(), destFile.toPath()) + CryptoFile.plain(destFileName) } - CryptoFile.plain(fileToSave) } catch (e: Exception) { Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}") null @@ -150,22 +155,40 @@ fun saveFileFromUri(uri: URI, encrypted: Boolean): CryptoFile? { return try { val inputStream = uri.inputStream() val fileToSave = getFileName(uri) - // TODO encrypt file if "encrypted" is true - if (inputStream != null && fileToSave != null) { + return if (inputStream != null && fileToSave != null) { val destFileName = uniqueCombine(fileToSave) val destFile = File(getAppFilePath(destFileName)) - Files.copy(inputStream, destFile.toPath()) - CryptoFile.plain(destFileName) + if (encrypted) { + createTmpFileAndDelete { tmpFile -> + Files.copy(inputStream, tmpFile.toPath()) + val args = encryptCryptoFile(tmpFile.absolutePath, destFile.absolutePath) + CryptoFile(destFileName, args) + } + } else { + Files.copy(inputStream, destFile.toPath()) + CryptoFile.plain(destFileName) + } } else { Log.e(TAG, "Util.kt saveFileFromUri null inputStream") null } } catch (e: Exception) { - Log.e(TAG, "Util.kt saveFileFromUri error: ${e.message}") + Log.e(TAG, "Util.kt saveFileFromUri error: ${e.stackTraceToString()}") null } } +fun <T> createTmpFileAndDelete(onCreated: (File) -> T): T { + val tmpFile = File(tmpDir, UUID.randomUUID().toString()) + tmpFile.deleteOnExit() + ChatModel.filesToDelete.add(tmpFile) + try { + return onCreated(tmpFile) + } finally { + tmpFile.delete() + } +} + fun generateNewFileName(prefix: String, ext: String): String { val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) sdf.timeZone = TimeZone.getTimeZone("GMT") @@ -266,6 +289,17 @@ fun blendARGB( return Color(r, g, b, a) } +fun InputStream.toByteArray(): ByteArray = + ByteArrayOutputStream().use { output -> + val b = ByteArray(4096) + var n = read(b) + while (n != -1) { + output.write(b, 0, n); + n = read(b) + } + return output.toByteArray() + } + expect fun ByteArray.toBase64StringForPassphrase(): String // Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index a848d3777..663292596 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import boofcv.alg.drawing.FiducialImageEngine import boofcv.alg.fiducial.qrcode.* +import chat.simplex.common.model.CryptoFile import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.helpers.* @@ -45,7 +46,7 @@ fun QRCode( .let { if (withLogo) it.addLogo() else it } val file = saveTempImageUncompressed(image, false) if (file != null) { - shareFile("", file.absolutePath) + shareFile("", CryptoFile.plain(file.absolutePath)) } } } 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 81d56a381..ef0940b2a 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 @@ -64,6 +64,7 @@ fun PrivacySettingsView( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_chats)) { + SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles) SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) SettingsPreferenceItem( 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 ac3a68fc4..7929413c9 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 @@ -164,10 +164,9 @@ private fun UserProfilesLayout( ) { if (profileHidden.value) { SectionView { - SettingsActionItem(painterResource(MR.images.ic_lock_open), stringResource(MR.strings.enter_password_to_show), click = { + SettingsActionItem(painterResource(MR.images.ic_lock_open_right), stringResource(MR.strings.enter_password_to_show), click = { profileHidden.value = false - } - ) + }) } SectionSpacer() } @@ -223,7 +222,7 @@ private fun UserView( Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { DefaultDropdownMenu(showMenu) { if (user.hidden) { - ItemAction(stringResource(MR.strings.user_unhide), painterResource(MR.images.ic_lock_open), onClick = { + ItemAction(stringResource(MR.strings.user_unhide), painterResource(MR.images.ic_lock_open_right), onClick = { showMenu.value = false unhideUser(user) }) 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 2b83ff869..ae55ad7e5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -855,6 +855,7 @@ <string name="privacy_and_security">Privacy & security</string> <string name="your_privacy">Your privacy</string> <string name="protect_app_screen">Protect app screen</string> + <string name="encrypt_local_files">Encrypt local files</string> <string name="auto_accept_images">Auto-accept images</string> <string name="send_link_previews">Send link previews</string> <string name="privacy_show_last_messages">Show last messages</string> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg deleted file mode 100644 index bf6b7b47b..000000000 --- a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 96 960 960" width="24"><path d="M222 971q-23.719 0-40.609-16.891Q164.5 937.219 164.5 913.5v-431q0-23.719 16.891-40.609Q198.281 425 222 425h387v-95.385q0-53.782-37.373-91.198Q534.254 201 479.863 201q-46.363 0-81.363 28T354 300.5q-3 13-11.75 21.25T321.983 330q-12.311 0-20.397-8.5-8.086-8.5-6.086-20 10-68 61.902-113t122.629-45q77.383 0 131.926 54.551Q666.5 252.603 666.5 330v95H738q23.719 0 40.609 16.891Q795.5 458.781 795.5 482.5v431q0 23.719-16.891 40.609Q761.719 971 738 971H222Zm0-57.5h516v-431H222v431Zm258.084-140q31.179 0 53.297-21.566 22.119-21.566 22.119-51.85 0-29.347-22.203-53.465-22.203-24.119-53.381-24.119-31.179 0-53.297 24.035-22.119 24.034-22.119 53.881t22.203 51.465q22.203 21.619 53.381 21.619ZM222 482.5v431-431Z"/></svg> \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg new file mode 100644 index 000000000..3188cf798 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M222-142.5h516v-431H222v431Zm258.084-140q31.179 0 53.297-21.566 22.119-21.566 22.119-51.85 0-29.347-22.203-53.465-22.203-24.119-53.381-24.119-31.179 0-53.297 24.035-22.119 24.034-22.119 53.881t22.203 51.465q22.203 21.619 53.381 21.619ZM222-142.5v-431 431Zm0 57.5q-23.719 0-40.609-16.891Q164.5-118.781 164.5-142.5v-431q0-23.719 16.891-40.609Q198.281-631 222-631h329.5v-95.018q0-77.832 54.349-132.157Q660.198-912.5 738-912.5q70 0 121.25 44T922-759q2 11.5-6.638 22.25T895.75-726q-12.66 0-20.705-6-8.045-6-9.545-18.5-9-44.5-44.55-74.5T738-855q-54.333 0-91.667 37.333Q609-780.333 609-726.231V-631h129q23.719 0 40.609 16.891Q795.5-597.219 795.5-573.5v431q0 23.719-16.891 40.609Q761.719-85 738-85H222Z"/></svg> \ No newline at end of file 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 471389d0c..612217925 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 @@ -25,6 +25,8 @@ fun initApp() { initChatController() runMigrations() } + // LALAL + //testCrypto() } private fun applyAppLocale() { 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 2ba6f3b3f..6e85ea91c 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 @@ -1,7 +1,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.MutableState -import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.* import chat.simplex.common.views.usersettings.showInDevelopingAlert import kotlinx.coroutines.CoroutineScope @@ -18,7 +18,7 @@ actual class RecorderNative: RecorderInterface { } actual object AudioPlayer: AudioPlayerInterface { - override fun play(filePath: String?, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, resetOnEnd: Boolean) { + override fun play(fileSource: CryptoFile, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, resetOnEnd: Boolean) { showInDevelopingAlert() } 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 84e24a1d5..1d5ab45bb 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 @@ -3,6 +3,8 @@ package chat.simplex.common.platform 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 @@ -20,12 +22,16 @@ actual fun ClipboardManager.shareText(text: String) { showToast(MR.strings.copied.localized()) } -actual fun shareFile(text: String, filePath: String) { +actual fun shareFile(text: String, fileSource: CryptoFile) { withApi { FileChooserLauncher(false) { to: URI? -> if (to != null) { - copyFileToFile(File(filePath), to) {} + if (fileSource.cryptoArgs != null) { + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, to.path) + } else { + copyFileToFile(File(fileSource.filePath), to) {} + } } - }.launch(filePath) + }.launch(fileSource.filePath) } } 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 214946b1c..711e09267 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 @@ -11,7 +11,7 @@ import java.net.URI @Composable actual fun SimpleAndAnimatedImageView( - uri: URI, + data: ByteArray, imageBitmap: ImageBitmap, file: CIFile?, imageProvider: () -> ImageGalleryProvider, 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 9b265a5f5..c1d9eeec5 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 @@ -31,7 +31,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) { - ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { + 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 -> {} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index e4e483092..a73c2784e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -4,19 +4,16 @@ import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import chat.simplex.common.platform.VideoPlayer -import chat.simplex.common.views.helpers.getBitmapFromUri +import chat.simplex.common.views.helpers.getBitmapFromByteArray import chat.simplex.res.MR -import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import java.net.URI @Composable -actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) { +actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) { Image( - getBitmapFromUri(uri, false) ?: MR.images.decentralized.image.toComposeImageBitmap(), + getBitmapFromByteArray(data, false) ?: MR.images.decentralized.image.toComposeImageBitmap(), contentDescription = stringResource(MR.strings.image_descr), contentScale = ContentScale.Fit, modifier = modifier, 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 f4a9ac9b7..4fa768a5d 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 @@ -6,8 +6,10 @@ 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.platform.* import chat.simplex.common.simplexWindowState +import java.io.ByteArrayInputStream import java.io.File import java.net.URI import javax.imageio.ImageIO @@ -88,11 +90,12 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat actual fun getAppFileUri(fileName: String): URI = URI("file:" + appFilesDir.absolutePath + File.separator + fileName) -actual fun getLoadedImage(file: CIFile?): ImageBitmap? { +actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? { val filePath = getLoadedFilePath(file) return if (filePath != null) { - val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) - getBitmapFromUri(uri, false) + 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 } else { null } @@ -107,6 +110,9 @@ actual fun getFileSize(uri: URI): Long? = uri.toPath().toFile().length() actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? = ImageIO.read(uri.inputStream()).toComposeImageBitmap() +actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? = + ImageIO.read(ByteArrayInputStream(data)).toComposeImageBitmap() + // LALAL implement to support animated drawable actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? = null From 55954a004bcfd2d196d505176a0e7c15e3e4cacc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 10 Sep 2023 18:53:34 +0100 Subject: [PATCH 39/41] android, desktop: notices about SOCKS proxy limitations (#3044) --- .../common/views/usersettings/NetworkAndServers.kt | 8 +++++++- .../common/src/commonMain/resources/MR/base/strings.xml | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) 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 447b65eff..f2fee926a 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 @@ -180,7 +180,13 @@ fun NetworkAndServersView( SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) } if (networkUseSocksProxy.value) { - SectionCustomFooter { Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) } + SectionCustomFooter { + Column { + Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + Text(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) + } + } Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp)) } else { Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp)) 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 ae55ad7e5..43c237f97 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -615,7 +615,7 @@ <string name="network_use_onion_hosts_required">Required</string> <string name="network_use_onion_hosts_prefer_desc">Onion hosts will be used when available.</string> <string name="network_use_onion_hosts_no_desc">Onion hosts will not be used.</string> - <string name="network_use_onion_hosts_required_desc">Onion hosts will be required for connection.</string> + <string name="network_use_onion_hosts_required_desc">Onion hosts will be required for connection.\nPlease note: you will not be able to connect to the servers without .onion address.</string> <string name="network_use_onion_hosts_prefer_desc_in_alert">Onion hosts will be used when available.</string> <string name="network_use_onion_hosts_no_desc_in_alert">Onion hosts will not be used.</string> <string name="network_use_onion_hosts_required_desc_in_alert">Onion hosts will be required for connection.</string> @@ -626,6 +626,7 @@ <string name="network_session_mode_entity_description"><![CDATA[A separate TCP connection (and SOCKS credential) will be used <b>for each contact and group member</b>.\n<b>Please note</b>: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.]]></string> <string name="update_network_session_mode_question">Update transport isolation mode?</string> <string name="disable_onion_hosts_when_not_supported"><![CDATA[Set <i>Use .onion hosts</i> to No if SOCKS proxy does not support them.]]></string> + <string name="socks_proxy_setting_limitations"><![CDATA[<b>Please note</b>: message and file relays are connected via SOCKS proxy. Calls and link previews use direct network connection.]]></string> <string name="appearance_settings">Appearance</string> <string name="customize_theme_title">Customize theme</string> <string name="theme_colors_section_title">THEME COLORS</string> From 7b582b2cf90706eb00a51ce27e297923cc470611 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 10 Sep 2023 20:04:50 +0100 Subject: [PATCH 40/41] android, desktop: update SOCKS notice --- .../common/src/commonMain/resources/MR/base/strings.xml | 2 +- 1 file changed, 1 insertion(+), 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 43c237f97..8e035420d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -626,7 +626,7 @@ <string name="network_session_mode_entity_description"><![CDATA[A separate TCP connection (and SOCKS credential) will be used <b>for each contact and group member</b>.\n<b>Please note</b>: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.]]></string> <string name="update_network_session_mode_question">Update transport isolation mode?</string> <string name="disable_onion_hosts_when_not_supported"><![CDATA[Set <i>Use .onion hosts</i> to No if SOCKS proxy does not support them.]]></string> - <string name="socks_proxy_setting_limitations"><![CDATA[<b>Please note</b>: message and file relays are connected via SOCKS proxy. Calls and link previews use direct network connection.]]></string> + <string name="socks_proxy_setting_limitations"><![CDATA[<b>Please note</b>: message and file relays are connected via SOCKS proxy. Calls and sending link previews use direct connection.]]></string> <string name="appearance_settings">Appearance</string> <string name="customize_theme_title">Customize theme</string> <string name="theme_colors_section_title">THEME COLORS</string> From 2dff6c88594d221997246b024b5ef1eda708d30c Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Sun, 10 Sep 2023 22:40:15 +0300 Subject: [PATCH 41/41] core: do not subscribe to new connections from iOS NSE (subscribe=off flag), subscribe in app when it activates (#3016) * Trace auto-subs flag * Replace Bool with SubscriptionMode * Add subscriptionMode to chat controller * Start using subscriptionMode in event handlers * Add need_subs to chat connections * Add onlyNeeded to subscribeUserConnections * Post-rebase fixes * Pass onlyNeeded to Store functions * Drop needs_sub for connections registered with agent * update simplexmq, fix activate * fix rebase, reduce diff * fix rebase, tests * fix rebase, executeMany, always subscribe on activate * test * update queries * Update src/Simplex/Chat.hs Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * unset connections to subscribe on start * update simplexmq --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 194 ++++++++++++------ src/Simplex/Chat/Controller.hs | 11 +- .../M20230903_connections_to_subscribe.hs | 20 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 2 + src/Simplex/Chat/Store/Connections.hs | 24 ++- src/Simplex/Chat/Store/Direct.hs | 27 +-- src/Simplex/Chat/Store/Files.hs | 33 +-- src/Simplex/Chat/Store/Groups.hs | 51 ++--- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 8 +- src/Simplex/Chat/Store/Shared.hs | 11 +- stack.yaml | 2 +- tests/ChatTests/Direct.hs | 31 +++ 16 files changed, 286 insertions(+), 137 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs diff --git a/cabal.project b/cabal.project index ec72b72fc..983468726 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: 351f42650c57f310fc1ea858ff9b7178823f1fd4 + tag: 0cabe0690beee90f460ad7bada72294222e7e109 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index dbbc7475c..493985085 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."351f42650c57f310fc1ea858ff9b7178823f1fd4" = "12r13yc0qk9dkii58808862wraqrk66rzmkrgyp6lg1xrazrd0d2"; + "https://github.com/simplex-chat/simplexmq.git"."0cabe0690beee90f460ad7bada72294222e7e109" = "1yfcrifb2l59wgl14q56ywlil2g2zs57ic62s617whh3w2mnh0kz"; "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"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 335e0ee10..7750069b5 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -110,6 +110,7 @@ library Simplex.Chat.Migrations.M20230814_indexes Simplex.Chat.Migrations.M20230827_file_encryption Simplex.Chat.Migrations.M20230829_connections_chat_vrange + Simplex.Chat.Migrations.M20230903_connections_to_subscribe Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 359e7f5b5..49c5fc94e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -89,7 +89,7 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), UserProtocol, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) import qualified Simplex.Messaging.Protocol as SMP import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxy) @@ -194,6 +194,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize notifyQ <- newTBQueueIO tbqSize + subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty @@ -207,7 +208,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, 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, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} where configServers :: DefaultAgentServers configServers = @@ -246,6 +247,8 @@ cfgServers = \case startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ()) startChatController subConns enableExpireCIs startXFTPWorkers = do asks smpAgent >>= resumeAgentClient + unless subConns $ + chatWriteVar subscriptionMode SMOnlyCreate users <- fromRight [] <$> runExceptT (withStoreCtx' (Just "startChatController, getUsers") getUsers) restoreCalls s <- asks agentAsync @@ -255,7 +258,7 @@ startChatController subConns enableExpireCIs startXFTPWorkers = do a1 <- async $ race_ notificationSubscriber agentSubscriber a2 <- if subConns - then Just <$> async (subscribeUsers users) + then Just <$> async (subscribeUsers False users) else pure Nothing atomically . writeTVar s $ Just (a1, a2) when startXFTPWorkers $ do @@ -283,14 +286,14 @@ startChatController subConns enableExpireCIs startXFTPWorkers = do startExpireCIThread user setExpireCIFlag user True -subscribeUsers :: forall m. ChatMonad' m => [User] -> m () -subscribeUsers users = do +subscribeUsers :: forall m. ChatMonad' m => Bool -> [User] -> m () +subscribeUsers onlyNeeded users = do let (us, us') = partition activeUser users subscribe us subscribe us' where subscribe :: [User] -> m () - subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections + subscribe = mapM_ $ runExceptT . subscribeUserConnections onlyNeeded Agent.subscribeConnections startFilesToReceive :: forall m. ChatMonad' m => [User] -> m () startFilesToReceive users = do @@ -464,14 +467,16 @@ processChatCommand = \case APIActivateChat -> withUser $ \_ -> do restoreCalls withAgent foregroundAgent - withStoreCtx' (Just "APIActivateChat, getUsers") getUsers >>= void . forkIO . startFilesToReceive + users <- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers + void . forkIO $ subscribeUsers True users + void . forkIO $ startFilesToReceive users setAllExpireCIFlags True ok_ APISuspendChat t -> do setAllExpireCIFlags False withAgent (`suspendAgent` t) ok_ - ResubscribeAllConnections -> withStoreCtx' (Just "ResubscribeAllConnections, getUsers") getUsers >>= subscribeUsers >> ok_ + ResubscribeAllConnections -> withStoreCtx' (Just "ResubscribeAllConnections, getUsers") getUsers >>= subscribeUsers False >> ok_ -- has to be called before StartChat SetTempFolder tf -> do createDirectoryIfMissing True tf @@ -567,15 +572,16 @@ processChatCommand = \case smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled smpSndFileTransfer (CryptoFile file Nothing) fileSize fileInline = do + subMode <- chatReadVar subscriptionMode (agentConnId_, fileConnReq) <- if isJust fileInline then pure (Nothing, Nothing) - else bimap Just Just <$> withAgent (\a -> createConnection a (aUserId user) True SCMInvitation Nothing) + else bimap Just Just <$> withAgent (\a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode) let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} chSize <- asks $ fileChunkSize . config withStore' $ \db -> do - ft@FileTransferMeta {fileId} <- createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize + ft@FileTransferMeta {fileId} <- createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize subMode fileStatus <- case fileInline of Just IFMSent -> createSndDirectInlineFT db ct ft $> CIFSSndTransfer 0 1 _ -> pure CIFSSndStored @@ -1273,8 +1279,9 @@ processChatCommand = \case APIAddContact userId incognito -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile + subMode <- chatReadVar subscriptionMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode toView $ CRNewContactConnection user conn pure $ CRInvitation user cReq conn AddContact incognito -> withUser $ \User {userId} -> @@ -1295,12 +1302,13 @@ processChatCommand = \case Just conn' -> pure $ CRConnectionIncognitoUpdated user conn' Nothing -> throwChatError CEConnectionIncognitoChangeProhibited APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do + subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing dm <- directMessage $ XInfo profileToSend - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined $ incognitoProfile $> profileToSend + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode toView $ CRNewContactConnection user conn pure $ CRSentConfirmation user APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq @@ -1317,8 +1325,9 @@ processChatCommand = \case ListContacts -> withUser $ \User {userId} -> processChatCommand $ APIListContacts userId APICreateMyAddress userId -> withUserId userId $ \user -> withChatLock "createMyAddress" . procCmd $ do - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing - withStore $ \db -> createUserContactLink db user connId cReq + subMode <- chatReadVar subscriptionMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing subMode + withStore $ \db -> createUserContactLink db user connId cReq subMode pure $ CRUserContactLinkCreated user cReq CreateMyAddress -> withUser $ \User {userId} -> processChatCommand $ APICreateMyAddress userId @@ -1423,8 +1432,9 @@ processChatCommand = \case case contactMember contact members of Nothing -> do gVar <- asks idsDrg - (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing - member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq + 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 sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} @@ -1443,10 +1453,11 @@ processChatCommand = \case let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation Contact {activeConn = Connection {peerChatVRange}} = ct withChatLock "joinGroup" . procCmd $ do + subMode <- chatReadVar subscriptionMode dm <- directMessage $ XGrpAcpt (memberId (membership :: GroupMember)) - agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm + agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode withStore' $ \db -> do - createMemberConnection db userId fromMember agentConnId peerChatVRange + createMemberConnection db userId fromMember agentConnId peerChatVRange subMode updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted updateCIGroupInvitationStatus user @@ -1557,9 +1568,10 @@ processChatCommand = \case assertUserGroupRole gInfo GRAdmin when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole groupLinkId <- GroupLinkId <$> drgRandomBytes 16 + subMode <- chatReadVar subscriptionMode let crClientData = encodeJSON $ CRDataGroup groupLinkId - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData - withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) subMode + withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole subMode pure $ CRGroupLinkCreated user gInfo cReq mRole APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withChatLock "groupLinkMemberRole " $ do gInfo <- withStore $ \db -> getGroupInfo db user groupId @@ -1845,13 +1857,14 @@ processChatCommand = \case (_, xContactId_) -> procCmd $ do let randomXContactId = XContactId <$> drgRandomBytes 16 xContactId <- maybe randomXContactId pure xContactId_ + subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing dm <- directMessage (XContact profileToSend $ Just xContactId) - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli - conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId + conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode toView $ CRNewContactConnection user conn pure $ CRSentInvitation user incognitoProfile contactMember :: Contact -> [GroupMember] -> Maybe GroupMember @@ -2240,9 +2253,11 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI case (xftpRcvFile, fileConnReq) of -- direct file protocol (Nothing, Just connReq) -> do - connIds <- joinAgentConnectionAsync user True connReq =<< directMessage (XFileAcpt fName) + subMode <- chatReadVar subscriptionMode + dm <- directMessage $ XFileAcpt fName + connIds <- joinAgentConnectionAsync user True connReq dm subMode filePath <- getRcvFilePath fileId filePath_ fName True - withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnJoined filePath + withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnJoined filePath subMode -- XFTP (Just XFTPRcvFile {cryptoArgs}, _) -> do filePath <- getRcvFilePath fileId filePath_ fName False @@ -2283,8 +2298,9 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI | fileInline == Just IFMSent -> throwChatError $ CEFileAlreadyReceiving fName | otherwise -> do -- accepting via a new connection - connIds <- createAgentConnectionAsync user cmdFunction True SCMInvitation - withStoreCtx (Just "acceptFile, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnNew filePath + subMode <- chatReadVar subscriptionMode + connIds <- createAgentConnectionAsync user cmdFunction True SCMInvitation subMode + withStoreCtx (Just "acceptFile, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnNew filePath subMode receiveInline :: m Bool receiveInline = do ChatConfig {fileChunkSize, inlineFiles = InlineFilesConfig {receiveChunks, offerChunks}} <- asks config @@ -2356,17 +2372,19 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of 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 let profileToSend = profileToSendOnAccept user incognitoProfile dm <- directMessage $ XInfo profileToSend - acId <- withAgent $ \a -> acceptContact a True invId dm - withStore' $ \db -> createAcceptedContact db user acId cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile + acId <- withAgent $ \a -> acceptContact a True invId dm subMode + withStore' $ \db -> createAcceptedContact db user acId cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile subMode acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile = do + subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile - (cmdId, acId) <- agentAcceptContactAsync user True invId $ XInfo profileToSend + (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode withStore' $ \db -> do - ct@Contact {activeConn = Connection {connId}} <- createAcceptedContact db user acId cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile + ct@Contact {activeConn = Connection {connId}} <- createAcceptedContact db user acId cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode setCommandConnId db user cmdId connId pure ct @@ -2413,18 +2431,28 @@ agentSubscriber = do type AgentBatchSubscribe m = AgentClient -> [ConnId] -> ExceptT AgentErrorType m (Map ConnId (Either AgentErrorType ())) -subscribeUserConnections :: forall m. ChatMonad m => AgentBatchSubscribe m -> User -> m () -subscribeUserConnections agentBatchSubscribe user@User {userId} = do +subscribeUserConnections :: forall m. ChatMonad m => Bool -> AgentBatchSubscribe m -> User -> m () +subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do -- get user connections ce <- asks $ subscriptionEvents . config - (ctConns, cts) <- getContactConns - (ucConns, ucs) <- getUserContactLinkConns - (gs, mConns, ms) <- getGroupMemberConns - (sftConns, sfts) <- getSndFileTransferConns - (rftConns, rfts) <- getRcvFileTransferConns - (pcConns, pcs) <- getPendingContactConns + (conns, cts, ucs, gs, ms, sfts, rfts, pcs) <- + if onlyNeeded + then do + (conns, entities) <- withStore' getConnectionsToSubscribe + let (cts, ucs, ms, sfts, rfts, pcs) = foldl' addEntity (M.empty, M.empty, M.empty, M.empty, M.empty, M.empty) entities + pure (conns, cts, ucs, [], ms, sfts, rfts, pcs) + else do + withStore' unsetConnectionToSubscribe + (ctConns, cts) <- getContactConns + (ucConns, ucs) <- getUserContactLinkConns + (gs, mConns, ms) <- getGroupMemberConns + (sftConns, sfts) <- getSndFileTransferConns + (rftConns, rfts) <- getRcvFileTransferConns + (pcConns, pcs) <- getPendingContactConns + let conns = concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns] + pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs) -- subscribe using batched commands - rs <- withAgent (`agentBatchSubscribe` concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns]) + rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce contactLinkSubsToView rs ucs @@ -2433,6 +2461,29 @@ subscribeUserConnections agentBatchSubscribe user@User {userId} = do rcvFileSubsToView rs rfts pendingConnSubsToView rs pcs where + addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case + RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs) + RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (toPCC c) pcs in (cts, ucs, ms, sfts, rfts, pcs') + RcvGroupMsgConnection c _g m -> let ms' = addConn c m ms in (cts, ucs, ms', sfts, rfts, pcs) + SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs) + RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs) + UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs) + addConn :: Connection -> a -> Map ConnId a -> Map ConnId a + addConn = M.insert . aConnId + toPCC Connection {connId, agentConnId, connStatus, viaUserContactLink, groupLinkId, customUserProfileId, localAlias, createdAt} = + PendingContactConnection + { pccConnId = connId, + pccAgentConnId = agentConnId, + pccConnStatus = connStatus, + viaContactUri = False, + viaUserContactLink, + groupLinkId, + customUserProfileId, + connReqInv = Nothing, + localAlias, + createdAt, + updatedAt = createdAt + } getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts @@ -2971,9 +3022,10 @@ 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 + subMode <- chatReadVar subscriptionMode gVar <- asks idsDrg - groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation - withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds peerChatVRange + groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode + withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds peerChatVRange subMode _ -> pure () Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do @@ -3920,8 +3972,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do then unless cancelled $ case fileConnReq_ of -- receiving via a separate connection Just fileConnReq -> do - connIds <- joinAgentConnectionAsync user True fileConnReq =<< directMessage XOk - withStore' $ \db -> createSndDirectFTConnection db user fileId connIds + subMode <- chatReadVar subscriptionMode + dm <- directMessage XOk + connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode + withStore' $ \db -> createSndDirectFTConnection db user fileId connIds subMode -- receiving inline _ -> do event <- withStore $ \db -> do @@ -4015,10 +4069,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do if fName == fileName then unless cancelled $ case (fileConnReq_, activeConn) of (Just fileConnReq, _) -> do + subMode <- chatReadVar subscriptionMode -- receiving via a separate connection -- [async agent commands] no continuation needed, but command should be asynchronous for stability - connIds <- joinAgentConnectionAsync user True fileConnReq =<< directMessage XOk - withStore' $ \db -> createSndGroupFileTransferConnection db user fileId connIds m + dm <- directMessage XOk + connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode + withStore' $ \db -> createSndGroupFileTransferConnection db user fileId connIds m subMode (_, Just conn) -> do -- receiving inline event <- withStore $ \db -> do @@ -4049,9 +4105,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId if sameGroupLinkId groupLinkId groupLinkId' then do - connIds <- joinAgentConnectionAsync user True connRequest =<< directMessage (XGrpAcpt memberId) + subMode <- chatReadVar subscriptionMode + dm <- directMessage $ XGrpAcpt memberId + connIds <- joinAgentConnectionAsync user True connRequest dm subMode withStore' $ \db -> do - createMemberConnectionAsync db user hostId connIds peerChatVRange + createMemberConnectionAsync db user hostId connIds peerChatVRange subMode updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) @@ -4285,18 +4343,19 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do then messageWarning "x.grp.mem.intro ignored: member already exists" else 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 - groupConnIds <- createConn + groupConnIds <- createConn subMode directConnIds <- case memberChatVRange of - Nothing -> Just <$> createConn + Nothing -> Just <$> createConn subMode Just mcvr - | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> Just <$> createConn -- pure Nothing - | otherwise -> Just <$> createConn + | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> Just <$> createConn subMode -- pure Nothing + | otherwise -> Just <$> createConn subMode let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing - void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId + void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" where - createConn = createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation + createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation subMode sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> m () sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do @@ -4330,14 +4389,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Nothing -> withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced Just m' -> pure m' withStore' $ \db -> saveMemberInvitation db toMember introInv + subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito dm <- directMessage $ XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership) -- [async agent commands] no continuation needed, but commands should be asynchronous for stability - groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq dm - directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user enableNtfs dcr dm + groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq dm subMode + directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user enableNtfs dcr dm subMode let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange - withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId + 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 @@ -4838,16 +4898,16 @@ cancelCIFile user file_ = fileAgentConnIds <- cancelFile' user (mkCIFileInfo file) True deleteAgentConnectionsAsync user fileAgentConnIds -createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> m (CommandId, ConnId) -createAgentConnectionAsync user cmdFunction enableNtfs cMode = do +createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId) +createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction - connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode + connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode subMode pure (cmdId, connId) -joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> m (CommandId, ConnId) -joinAgentConnectionAsync user enableNtfs cReqUri cInfo = do +joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> m (CommandId, ConnId) +joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn - connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo + connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo subMode pure (cmdId, connId) allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m () @@ -4857,11 +4917,11 @@ allowAgentConnectionAsync user conn@Connection {connId} confId msg = do withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted -agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> m (CommandId, ConnId) -agentAcceptContactAsync user enableNtfs invId msg = do +agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> m (CommandId, ConnId) +agentAcceptContactAsync user enableNtfs invId msg subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact dm <- directMessage msg - connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm + connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm subMode pure (cmdId, connId) deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m () diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6380da647..af9aa964c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -62,7 +62,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, UserProtocol, XFTPServerWithAuth) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) @@ -176,6 +176,7 @@ data ChatController = ChatController outputQ :: TBQueue (Maybe CorrId, ChatResponse), notifyQ :: TBQueue Notification, sendNotification :: Notification -> IO (), + subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), @@ -960,6 +961,14 @@ type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) +chatReadVar :: ChatMonad' m => (ChatController -> TVar a) -> m a +chatReadVar f = asks f >>= readTVarIO +{-# INLINE chatReadVar #-} + +chatWriteVar :: ChatMonad' m => (ChatController -> TVar a) -> a -> m () +chatWriteVar f value = asks f >>= atomically . (`writeTVar` value) +{-# INLINE chatWriteVar #-} + tryChatError :: ChatMonad m => m a -> m (Either ChatError a) tryChatError = tryAllErrors mkChatError {-# INLINE tryChatError #-} diff --git a/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs b/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs new file mode 100644 index 000000000..48ad8dbf8 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230903_connections_to_subscribe where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230903_connections_to_subscribe :: Query +m20230903_connections_to_subscribe = + [sql| +ALTER TABLE connections ADD COLUMN to_subscribe INTEGER DEFAULT 0 NOT NULL; +CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); +|] + +down_m20230903_connections_to_subscribe :: Query +down_m20230903_connections_to_subscribe = + [sql| +DROP INDEX idx_connections_to_subscribe; +ALTER TABLE connections DROP COLUMN to_subscribe; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index f0731b6ef..c71cc9aa9 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -287,6 +287,7 @@ CREATE TABLE connections( auth_err_counter INTEGER DEFAULT 0 CHECK(auth_err_counter NOT NULL), peer_chat_min_version INTEGER NOT NULL DEFAULT 1, peer_chat_max_version INTEGER NOT NULL DEFAULT 1, + to_subscribe INTEGER DEFAULT 0 NOT NULL, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -711,3 +712,4 @@ CREATE INDEX idx_chat_items_user_id_item_status ON chat_items( user_id, item_status ); +CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 4bd092b7b..025755c92 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} @@ -6,25 +7,30 @@ module Simplex.Chat.Store.Connections ( getConnectionEntity, + getConnectionsToSubscribe, + unsetConnectionToSubscribe, ) where import Control.Applicative ((<|>)) import Control.Monad.Except import Data.Int (Int64) -import Data.Maybe (fromMaybe) +import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import Data.Time.Clock (UTCTime (..)) -import Database.SQLite.Simple ((:.) (..)) +import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups +import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Messaging.Agent.Protocol (ConnId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow') import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Util (eitherToMaybe) getConnectionEntity :: DB.Connection -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity getConnectionEntity db user@User {userId, userContactId} agentConnId = do @@ -142,3 +148,17 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do userContact_ :: [(ConnReqContact, Maybe GroupId)] -> Either StoreError UserContact userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound + +getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) +getConnectionsToSubscribe db = do + aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" + entities <- forM aConnIds $ \acId -> do + getUserByAConnId db acId >>= \case + Just user -> eitherToMaybe <$> runExceptT (getConnectionEntity db user acId) + Nothing -> pure Nothing + unsetConnectionToSubscribe db + let connIds = map (\(AgentConnId connId) -> connId) aConnIds + pure (connIds, catMaybes entities) + +unsetConnectionToSubscribe :: DB.Connection -> IO () +unsetConnectionToSubscribe db = DB.execute_ db "UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1" diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 7df2858e9..609da128a 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -75,6 +75,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Version getPendingContactConnection :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO PendingContactConnection @@ -109,8 +110,8 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection -createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let pccConnStatus = ConnJoined @@ -119,10 +120,10 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, - via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt)) + ((userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate)) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -162,17 +163,17 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) -createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> IO PendingContactConnection -createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile = do +createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> IO PendingContactConnection +createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile DB.execute db [sql| INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, custom_user_profile_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?) + (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?) |] - (userId, acId, cReq, pccConnStatus, ConnContact, customUserProfileId, createdAt, createdAt) + (userId, acId, cReq, pccConnStatus, ConnContact, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -587,8 +588,8 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> IO Contact -createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do +createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> IO Contact +createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode = do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) createdAt <- getCurrentTime customUserProfileId <- forM incognitoProfile $ \case @@ -600,7 +601,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id) VALUES (?,?,?,?,?,?,?,?,?)" (userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId) contactId <- insertedRowId db - activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt + activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt} @@ -616,7 +617,7 @@ getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO C getContact_ db user@User {userId} contactId deleted = ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $ DB.query - db + db [sql| SELECT -- Contact diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index fa085908e..685d67e4d 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -100,6 +100,7 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Protocol (SubscriptionMode (..)) getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] getLiveSndFileTransfers db User {userId} = do @@ -156,8 +157,8 @@ getPendingSndChunks db fileId connId = |] (fileId, connId) -createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> IO FileTransferMeta -createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize = do +createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> SubscriptionMode -> IO FileTransferMeta +createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize subMode = do currentTs <- getCurrentTime DB.execute db @@ -165,7 +166,7 @@ createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitatio ((userId, contactId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs)) fileId <- insertedRowId db forM_ acId_ $ \acId -> do - Connection {connId} <- createSndFileConnection_ db userId fileId acId + Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode let fileStatus = FSNew DB.execute db @@ -173,10 +174,10 @@ createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitatio (fileId, fileStatus, fileInline, connId, currentTs, currentTs) pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} -createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> IO () -createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) = do +createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO () +createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) subMode = do currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId + Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode setCommandConnId db user cmdId connId DB.execute db @@ -193,10 +194,10 @@ createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation fileId <- insertedRowId db pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} -createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> IO () -createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} = do +createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO () +createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId + Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode setCommandConnId db user cmdId connId DB.execute db @@ -422,10 +423,10 @@ getChatRefByFileId db User {userId} fileId = |] (userId, fileId) -createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection -createSndFileConnection_ db userId fileId agentConnId = do +createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection +createSndFileConnection_ db userId fileId agentConnId subMode = do currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs + createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do @@ -644,14 +645,14 @@ getRcvFileTransfer db User {userId} fileId = do _ -> pure Nothing cancelled = fromMaybe False cancelled_ -acceptRcvFileTransfer :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> ExceptT StoreError IO AChatItem -acceptRcvFileTransfer db user@User {userId} fileId (cmdId, acId) connStatus filePath = ExceptT $ do +acceptRcvFileTransfer :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem +acceptRcvFileTransfer db user@User {userId} fileId (cmdId, acId) connStatus filePath subMode = ExceptT $ do currentTs <- getCurrentTime acceptRcvFT_ db user fileId filePath Nothing currentTs DB.execute db - "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs) + "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?)" + (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs, subMode == SMOnlyCreate) connId <- insertedRowId db setCommandConnId db user cmdId connId runExceptT $ getChatItemByFileId db user fileId diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 59f1b6090..81fc37cce 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -105,6 +105,7 @@ import Simplex.Messaging.Agent.Protocol (ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Protocol (SubscriptionMode) import Simplex.Messaging.Util (eitherToMaybe) import Simplex.Messaging.Version import UnliftIO.STM @@ -135,8 +136,8 @@ toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just member Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) toMaybeGroupMember _ _ = Nothing -createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> ExceptT StoreError IO () -createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId memberRole = +createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () +createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId memberRole subMode = checkConstraint (SEDuplicateGroupLink groupInfo) . liftIO $ do currentTs <- getCurrentTime DB.execute @@ -144,7 +145,7 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode getGroupLinkConnection :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO Connection getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = @@ -536,7 +537,7 @@ groupMemberQuery = LEFT JOIN connections c ON c.connection_id = ( SELECT max(cc.connection_id) FROM connections cc - where cc.user_id = ? AND cc.group_member_id = m.group_member_id + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id ) |] @@ -614,12 +615,12 @@ 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 -> ExceptT StoreError IO GroupMember -createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Connection {peerChatVRange}} memberRole agentConnId connRequest = +createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = 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 peerChatVRange Nothing 0 createdAt + void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode pure member where createMember_ memberId createdAt = do @@ -654,13 +655,13 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) ) -createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> ExceptT StoreError IO () -createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange = +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 = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt groupMemberId <- liftIO $ insertedRowId db - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode setCommandConnId db user cmdId connId where insertMember_ memberId createdAt = @@ -713,15 +714,15 @@ getMemberInvitation db User {userId} groupMemberId = fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) -createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRange -> IO () -createMemberConnection db userId GroupMember {groupMemberId} agentConnId peerChatVRange = do +createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRange -> SubscriptionMode -> IO () +createMemberConnection db userId GroupMember {groupMemberId} agentConnId peerChatVRange subMode = do currentTs <- getCurrentTime - void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs + void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode -createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRange -> IO () -createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) peerChatVRange = do +createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> IO () +createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) peerChatVRange subMode = do currentTs <- getCurrentTime - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode setCommandConnId db user cmdId connId updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () @@ -920,14 +921,14 @@ getIntroduction_ db reMember toMember = ExceptT $ do in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> ExceptT StoreError IO GroupMember -createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId = do +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 cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn currentTs <- liftIO getCurrentTime newMember <- case directConnIds of Just (directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs + Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode liftIO $ setCommandConnId db user directCmdId directConnId (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId} @@ -936,18 +937,18 @@ createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, 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 + conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId mcvr memberContactId cLevel currentTs subMode liftIO $ setCommandConnId db user groupCmdId groupConnId pure (member :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionRange -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> IO () -createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId = do +createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionRange -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () +createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn currentTs <- getCurrentTime - Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId mcvr viaContactId cLevel currentTs + Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId mcvr viaContactId cLevel currentTs subMode setCommandConnId db user groupCmdId groupConnId forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs + Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode setCommandConnId db user directCmdId directConnId contactId <- createMemberContact_ directConnId currentTs updateMember_ contactId currentTs @@ -977,7 +978,7 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = |] [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] -createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRange -> Maybe Int64 -> Int -> UTCTime -> IO Connection +createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRange -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId peerChatVRange viaContact Nothing Nothing getViaGroupMember :: DB.Connection -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index b763f9a54..cbcc4ddd2 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -78,6 +78,7 @@ import Simplex.Chat.Migrations.M20230721_group_snd_item_statuses import Simplex.Chat.Migrations.M20230814_indexes import Simplex.Chat.Migrations.M20230827_file_encryption import Simplex.Chat.Migrations.M20230829_connections_chat_vrange +import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -155,7 +156,8 @@ schemaMigrations = ("20230721_group_snd_item_statuses", m20230721_group_snd_item_statuses, Just down_m20230721_group_snd_item_statuses), ("20230814_indexes", m20230814_indexes, Just down_m20230814_indexes), ("20230827_file_encryption", m20230827_file_encryption, Just down_m20230827_file_encryption), - ("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange) + ("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) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 0c2f1f636..7f3c9841c 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -80,7 +80,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.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..)) +import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (safeDecodeUtf8) @@ -293,8 +293,8 @@ getUserContactProfiles db User {userId} = toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) -> (Profile) toContactProfile (displayName, fullName, image, contactLink, preferences) = Profile {displayName, fullName, image, contactLink, preferences} -createUserContactLink :: DB.Connection -> User -> ConnId -> ConnReqContact -> ExceptT StoreError IO () -createUserContactLink db User {userId} agentConnId cReq = +createUserContactLink :: DB.Connection -> User -> ConnId -> ConnReqContact -> SubscriptionMode -> ExceptT StoreError IO () +createUserContactLink db User {userId} agentConnId cReq subMode = checkConstraint SEDuplicateContactLink . liftIO $ do currentTs <- getCurrentTime DB.execute @@ -302,7 +302,7 @@ createUserContactLink db User {userId} agentConnId cReq = "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode getUserAddressConnections :: DB.Connection -> User -> ExceptT StoreError IO [Connection] getUserAddressConnections db User {userId} = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 7ec307ab4..1e9f2888a 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -36,6 +36,7 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (allFinally) import Simplex.Messaging.Version import UnliftIO.STM @@ -158,8 +159,8 @@ toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, v Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) toMaybeConnection _ = Nothing -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> IO Connection -createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs = do +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> IO Connection +createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode = do viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId -> maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId) let viaGroupLink = isJust viaLinkGroupId @@ -169,12 +170,12 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + peer_chat_min_version, peer_chat_max_version, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, ConnNew, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) - :. (minV, maxV) + :. (minV, maxV, subMode == SMOnlyCreate) ) connId <- insertedRowId db pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} diff --git a/stack.yaml b/stack.yaml index 9c6b35432..18d5afe8b 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: 351f42650c57f310fc1ea858ff9b7178823f1fd4 + commit: 0cabe0690beee90f460ad7bada72294222e7e109 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 4bb87b1e9..3db405222 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -57,6 +57,8 @@ chatDirectTests = do it "start/stop/export/import chat" testMaintenanceMode it "export/import chat with files" testMaintenanceModeWithFiles it "encrypt/decrypt database" testDatabaseEncryption + describe "coordination between app and NSE" $ do + it "should not subscribe in NSE and subscribe in the app" testSubscribeAppNSE describe "mute/unmute messages" $ do it "mute/unmute contact" testMuteContact it "mute/unmute group" testMuteGroup @@ -970,6 +972,35 @@ testDatabaseEncryption tmp = do withTestChat tmp "alice" $ \alice -> do testChatWorking alice bob +testSubscribeAppNSE :: HasCallStack => FilePath -> IO () +testSubscribeAppNSE tmp = + withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withTestChatOpts tmp testOpts {maintenance = True} "alice" $ \nseAlice -> do + alice ##> "/_app suspend 1" + alice <## "ok" + alice <## "chat suspended" + nseAlice ##> "/_start subscribe=off expire=off xftp=off" + nseAlice <## "chat started" + nseAlice ##> "/ad" + cLink <- getContactLink nseAlice True + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + (nseAlice </) + alice ##> "/_app activate" + alice <## "ok" + alice <## "Your address is active! To show: /sa" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + threadDelay 100000 + alice <##> bob + testMuteContact :: HasCallStack => FilePath -> IO () testMuteContact = testChat2 aliceProfile bobProfile $