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 01/15] 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.
+
+
+
+#### 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 @@
+
\ 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 10/15] 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 11/15] 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.
+