diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 986287fca0..a5a07a8722 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -270,7 +270,7 @@ jobs: - name: Unix test if: matrix.os != 'windows-latest' - timeout-minutes: 30 + timeout-minutes: 40 shell: bash run: cabal test --test-show-details=direct diff --git a/Dockerfile b/Dockerfile index 834f2374a6..6c60195f97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,41 @@ -FROM ubuntu:focal AS build +ARG TAG=22.04 -# Install curl and simplex-chat-related dependencies -RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev +FROM ubuntu:${TAG} AS build + +### Build stage + +# Install curl and git and simplex-chat dependencies +RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev + +# Specify bootstrap Haskell versions +ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3 +ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0 # Install ghcup -RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \ - chmod +x /usr/bin/ghcup - -# Install ghc -RUN ghcup install ghc 9.6.3 -# Install cabal -RUN ghcup install cabal 3.10.1.0 -# Set both as default -RUN ghcup set ghc 9.6.3 && \ - ghcup set cabal 3.10.1.0 - -COPY . /project -WORKDIR /project +RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh # Adjust PATH ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH" +# Set both as default +RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \ + ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}" + +COPY . /project +WORKDIR /project + # Adjust build RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local # Compile simplex-chat RUN cabal update -RUN cabal install +RUN cabal build exe:simplex-chat +# Strip the binary from debug symbols to reduce size +RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \ + mv "$bin" ./ && \ + strip ./simplex-chat + +# Copy compiled app from build stage FROM scratch AS export-stage -COPY --from=build /root/.cabal/bin/simplex-chat / +COPY --from=build /project/simplex-chat / diff --git a/PRIVACY.md b/PRIVACY.md index dbd48940f6..3204fa1e53 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,134 +1,168 @@ -# SimpleX Chat Terms & Privacy Policy +# SimpleX Chat Privacy Policy and Conditions of Use -SimpleX Chat is the first communication platform that has no user profile IDs of any kind, not even random numbers. Not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we cannot observe your connections graph. +SimpleX Chat is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability. -If you believe that some of the clauses in this document are not aligned with our mission or principles, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). +SimpleX Chat communication protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX Chat apps allow their users to send messages and files via relay server infrastructure. Relay server owners and providers do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not provide any user accounts. + +Double ratchet algorithm has such important properties as [forward secrecy](./docs/GLOSSARY.md#forward-secrecy), sender [repudiation](./docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](./docs/GLOSSARY.md#post-compromise-security)). + +If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). ## Privacy Policy -SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and encryption to provide secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the servers via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack). +SimpleX Chat Ltd uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack), unlike most other communication platforms, services and networks. -SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol allowing to establish private connections without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users. +SimpleX Chat software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having any kind of addresses or other identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications. -SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). +SimpleX Chat software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server providers, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the preset servers that we operate, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers. -### Information you provide +While SimpleX Chat Ltd is not a communication service provider, and provide public preset relays "as is", as experimental, without any guarantees of availability or data retention, we are committed to maintain a high level of availability, reliability and security of these preset relays. We will be adding alternative preset infrastructure providers to the software in the future, and you will continue to be able to use any other providers or your own servers. + +We see users and data sovereignty, and device and provider portability as critically important properties for any communication system. + +SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). + +### Your information #### User profiles -We do not store user profiles. The profile you create in the app is local to your device. +Servers used by SimpleX Chat apps do not create, store or identify user profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app. -When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users. +When you create the local profile, no records are created on any of the relay servers, and infrastructure providers, whether SimpleX Chat Ltd or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users. + +You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption. #### Messages and Files -SimpleX Chat cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 256kb, 1mb or 8mb via all or some of the configured file servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band. +SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 8mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band. -Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline SimpleX Chat temporarily stores end-to-end encrypted messages on the messaging (SMP) servers that are preset in the app or chosen by the users. +Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages – you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too. -The messages are permanently removed from the preset servers as soon as they are delivered. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers). +You do not have control over which servers are used to send messages to your contacts - they are chosen by them. To send messages your client needs to connect to these servers, therefore the servers chosen by your contacts can observe your IP address. You can use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by your contacts. In the near future we will add the layer in the messaging protocol that will route sent message via the relays chosen by you as well. -The files are stored on file (XFTP) servers for the time configured in the file servers you use (48 hours for preset file servers). +The messages are permanently removed from the used relay servers as soon as they are delivered, as long as these servers used unmodified published code. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers). -If a messaging or file servers are restarted, the encrypted message or the record of the file can be stored in a backup file until it is overwritten by the next restart (usually within 1 week). +The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers). + +If a messaging servers are restarted, the encrypted message can be stored in a backup file until it is overwritten by the next restart (usually within 1 week for preset relay servers). + +As this software is fully open-source and provided under AGPLv3 license, all infrastructure providers and owners, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the provided servers. + +In addition to the AGPLv3 license terms, SimpleX Chat Ltd is committed to the software users that the preset relays that we provide via the apps will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications. #### Connections with other users -When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on chosen messaging servers, that can be the preset servers or the servers that you configured in the app, in case it allows such configuration. SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default. +When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers for increased privacy, in case you have more than one relay server configured in the app, which is the default. -At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. +SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and providers to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. + +#### Connection links privacy + +When you create a connection with another user, the app generates a link/QR code that can be shared with the user to establish the connection via any channel (email, any other messenger, or a video call). This link is safe to share via insecure channels, as long as you can identify the recipient and also trust that this channel did not replace this link (to mitigate the latter risk you can validate the security code via the app). + +While the connection "links" contain SimpleX Chat Ltd domain name `simplex.chat`, this site is never accessed by the app, and is only used for these purposes: +- to direct the new users to the app download instructions, +- to show connection QR code that can be scanned via the app, +- to "namespace" these links, +- to open links directly in the installed app when it is clicked outside of the app. + +You can always safely replace the initial part of the link `https://simplex.chat/` either with `simplex:/` (which is a URI scheme provisionally registered with IANA) or with any other domain name where you can self-host the app download instructions and show the connection QR code (but in case it is your domain, it will not open in the app). Also, while the page renders QR code, all the information needed to render it is only available to the browser, as the part of the "link" after `#` symbol is not sent to the website server. #### iOS Push Notifications When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue. -Notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers. +Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers. -It also does not allow to see message content or sizes, as the actual messages are not sent via the notification server, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot observe it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off). +You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off). #### Another information stored on the servers -Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services. +Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively. -#### SimpleX Directory Service +#### SimpleX Directory -[SimpleX directory service](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the group. You can connect to SimpleX Directory Service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). +[SimpleX Directory](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). -#### User Support. +#### User Support -If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible. +If you contact SimpleX Chat Ltd, any personal data you share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information. ### Information we may share -We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers. +SimpleX Chat Ltd operates preset relay servers using third parties. While we do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via our servers. Hosting providers can also store IP addresses and other transport information as part of their logs. -We use a third party for email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according to their privacy policies and terms of service. +We use a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, we recommend contacting us via SimpleX Chat or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat). -The cases when SimpleX Chat may need to share the data we temporarily store on the servers: +The cases when SimpleX Chat Ltd may share the data temporarily stored on the servers: -- To meet any applicable law, regulation, legal process or enforceable governmental request. -- To enforce applicable Terms, including investigation of potential violations. +- To meet any applicable law, or enforceable governmental request or court order. +- To enforce applicable terms, including investigation of potential violations. - To detect, prevent, or otherwise address fraud, security, or technical issues. -- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law. +- To protect against harm to the rights, property, or safety of software users, SimpleX Chat Ltd, or the public as required or permitted by law. -At the time of updating this document, we have never provided or have been requested the access to our servers or any information from our servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process. +At the time of updating this document, we have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process to limit any information shared with the third parties to the minimally required by law. ### Updates -We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy. +We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our software applications and preset relays infrastructure confirms your acceptance of our updated Privacy Policy. -Please also read our Terms of Service below. +Please also read our Conditions of Use of Software and Infrastructure below. If you have questions about our Privacy Policy please contact us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). -## Terms of Service +## Conditions of Use of Software and Infrastructure -You accept our Terms of Service ("Terms") by installing or using any of our apps or services ("Services"). +You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of our software or using any of our server infrastructure (collectively referred to as "Applications"), whether preset in the software or not. -**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country. +**Minimal age**. You must be at least 13 years old to use our Applications. The minimum age to use our Applications without parental approval may be higher in your country. -**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks. +**Infrastructure**. Our Infrastructure includes preset messaging and file relay servers, and iOS push notification servers provided by SimpleX Chat Ltd for public use. Our infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated. -**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data. +**Client applications**. Our client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on our code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any information with SimpleX Chat Ltd or any other third parties. If you ever discover any tracking or analytics code, please report it to us, so we can remove it. -**Operating our services**. For the purpose of operating our Services, you agree that your end-to-end encrypted messages are transferred via our servers in the United Kingdom, the United States and other countries where we have or use facilities and service providers or partners. +**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks. -**Software**. You agree to downloading and installing updates to our Services when they are available; they would only be automatic if you configure your devices in this way. +**Privacy of user data**. Servers do not retain any data we transmit for any longer than necessary to deliver the messages between apps. SimpleX Chat Ltd collects aggregate statistics across all its servers, as supported by published code and can be enabled by any infrastructure provider, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. We do not have information about how many people use SimpleX Chat applications, we only know an approximate number of app installations and the aggregate traffic through the preset servers. In any case, we do not and will not sell or in any way monetize user data. Our future business model assumes charging for some optional Software features instead, in a transparent and fair way. -**Traffic and device costs**. You are solely responsible for the traffic and device costs on which you use our Services, and any associated taxes. +**Operating our Infrastructure**. For the purpose of using our Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where we have or use facilities and service providers or partners. The information about geographic location of the servers will be made available in the apps in the near future. -**Legal and acceptable usage**. You agree to use our Services only for legal and acceptable purposes. You will not use (or assist others in using) our Services in ways that: 1) violate or infringe the rights of SimpleX Chat, our users, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam. +**Software**. You agree to downloading and installing updates to our Applications when they are available; they would only be automatic if you configure your devices in this way. -**Damage to SimpleX Chat**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Services in unauthorized manners, or in ways that harm SimpleX Chat, our Services, or systems. For example, you must not 1) access our Services or systems without authorization, other than by using the apps; 2) disrupt the integrity or performance of our Services; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Services. +**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using our Applications, and any associated taxes. -**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up. +**Legal and acceptable usage**. You agree to use our Applications only for legal and acceptable purposes. You will not use (or assist others in using) our Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal or impermissible communications available via our Applications can be shared publicly on social media or websites. We reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via our servers, whether they were reported by the users or discovered by our team. -**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the application you use. Legacy databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app. In this case, if you make a backup of the app data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the beta version of desktop app currently stores the database passphrase in the configuration file in plaintext, so you may need to remove passphrase from the device via the app configuration. +**Damage to SimpleX Chat Ltd**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, our Infrastructure, or any other systems. For example, you must not 1) access our Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of our Infrastructure; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software. -**Storing the files on the device**. The files are stored on your device unencrypted. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access the files. +**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. -**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service. +**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings. -**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services. +**Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted. -**Your Rights**. You own the messages and the information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the app. +**No Access to Emergency Services**. Our Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service. -**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE) +**Third-party services**. Our Applications may allow you to access, use, or interact with our or third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services. -**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat. +**Your Rights**. You own the messages and the information you transmit through our Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design. -**Disclaimers**. YOU USE OUR SERVICES AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR SERVICES ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR SERVICES WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR SERVICES WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR SERVICES. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES. +**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use our Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE). -**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR TERMS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW. +**SimpleX Chat Ltd Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Applications. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat. -**Availability**. Our Services may be interrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Services, including certain features and the support for certain devices and platforms, at any time. +**Disclaimers**. YOU USE OUR APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR APPLICATIONS. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES. -**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Terms, us, or our Services in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Terms, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat and you, without regard to conflict of law provisions. +**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW. -**Changes to the terms**. SimpleX Chat may update the Terms from time to time. Your continued use of our Services confirms your acceptance of our updated Terms and supersedes any prior Terms. You will comply with all applicable export control and trade sanctions laws. Our Terms cover the entire agreement between you and SimpleX Chat regarding our Services. If you do not agree with our Terms, you should stop using our Services. +**Availability**. Our Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Applications, including certain features and the support for certain devices and platforms, at any time. -**Enforcing the terms**. If we fail to enforce any of our Terms, that does not mean we waive the right to enforce them. If any provision of the Terms is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Terms and shall not affect the enforceability of the remaining provisions. Our Services are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Services in any country. If you have specific questions about these Terms, please contact us at chat@simplex.chat. +**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Conditions, us, or our Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd and you, without regard to conflict of law provisions. -**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat. +**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. Your continued use of our Applications confirms your acceptance of our updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. Our Conditions cover the entire agreement between you and SimpleX Chat Ltd regarding our Applications. If you do not agree with our Conditions, you should stop using our Applications. -Updated August 17, 2023 +**Enforcing the conditions**. If we fail to enforce any of our Conditions, that does not mean we waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Conditions and shall not affect the enforceability of the remaining provisions. Our Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Applications in any country. If you have specific questions about these Conditions, please contact us at chat@simplex.chat. + +**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd. + +Updated February 24, 2024 diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 24c0eeb605..7204625ad4 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -16,6 +16,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { application.registerForRemoteNotifications() if #available(iOS 17.0, *) { trackKeyboard() } NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil) + removePasscodesIfReinstalled() return true } @@ -127,6 +128,19 @@ class AppDelegate: NSObject, UIApplicationDelegate { BGManager.shared.receiveMessages(complete) } + private func removePasscodesIfReinstalled() { + // Check for the database existence, because app and self destruct passcodes + // will be saved and restored by iOS when a user deletes and re-installs the app. + // In this case the database and settings will be deleted, but the passcodes won't be. + // Deleting passcodes ensures that the user will not get stuck on "Opening app..." screen. + if (kcAppPassword.get() != nil || kcSelfDestructPassword.get() != nil) && + !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) && !hasDatabase() { + _ = kcAppPassword.remove() + _ = kcSelfDestructPassword.remove() + _ = kcDatabasePassword.remove() + } + } + static func keepScreenOn(_ on: Bool) { UIApplication.shared.isIdleTimerDisabled = on } diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 45e0332dab..acea38e69e 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -34,6 +34,8 @@ struct ContentView: View { @State private var waitingForOrPassedAuth = true @State private var chatListActionSheet: ChatListActionSheet? = nil + private let callTopPadding: CGFloat = 50 + private enum ChatListActionSheet: Identifiable { case planAndConnectSheet(sheet: PlanAndConnectActionSheet) @@ -50,16 +52,28 @@ struct ContentView: View { var body: some View { ZStack { + let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings. // i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() } if !prefPerformLA || accessAuthenticated { contentView() + .padding(.top, showCallArea ? callTopPadding : 0) } else { lockButton() + .padding(.top, showCallArea ? callTopPadding : 0) } + + if showCallArea, let call = chatModel.activeCall { + VStack { + activeCallInteractiveArea(call) + Spacer() + } + } + if chatModel.showCallView, let call = chatModel.activeCall { callView(call) } + if !showSettings, let la = chatModel.laRequest { LocalAuthView(authRequest: la) .onDisappear { @@ -135,11 +149,11 @@ struct ContentView: View { if case .onboardingComplete = step, chatModel.currentUser != nil { mainView() - .actionSheet(item: $chatListActionSheet) { sheet in - switch sheet { - case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false) + .actionSheet(item: $chatListActionSheet) { sheet in + switch sheet { + case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false) + } } - } } else { OnboardingView(onboarding: step) } @@ -163,6 +177,40 @@ struct ContentView: View { } } + @ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View { + HStack { + Text(call.contact.displayName).font(.body).foregroundColor(.white) + Spacer() + CallDuration(call: call) + } + .padding(.horizontal) + .frame(height: callTopPadding - 10) + .background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1))) + .onTapGesture { + chatModel.activeCallViewIsCollapsed = false + } + } + + struct CallDuration: View { + let call: Call + @State var text: String = "" + @State var timer: Timer? = nil + + var body: some View { + Text(text).frame(minWidth: text.count <= 5 ? 52 : 77, alignment: .leading).offset(x: 4).font(.body).foregroundColor(.white) + .onAppear { + timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in + if let connectedAt = call.connectedAt { + text = durationText(Int(Date.now.timeIntervalSince1970 - connectedAt.timeIntervalSince1970)) + } + } + } + .onDisappear { + _ = timer?.invalidate() + } + } + } + private func lockButton() -> some View { Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c31ad579ab..c54e11eb78 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -80,6 +80,7 @@ final class ChatModel: ObservableObject { @Published var tokenRegistered = false @Published var tokenStatus: NtfTknStatus? @Published var notificationMode = NotificationsMode.off + @Published var notificationServer: String? @Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get() // pending notification actions @Published var ntfContactRequest: NTFContactRequest? @@ -89,6 +90,7 @@ final class ChatModel: ObservableObject { @Published var activeCall: Call? let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor() @Published var showCallView = false + @Published var activeCallViewIsCollapsed = false // remote desktop @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing invitation diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d1a16f73a8..57dab12a87 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -252,18 +252,24 @@ func apiSetFilesFolder(filesFolder: String) throws { throw r } -func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { - let r = chatSendCmdSync(.apiSetXFTPConfig(config: cfg)) +func apiSetEncryptLocalFiles(_ enable: Bool) throws { + let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) if case .cmdOk = r { return } throw r } -func apiSetEncryptLocalFiles(_ enable: Bool) throws { - let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) +func apiSetPQEncryption(_ enable: Bool) throws { + let r = chatSendCmdSync(.apiSetPQEncryption(enable: enable)) if case .cmdOk = r { return } throw r } +func apiSetContactPQ(_ contactId: Int64, _ enable: Bool) async throws -> Contact { + let r = await chatSendCmd(.apiSetContactPQ(contactId: contactId, enable: enable)) + if case let .contactPQAllowed(_, contact, _) = r { return contact } + throw r +} + func apiExportArchive(config: ArchiveConfig) async throws { try await sendCommandOkResp(.apiExportArchive(config: config)) } @@ -412,14 +418,14 @@ func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64 throw r } -func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) { +func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) { let r = chatSendCmdSync(.apiGetNtfToken) switch r { - case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode) - case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off) + case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer) + case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil) default: logger.debug("apiGetNtfToken response: \(String(describing: r))") - return (nil, nil, .off) + return (nil, nil, .off, nil) } } @@ -1249,8 +1255,8 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni } try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) - try setXFTPConfig(getXFTPCfg()) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) + try apiSetPQEncryption(pqExperimentalEnabledDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() if m.currentUser == nil { @@ -1309,7 +1315,7 @@ func startChat(refreshInvitations: Bool = true) throws { if (refreshInvitations) { try refreshCallInvitations() } - (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() + (m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken() // deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called, // when it is called before startChat if let token = m.deviceToken { @@ -1825,6 +1831,12 @@ func processReceivedMsg(_ res: ChatResponse) async { } } } + case let .contactPQEnabled(user, contact, _): + if active(user) { + await MainActor.run { + m.updateContact(contact) + } + } default: logger.debug("unsupported event: \(res.responseType)") } @@ -1861,7 +1873,9 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async { let cItem = aChatItem.chatItem if active(user) { if await MainActor.run(body: { m.upsertChatItem(cInfo, cItem) }) { - NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) + if cItem.showNotification { + NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) + } } } } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index a3be2e900a..9f246f63f3 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -12,49 +12,67 @@ import SimpleXChat struct ActiveCallView: View { @EnvironmentObject var m: ChatModel + @Environment(\.colorScheme) var colorScheme @ObservedObject var call: Call @Environment(\.scenePhase) var scenePhase @State private var client: WebRTCClient? = nil @State private var activeCall: WebRTCClient.Call? = nil @State private var localRendererAspectRatio: CGFloat? = nil @Binding var canConnectCall: Bool + @State var prevColorScheme: ColorScheme = .dark + @State var pipShown = false var body: some View { - ZStack(alignment: .bottom) { - if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil { - GeometryReader { g in - let width = g.size.width * 0.3 - ZStack(alignment: .topTrailing) { - CallViewRemote(client: client, activeCall: $activeCall) - CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio) - .cornerRadius(10) - .frame(width: width, height: width / (localRendererAspectRatio ?? 1)) - .padding([.top, .trailing], 17) + ZStack(alignment: .topLeading) { + ZStack(alignment: .bottom) { + if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil { + GeometryReader { g in + let width = g.size.width * 0.3 + ZStack(alignment: .topTrailing) { + CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown) + CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown) + .cornerRadius(10) + .frame(width: width, height: width / (localRendererAspectRatio ?? 1)) + .padding([.top, .trailing], 17) + ZStack(alignment: .center) { + // For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.primary.opacity(0.000001)) + } } } - } - if let call = m.activeCall, let client = client { - ActiveCallOverlay(call: call, client: client) + if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) { + ActiveCallOverlay(call: call, client: client) + } } } + .allowsHitTesting(!m.activeCallViewIsCollapsed) + .opacity(m.activeCallViewIsCollapsed ? 0 : 1) .onAppear { logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)") AppDelegate.keepScreenOn(true) createWebRTCClient() dismissAllSheets() + hideKeyboard() + prevColorScheme = colorScheme } .onChange(of: canConnectCall) { _ in logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)") createWebRTCClient() } + .onChange(of: m.activeCallViewIsCollapsed) { _ in + hideKeyboard() + } .onDisappear { logger.debug("ActiveCallView: disappear") Task { await m.callCommand.setClient(nil) } AppDelegate.keepScreenOn(false) client?.endCall() } - .background(.black) - .preferredColorScheme(.dark) + .background(m.activeCallViewIsCollapsed ? .clear : .black) + // Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it + .preferredColorScheme(m.activeCallViewIsCollapsed || CallController.useCallKit() ? prevColorScheme : .dark) } private func createWebRTCClient() { @@ -69,8 +87,8 @@ struct ActiveCallView: View { @MainActor private func processRtcMessage(msg: WVAPIMessage) { if call == m.activeCall, - let call = m.activeCall, - let client = client { + let call = m.activeCall, + let client = client { logger.debug("ActiveCallView: response \(msg.resp.respType)") switch msg.resp { case let .capabilities(capabilities): @@ -90,7 +108,7 @@ struct ActiveCallView: View { Task { do { try await apiSendCallOffer(call.contact, offer, iceCandidates, - media: call.localMedia, capabilities: capabilities) + media: call.localMedia, capabilities: capabilities) } catch { logger.error("apiSendCallOffer \(responseError(error))") } @@ -122,13 +140,15 @@ struct ActiveCallView: View { if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), case .connected = callStatus { call.direction == .outgoing - ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) - : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) + ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) + : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) call.callState = .connected + call.connectedAt = .now } if state.connectionState == "closed" { closeCallView(client) m.activeCall = nil + m.activeCallViewIsCollapsed = false } Task { do { @@ -140,6 +160,7 @@ struct ActiveCallView: View { case let .connected(connectionInfo): call.callState = .connected call.connectionInfo = connectionInfo + call.connectedAt = .now case .ended: closeCallView(client) call.callState = .ended @@ -153,6 +174,7 @@ struct ActiveCallView: View { case .end: closeCallView(client) m.activeCall = nil + m.activeCallViewIsCollapsed = false default: () } case let .error(message): @@ -181,7 +203,7 @@ struct ActiveCallOverlay: View { VStack { switch call.localMedia { case .video: - callInfoView(call, .leading) + videoCallInfoView(call) .foregroundColor(.white) .opacity(0.8) .padding() @@ -208,16 +230,25 @@ struct ActiveCallOverlay: View { .frame(maxWidth: .infinity, alignment: .center) case .audio: - VStack { - ProfileImage(imageStr: call.contact.profile.image) - .scaledToFit() - .frame(width: 192, height: 192) - callInfoView(call, .center) + ZStack(alignment: .topLeading) { + Button { + chatModel.activeCallViewIsCollapsed = true + } label: { + Label("Back", systemImage: "chevron.left") + .padding() + .foregroundColor(.white.opacity(0.8)) + } + VStack { + ProfileImage(imageStr: call.contact.profile.image) + .scaledToFit() + .frame(width: 192, height: 192) + audioCallInfoView(call) + } + .foregroundColor(.white) + .opacity(0.8) + .padding() + .frame(maxHeight: .infinity) } - .foregroundColor(.white) - .opacity(0.8) - .padding() - .frame(maxHeight: .infinity) Spacer() @@ -235,12 +266,12 @@ struct ActiveCallOverlay: View { .frame(maxWidth: .infinity) } - private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View { + private func audioCallInfoView(_ call: Call) -> some View { VStack { Text(call.contact.chatViewName) .lineLimit(1) .font(.title) - .frame(maxWidth: .infinity, alignment: alignment) + .frame(maxWidth: .infinity, alignment: .center) Group { Text(call.callState.text) HStack { @@ -251,7 +282,36 @@ struct ActiveCallOverlay: View { } } .font(.subheadline) - .frame(maxWidth: .infinity, alignment: alignment) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + private func videoCallInfoView(_ call: Call) -> some View { + VStack { + Button { + chatModel.activeCallViewIsCollapsed = true + } label: { + HStack(alignment: .center, spacing: 16) { + Image(systemName: "chevron.left") + .resizable() + .frame(width: 10, height: 18) + Text(call.contact.chatViewName) + .lineLimit(1) + .font(.title) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + Group { + Text(call.callState.text) + HStack { + Text(call.encryptionStatus) + if let connInfo = call.connectionInfo { + Text("(") + Text(connInfo.text) + Text(")") + } + } + } + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift index 194af3ab01..a6d5ea17c4 100644 --- a/apps/ios/Shared/Views/Call/CallManager.swift +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -92,6 +92,7 @@ class CallManager { if case .ended = call.callState { logger.debug("CallManager.endCall: call ended") m.activeCall = nil + m.activeCallViewIsCollapsed = false m.showCallView = false completed() } else { @@ -100,6 +101,7 @@ class CallManager { await m.callCommand.processCommand(.end) await MainActor.run { m.activeCall = nil + m.activeCallViewIsCollapsed = false m.showCallView = false completed() } diff --git a/apps/ios/Shared/Views/Call/CallViewRenderers.swift b/apps/ios/Shared/Views/Call/CallViewRenderers.swift index 93766ced1c..a3201d9351 100644 --- a/apps/ios/Shared/Views/Call/CallViewRenderers.swift +++ b/apps/ios/Shared/Views/Call/CallViewRenderers.swift @@ -6,14 +6,20 @@ import SwiftUI import WebRTC import SimpleXChat +import AVKit struct CallViewRemote: UIViewRepresentable { var client: WebRTCClient var activeCall: Binding + @State var enablePip: (Bool) -> Void = {_ in } + @Binding var activeCallViewIsCollapsed: Bool + @Binding var pipShown: Bool - init(client: WebRTCClient, activeCall: Binding) { + init(client: WebRTCClient, activeCall: Binding, activeCallViewIsCollapsed: Binding, pipShown: Binding) { self.client = client self.activeCall = activeCall + self._activeCallViewIsCollapsed = activeCallViewIsCollapsed + self._pipShown = pipShown } func makeUIView(context: Context) -> UIView { @@ -23,12 +29,120 @@ struct CallViewRemote: UIViewRepresentable { remoteRenderer.videoContentMode = .scaleAspectFill client.addRemoteRenderer(call, remoteRenderer) addSubviewAndResize(remoteRenderer, into: view) + + if AVPictureInPictureController.isPictureInPictureSupported() { + makeViewWithRTCRenderer(call, remoteRenderer, view, context) + } } return view } + + func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) { + let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame) + pipRemoteRenderer.videoContentMode = .scaleAspectFill + + let pipVideoCallViewController = AVPictureInPictureVideoCallViewController() + pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920) + addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view) + let pipContentSource = AVPictureInPictureController.ContentSource( + activeVideoCallSourceView: view, + contentViewController: pipVideoCallViewController + ) + + let pipController = AVPictureInPictureController(contentSource: pipContentSource) + pipController.canStartPictureInPictureAutomaticallyFromInline = true + pipController.delegate = context.coordinator + context.coordinator.pipController = pipController + context.coordinator.willShowHide = { show in + if show { + client.addRemoteRenderer(call, pipRemoteRenderer) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + activeCallViewIsCollapsed = true + } + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + activeCallViewIsCollapsed = false + } + } + } + context.coordinator.didShowHide = { show in + if show { + remoteRenderer.isHidden = true + } else { + client.removeRemoteRenderer(call, pipRemoteRenderer) + remoteRenderer.isHidden = false + } + pipShown = show + } + DispatchQueue.main.async { + enablePip = { enable in + if enable != pipShown /* pipController.isPictureInPictureActive */ { + if enable { + pipController.startPictureInPicture() + } else { + pipController.stopPictureInPicture() + } + } + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } func updateUIView(_ view: UIView, context: Context) { logger.debug("CallView.updateUIView remote") + DispatchQueue.main.async { + if activeCallViewIsCollapsed != pipShown { + enablePip(activeCallViewIsCollapsed) + } + } + } + + // MARK: - Coordinator + class Coordinator: NSObject, AVPictureInPictureControllerDelegate { + var pipController: AVPictureInPictureController? = nil + var willShowHide: (Bool) -> Void = { _ in } + var didShowHide: (Bool) -> Void = { _ in } + + func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + willShowHide(true) + } + + func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + didShowHide(true) + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { + logger.error("PiP failed to start: \(error.localizedDescription)") + } + + func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + willShowHide(false) + } + + func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + didShowHide(false) + } + + deinit { + pipController?.stopPictureInPicture() + pipController?.canStartPictureInPictureAutomaticallyFromInline = false + pipController?.contentSource = nil + pipController?.delegate = nil + pipController = nil + } + } + + class SampleBufferVideoCallView: UIView { + override class var layerClass: AnyClass { + get { return AVSampleBufferDisplayLayer.self } + } + + var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer { + return layer as! AVSampleBufferDisplayLayer + } } } @@ -36,11 +150,14 @@ struct CallViewLocal: UIViewRepresentable { var client: WebRTCClient var activeCall: Binding var localRendererAspectRatio: Binding + @State var pipStateChanged: (Bool) -> Void = {_ in } + @Binding var pipShown: Bool - init(client: WebRTCClient, activeCall: Binding, localRendererAspectRatio: Binding) { + init(client: WebRTCClient, activeCall: Binding, localRendererAspectRatio: Binding, pipShown: Binding) { self.client = client self.activeCall = activeCall self.localRendererAspectRatio = localRendererAspectRatio + self._pipShown = pipShown } func makeUIView(context: Context) -> UIView { @@ -50,12 +167,18 @@ struct CallViewLocal: UIViewRepresentable { client.addLocalRenderer(call, localRenderer) client.startCaptureLocalVideo(call) addSubviewAndResize(localRenderer, into: view) + DispatchQueue.main.async { + pipStateChanged = { shown in + localRenderer.isHidden = shown + } + } } return view } func updateUIView(_ view: UIView, context: Context) { logger.debug("CallView.updateUIView local") + pipStateChanged(pipShown) } } diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index c21ef5019a..919b1e14e7 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -28,6 +28,7 @@ class Call: ObservableObject, Equatable { @Published var speakerEnabled = false @Published var videoEnabled: Bool @Published var connectionInfo: ConnectionInfo? + @Published var connectedAt: Date? = nil init( direction: CallDirection, @@ -59,6 +60,7 @@ class Call: ObservableObject, Equatable { } } var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } } + var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } } } enum CallDirection { diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 933a3c745e..1806984d64 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -331,6 +331,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg activeCall.remoteStream?.add(renderer) } + func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) { + activeCall.remoteStream?.remove(renderer) + } + func startCaptureLocalVideo(_ activeCall: Call) { #if targetEnvironment(simulator) guard @@ -410,6 +414,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg guard let call = activeCall.wrappedValue else { return } logger.debug("WebRTCClient: ending the call") activeCall.wrappedValue = nil + (call.localCamera as? RTCCameraVideoCapturer)?.stopCapture() call.connection.close() call.connection.delegate = nil call.frameEncryptor?.delegate = nil diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index b702c2cc23..86532605db 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -103,6 +103,7 @@ struct ChatInfoView: View { @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @AppStorage(GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED, store: groupDefaults) private var pqExperimentalEnabled = false enum ChatInfoViewAlert: Identifiable { case clearChatAlert @@ -110,6 +111,7 @@ struct ChatInfoView: View { case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert + case allowContactPQEncryptionAlert case error(title: LocalizedStringKey, error: LocalizedStringKey = "") var id: String { @@ -119,6 +121,7 @@ struct ChatInfoView: View { case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" + case .allowContactPQEncryptionAlert: return "allowContactPQEncryptionAlert" case let .error(title, _): return "error \(title)" } } @@ -165,6 +168,22 @@ struct ChatInfoView: View { } .disabled(!contact.ready || !contact.active) + if pqExperimentalEnabled, + let conn = contact.activeConn { + Section { + infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") + if !conn.pqEncryption { + allowPQButton() + } + } header: { + Text(String("Quantum resistant E2E encryption")) + } footer: { + if !conn.pqEncryption { + Text(String("After allowing quantum resistant encryption, it will be enabled after several messages if your contact also allows it.")) + } + } + } + if let contactLink = contact.contactLink { Section { SimpleXLinkQRCode(uri: contactLink) @@ -237,6 +256,7 @@ struct ChatInfoView: View { case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) }) + case .allowContactPQEncryptionAlert: return allowContactPQEncryptionAlert() case let .error(title, error): return mkAlert(title: title, message: error) } } @@ -410,6 +430,15 @@ struct ChatInfoView: View { } } + private func allowPQButton() -> some View { + Button { + alert = .allowContactPQEncryptionAlert + } label: { + Label(String("Allow PQ encryption"), systemImage: "exclamationmark.triangle") + .foregroundColor(.orange) + } + } + private func networkStatusRow() -> some View { HStack { Text("Network status") @@ -543,6 +572,34 @@ struct ChatInfoView: View { } } } + + private func allowContactPQEncryption() { + Task { + do { + let ct = try await apiSetContactPQ(contact.apiId, true) + contact = ct + await MainActor.run { + chatModel.updateContact(contact) + dismiss() + } + } catch let error { + logger.error("allowContactPQEncryption apiSetContactPQ error: \(responseError(error))") + let a = getErrorAlert(error, "Error allowing contact PQ encryption") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } + } + + func allowContactPQEncryptionAlert() -> Alert { + Alert( + title: Text(String("Allow quantum resistant encryption?")), + message: Text(String("This is an experimental feature, it is not recommended to enable it for important chats.")), + primaryButton: .destructive(Text(String("Allow")), action: allowContactPQEncryption), + secondaryButton: .cancel() + ) + } } func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index c7e89fc5ed..c3e4805bf3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -29,6 +29,9 @@ struct CIImageView: View { FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy) } .onTapGesture { showFullScreenImage = true } + .onChange(of: m.activeCallViewIsCollapsed) { _ in + showFullScreenImage = false + } } else if let data = Data(base64Encoded: dropImagePrefix(image)), let uiImage = UIImage(data: data) { imageView(uiImage) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index a824ddc49f..ff208fe58a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -120,6 +120,9 @@ struct CIVideoView: View { showFullScreenPlayer = urlDecrypted != nil } } + .onChange(of: m.activeCallViewIsCollapsed) { _ in + showFullScreenPlayer = false + } if !decryptionInProgress { Button { decrypt(file: file) { @@ -168,6 +171,9 @@ struct CIVideoView: View { default: () } } + .onChange(of: m.activeCallViewIsCollapsed) { _ in + showFullScreenPlayer = false + } if !videoPlaying { Button { m.stopPreviousRecPlay = url diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 8f67a8f737..d9404547e2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -111,6 +111,11 @@ struct ChatItemContentView: View { case .rcvModerated: deletedItemView() case .rcvBlocked: deletedItemView() case let .invalidJSON(json): CIInvalidJSONView(json: json) + // TODO proper items + case .sndDirectE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) + case .rcvDirectE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) + case .sndGroupE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) + case .rcvGroupE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 35caf655e9..550a9a45b8 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -161,11 +161,15 @@ struct ChatView: View { HStack { let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser if callsPrefEnabled { - callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready || !contact.active) + if chatModel.activeCall == nil { + callButton(contact, .audio, imageName: "phone") + .disabled(!contact.ready || !contact.active) + } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { + endCallButton(call) + } } Menu { - if callsPrefEnabled { + if callsPrefEnabled && chatModel.activeCall == nil { Button { CallController.shared.startCall(contact, .video) } label: { @@ -422,7 +426,19 @@ struct ChatView: View { Image(systemName: imageName) } } - + + private func endCallButton(_ call: Call) -> some View { + Button { + if let uuid = call.callkitUUID { + CallController.shared.endCall(callUUID: uuid) + } else { + CallController.shared.endCall(call: call) {} + } + } label: { + Image(systemName: "phone.down.fill").tint(.red) + } + } + private func searchButton() -> some View { Button { searchMode = true diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index dbea6a17e0..88b36077b4 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -234,39 +234,29 @@ struct GroupChatInfoView: View { Spacer() memberInfo(member) } - - // revert from this: + if user { v - } else if member.canBeRemoved(groupInfo: groupInfo) { - removeSwipe(member, blockSwipe(member, v)) + } else if groupInfo.membership.memberRole >= .admin { + // TODO if there are more actions, refactor with lists of swipeActions + let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo) + let canRemove = member.canBeRemoved(groupInfo: groupInfo) + if canBlockForAll && canRemove { + removeSwipe(member, blockForAllSwipe(member, v)) + } else if canBlockForAll { + blockForAllSwipe(member, v) + } else if canRemove { + removeSwipe(member, v) + } else { + v + } } else { - blockSwipe(member, v) + if !member.blockedByAdmin { + blockSwipe(member, v) + } else { + v + } } - // revert to this: vvv -// if user { -// v -// } else if groupInfo.membership.memberRole >= .admin { -// // TODO if there are more actions, refactor with lists of swipeActions -// let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo) -// let canRemove = member.canBeRemoved(groupInfo: groupInfo) -// if canBlockForAll && canRemove { -// removeSwipe(member, blockForAllSwipe(member, v)) -// } else if canBlockForAll { -// blockForAllSwipe(member, v) -// } else if canRemove { -// removeSwipe(member, v) -// } else { -// v -// } -// } else { -// if !member.blockedByAdmin { -// blockSwipe(member, v) -// } else { -// v -// } -// } - // ^^^ } @ViewBuilder private func memberInfo(_ member: GroupMember) -> some View { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index d2b0f77393..999617dde7 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -168,24 +168,11 @@ struct GroupMemberInfoView: View { } } - // revert from this: - Section { - if member.memberSettings.showMessages { - blockMemberButton(member) - } else { - unblockMemberButton(member) - } - if member.canBeRemoved(groupInfo: groupInfo) { - removeMemberButton(member) - } + if groupInfo.membership.memberRole >= .admin { + adminDestructiveSection(member) + } else { + nonAdminBlockSection(member) } - // revert to this: vvv -// if groupInfo.membership.memberRole >= .admin { -// adminDestructiveSection(member) -// } else { -// nonAdminBlockSection(member) -// } - // ^^^ if developerTools { Section("For console") { diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index e99c6e3301..9b11c6d0f7 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct DeveloperView: View { @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false + @AppStorage(GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED, store: groupDefaults) private var pqExperimentalEnabled = false @Environment(\.colorScheme) var colorScheme var body: some View { @@ -43,27 +44,32 @@ struct DeveloperView: View { (developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.") } -// Section { -// settingsRow("arrow.up.doc") { -// Toggle("Send videos and files via XFTP", isOn: $xftpSendEnabled) -// .onChange(of: xftpSendEnabled) { _ in -// do { -// try setXFTPConfig(getXFTPCfg()) -// } catch { -// logger.error("setXFTPConfig: cannot set XFTP config \(responseError(error))") -// } -// } -// } -// } header: { -// Text("Experimental") -// } footer: { -// if xftpSendEnabled { -// Text("v4.6.1+ is required to receive via XFTP.") -// } -// } + if developerTools { + Section { + settingsRow("key") { + Toggle("Post-quantum E2EE", isOn: $pqExperimentalEnabled) + .onChange(of: pqExperimentalEnabled) { + setPQExperimentalEnabled($0) + } + } + } header: { + Text(String("Experimental")) + } footer: { + Text(String("In this version applies only to new contacts.")) + } + } } } } + + private func setPQExperimentalEnabled(_ enable: Bool) { + do { + try apiSetPQEncryption(enable) + } catch let error { + let err = responseError(error) + logger.error("apiSetPQEncryption \(err)") + } + } } struct DeveloperView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift index 04c02f0dd2..4876d60eca 100644 --- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift +++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift @@ -76,6 +76,10 @@ struct NotificationsView: View { Text(m.notificationPreview.label) } } + + if let server = m.notificationServer { + smpServers("Push server", [server]) + } } header: { Text("Push notifications") } footer: { @@ -87,6 +91,9 @@ struct NotificationsView: View { } } .disabled(legacyDatabase) + .onAppear { + (m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken() + } } private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert { @@ -125,6 +132,7 @@ struct NotificationsView: View { m.tokenStatus = .new notificationMode = .off m.notificationMode = .off + m.notificationServer = nil } } catch let error { await MainActor.run { @@ -135,11 +143,13 @@ struct NotificationsView: View { } default: do { - let status = try await apiRegisterToken(token: token, notificationMode: mode) + let _ = try await apiRegisterToken(token: token, notificationMode: mode) + let (_, tknStatus, ntfMode, ntfServer) = apiGetNtfToken() await MainActor.run { - m.tokenStatus = status - notificationMode = mode - m.notificationMode = mode + m.tokenStatus = tknStatus + notificationMode = ntfMode + m.notificationMode = ntfMode + m.notificationServer = ntfServer } } catch let error { await MainActor.run { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 61c439fb33..67536d7b78 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -453,7 +453,6 @@ var receiverStarted = false let startLock = DispatchSemaphore(value: 1) let suspendLock = DispatchSemaphore(value: 1) var networkConfig: NetCfg = getNetCfg() -let xftpConfig: XFTPFileConfig? = getXFTPCfg() // startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller // Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active @@ -499,7 +498,6 @@ func doStartChat() -> DBMigrationResult? { try setNetworkConfig(networkConfig) try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) - try setXFTPConfig(xftpConfig) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) // prevent suspension while starting chat suspendLock.wait() @@ -733,12 +731,6 @@ func apiSetFilesFolder(filesFolder: String) throws { throw r } -func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { - let r = sendSimpleXCmd(.apiSetXFTPConfig(config: cfg)) - if case .cmdOk = r { return } - throw r -} - func apiSetEncryptLocalFiles(_ enable: Bool) throws { let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable)) if case .cmdOk = r { return } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d97c6d5576..c8bdd92b25 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -29,11 +29,6 @@ 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; - 5C29C3AF2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AA2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a */; }; - 5C29C3B02B783F82003DF84C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AB2B783F82003DF84C /* libgmpxx.a */; }; - 5C29C3B12B783F82003DF84C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AC2B783F82003DF84C /* libffi.a */; }; - 5C29C3B22B783F82003DF84C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AD2B783F82003DF84C /* libgmp.a */; }; - 5C29C3B32B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AE2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; @@ -66,6 +61,11 @@ 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; + 5C777BD82B99B38B00C72EFF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD32B99B38B00C72EFF /* libgmp.a */; }; + 5C777BD92B99B38B00C72EFF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD42B99B38B00C72EFF /* libgmpxx.a */; }; + 5C777BDA2B99B38B00C72EFF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD52B99B38B00C72EFF /* libffi.a */; }; + 5C777BDB2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */; }; + 5C777BDC2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; }; @@ -283,11 +283,6 @@ 5C245F3C2B501E98001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; 5C245F3D2B501F13001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = "tr.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C245F3E2B501F13001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; - 5C29C3AA2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a"; sourceTree = ""; }; - 5C29C3AB2B783F82003DF84C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C29C3AC2B783F82003DF84C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C29C3AD2B783F82003DF84C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C29C3AE2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a"; sourceTree = ""; }; 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; }; 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; 5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; @@ -335,6 +330,11 @@ 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; + 5C777BD32B99B38B00C72EFF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C777BD42B99B38B00C72EFF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C777BD52B99B38B00C72EFF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a"; sourceTree = ""; }; + 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a"; sourceTree = ""; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -524,13 +524,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C29C3B02B783F82003DF84C /* libgmpxx.a in Frameworks */, - 5C29C3B32B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a in Frameworks */, + 5C777BD92B99B38B00C72EFF /* libgmpxx.a in Frameworks */, + 5C777BDB2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 5C777BD82B99B38B00C72EFF /* libgmp.a in Frameworks */, + 5C777BDA2B99B38B00C72EFF /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C29C3B12B783F82003DF84C /* libffi.a in Frameworks */, - 5C29C3B22B783F82003DF84C /* libgmp.a in Frameworks */, - 5C29C3AF2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a in Frameworks */, + 5C777BDC2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -592,11 +592,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C29C3AC2B783F82003DF84C /* libffi.a */, - 5C29C3AD2B783F82003DF84C /* libgmp.a */, - 5C29C3AB2B783F82003DF84C /* libgmpxx.a */, - 5C29C3AA2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a */, - 5C29C3AE2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a */, + 5C777BD52B99B38B00C72EFF /* libffi.a */, + 5C777BD32B99B38B00C72EFF /* libgmp.a */, + 5C777BD42B99B38B00C72EFF /* libgmpxx.a */, + 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */, + 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */, ); path = Libraries; sourceTree = ""; @@ -1519,7 +1519,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 198; + CURRENT_PROJECT_VERSION = 201; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1541,7 +1541,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.5.3; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1562,7 +1562,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 198; + CURRENT_PROJECT_VERSION = 201; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1584,7 +1584,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.5.3; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1643,7 +1643,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 198; + CURRENT_PROJECT_VERSION = 201; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1656,7 +1656,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.5.3; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1675,7 +1675,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 198; + CURRENT_PROJECT_VERSION = 201; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1688,7 +1688,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.5.3; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1707,7 +1707,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 198; + CURRENT_PROJECT_VERSION = 201; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1731,7 +1731,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.5.3; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1753,7 +1753,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 198; + CURRENT_PROJECT_VERSION = 201; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1777,7 +1777,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.5.3; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index ae091f8415..4df419ffef 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -31,8 +31,9 @@ public enum ChatCommand { case apiSuspendChat(timeoutMicroseconds: Int) case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) - case apiSetXFTPConfig(config: XFTPFileConfig?) case apiSetEncryptLocalFiles(enable: Bool) + case apiSetPQEncryption(enable: Bool) + case apiSetContactPQ(contactId: Int64, enable: Bool) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage @@ -162,12 +163,9 @@ public enum ChatCommand { case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" - case let .apiSetXFTPConfig(cfg): if let cfg = cfg { - return "/_xftp on \(encodeJSON(cfg))" - } else { - return "/_xftp off" - } case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" + case let .apiSetPQEncryption(enable): return "/pq \(onOff(enable))" + case let .apiSetContactPQ(contactId, enable): return "/_pq @\(contactId) \(onOff(enable))" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" @@ -311,8 +309,9 @@ public enum ChatCommand { case .apiSuspendChat: return "apiSuspendChat" case .setTempFolder: return "setTempFolder" case .setFilesFolder: return "setFilesFolder" - case .apiSetXFTPConfig: return "apiSetXFTPConfig" case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" + case .apiSetPQEncryption: return "apiSetPQEncryption" + case .apiSetContactPQ: return "apiSetContactPQ" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" @@ -613,7 +612,7 @@ public enum ChatResponse: Decodable, Error { case callEnded(user: UserRef, contact: Contact) case callInvitations(callInvitations: [RcvCallInvitation]) case ntfTokenStatus(status: NtfTknStatus) - case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) + case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String) case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) @@ -624,6 +623,9 @@ public enum ChatResponse: Decodable, Error { case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) + // pq + case contactPQAllowed(user: UserRef, contact: Contact, pqEncryption: Bool) + case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) // misc case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user: UserRef?) @@ -772,6 +774,8 @@ public enum ChatResponse: Decodable, Error { case .remoteCtrlSessionCode: return "remoteCtrlSessionCode" case .remoteCtrlConnected: return "remoteCtrlConnected" case .remoteCtrlStopped: return "remoteCtrlStopped" + case .contactPQAllowed: return "contactPQAllowed" + case .contactPQEnabled: return "contactPQAllowed" case .versionInfo: return "versionInfo" case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" @@ -912,7 +916,7 @@ public enum ChatResponse: Decodable, Error { case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") case let .callInvitations(invs): return String(describing: invs) case let .ntfTokenStatus(status): return String(describing: status) - case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" + case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)" case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) @@ -922,6 +926,8 @@ public enum ChatResponse: Decodable, Error { case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) case .remoteCtrlStopped: return noDetails + case let .contactPQAllowed(u, contact, pqEncryption): return withUser(u, "contact: \(String(describing: contact))\npqEncryption: \(pqEncryption)") + case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" case .cmdOk: return noDetails case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) @@ -1005,10 +1011,6 @@ struct ComposedMessage: Encodable { var msgContent: MsgContent } -public struct XFTPFileConfig: Encodable { - var minFileSize: Int64 -} - public struct ArchiveConfig: Encodable { var archivePath: String var disableCompression: Bool? diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index f79c294e0c..47e250b7e9 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -39,6 +39,7 @@ let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase" let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" public let GROUP_DEFAULT_CONFIRM_DB_UPGRADES = "confirmDBUpgrades" public let GROUP_DEFAULT_CALL_KIT_ENABLED = "callKitEnabled" +public let GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED = "pqExperimentalEnabled" public let APP_GROUP_NAME = "group.chat.simplex.app" @@ -67,6 +68,7 @@ public func registerGroupDefaults() { GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true, GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false, GROUP_DEFAULT_CALL_KIT_ENABLED: true, + GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false, ]) } @@ -193,6 +195,8 @@ public let confirmDBUpgradesGroupDefault = BoolDefault(defaults: groupDefaults, public let callKitEnabledGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CALL_KIT_ENABLED) +public let pqExperimentalEnabledDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES) + public class DateDefault { var defaults: UserDefaults var key: String @@ -265,10 +269,6 @@ public class Default { } } -public func getXFTPCfg() -> XFTPFileConfig { - return XFTPFileConfig(minFileSize: 0) -} - public func getNetCfg() -> NetCfg { let onionHosts = networkUseOnionHostsGroupDefault.get() let (hostMode, requiredHostMode) = onionHosts.hostMode diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 198a777f8b..3463bfca18 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1532,22 +1532,32 @@ public struct Connection: Decodable { public var viaGroupLink: Bool public var customUserProfileId: Int64? public var connectionCode: SecurityCode? + public var pqSupport: Bool + public var pqEncryption: Bool + public var pqSndEnabled: Bool? + public var pqRcvEnabled: Bool? public var connectionStats: ConnectionStats? = nil private enum CodingKeys: String, CodingKey { - case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode + case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled } public var id: ChatId { get { ":\(connId)" } } + public var connPQEnabled: Bool { + pqSndEnabled == true && pqRcvEnabled == true + } + static let sampleData = Connection( connId: 1, agentConnId: "abc", peerChatVRange: VersionRange(minVersion: 1, maxVersion: 1), connStatus: .ready, connLevel: 0, - viaGroupLink: false + viaGroupLink: false, + pqSupport: false, + pqEncryption: false ) } @@ -2268,7 +2278,7 @@ public struct ChatItem: Identifiable, Decodable { case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent): switch rcvDirectEvent { case .contactDeleted: return false - case .profileUpdated: return true + case .profileUpdated: return false } case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent): switch rcvGroupEvent { @@ -2300,6 +2310,10 @@ public struct ChatItem: Identifiable, Decodable { case .sndModerated: return false case .rcvModerated: return false case .rcvBlocked: return false + case .sndDirectE2EEInfo: return false + case .rcvDirectE2EEInfo: return false + case .sndGroupE2EEInfo: return false + case .rcvGroupE2EEInfo: return false case .invalidJSON: return false } } @@ -2735,6 +2749,10 @@ public enum CIContent: Decodable, ItemContent { case sndModerated case rcvModerated case rcvBlocked + case sndDirectE2EEInfo(e2eeInfo: E2EEInfo) + case rcvDirectE2EEInfo(e2eeInfo: E2EEInfo) + case sndGroupE2EEInfo(e2eeInfo: E2EEInfo) + case rcvGroupE2EEInfo(e2eeInfo: E2EEInfo) case invalidJSON(json: String) public var text: String { @@ -2766,11 +2784,25 @@ public enum CIContent: Decodable, ItemContent { case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") + case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoToText(e2eeInfo) + case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoToText(e2eeInfo) + case .sndGroupE2EEInfo: return e2eeInfoNoPQText + case .rcvGroupE2EEInfo: return e2eeInfoNoPQText case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") } } } + private func directE2EEInfoToText(_ e2eeInfo: E2EEInfo) -> String { + e2eeInfo.pqEnabled + ? NSLocalizedString("This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery.", comment: "E2EE info chat item") + : e2eeInfoNoPQText + } + + private var e2eeInfoNoPQText: String { + NSLocalizedString("This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.", comment: "E2EE info chat item") + } + static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String { feature.hasParam ? "\(feature.text): \(timeText(param))" @@ -3457,6 +3489,10 @@ public enum CIGroupInvitationStatus: String, Decodable { case expired } +public struct E2EEInfo: Decodable { + public var pqEnabled: Bool +} + public enum RcvDirectEvent: Decodable { case contactDeleted case profileUpdated(fromProfile: Profile, toProfile: Profile) @@ -3574,7 +3610,8 @@ public enum RcvConnEvent: Decodable { case switchQueue(phase: SwitchPhase) case ratchetSync(syncStatus: RatchetSyncState) case verificationCodeReset - + case pqEnabled(enabled: Bool) + var text: String { switch self { case let .switchQueue(phase): @@ -3586,6 +3623,12 @@ public enum RcvConnEvent: Decodable { return ratchetSyncStatusToText(syncStatus) case .verificationCodeReset: return NSLocalizedString("security code changed", comment: "chat item text") + case let .pqEnabled(enabled): + if enabled { + return NSLocalizedString("quantum resistant e2e encryption", comment: "chat item text") + } else { + return NSLocalizedString("standard end-to-end encryption", comment: "chat item text") + } } } } @@ -3603,6 +3646,7 @@ func ratchetSyncStatusToText(_ ratchetSyncStatus: RatchetSyncState) -> String { public enum SndConnEvent: Decodable { case switchQueue(phase: SwitchPhase, member: GroupMemberRef?) case ratchetSync(syncStatus: RatchetSyncState, member: GroupMemberRef?) + case pqEnabled(enabled: Bool) var text: String { switch self { @@ -3626,6 +3670,12 @@ public enum SndConnEvent: Decodable { } } return ratchetSyncStatusToText(syncStatus) + case let .pqEnabled(enabled): + if enabled { + return NSLocalizedString("quantum resistant e2e encryption", comment: "chat item text") + } else { + return NSLocalizedString("standard end-to-end encryption", comment: "chat item text") + } } } } 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 422d886d7c..b76b18fd21 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 @@ -73,7 +73,7 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { Log.d(TAG, "onStateChanged: $event") - withBGApi { + withLongRunningApi { when (event) { Lifecycle.Event.ON_START -> { isAppOnForeground = true diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index f0c5ea6941..dc8ead80ee 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -14,17 +14,27 @@ import chat.simplex.common.views.helpers.* import java.io.BufferedOutputStream import java.io.File import chat.simplex.res.MR +import kotlin.math.min actual fun ClipboardManager.shareText(text: String) { - val sendIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, text) - type = "text/plain" - flags = FLAG_ACTIVITY_NEW_TASK + var text = text + for (i in 10 downTo 1) { + try { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + flags = FLAG_ACTIVITY_NEW_TASK + } + val shareIntent = Intent.createChooser(sendIntent, null) + shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK) + androidAppContext.startActivity(shareIntent) + break + } catch (e: Exception) { + Log.e(TAG, "Failed to share text: ${e.stackTraceToString()}") + text = text.substring(0, min(i * 1000, text.length)) + } } - val shareIntent = Intent.createChooser(sendIntent, null) - shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK) - androidAppContext.startActivity(shareIntent) } actual fun shareFile(text: String, fileSource: CryptoFile) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index 1e8fe94bf4..5a4ae01098 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -114,7 +114,8 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { Handler(Looper.getMainLooper()).post { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.app_was_crashed), - text = e.stackTraceToString() + text = e.stackTraceToString(), + shareText = true ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index a87e7c45bb..df1dec330d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1123,11 +1123,19 @@ data class Connection( val viaGroupLink: Boolean, val customUserProfileId: Long? = null, val connectionCode: SecurityCode? = null, + val pqSupport: Boolean, + val pqEncryption: Boolean, + val pqSndEnabled: Boolean? = null, + val pqRcvEnabled: Boolean? = null, val connectionStats: ConnectionStats? = null ) { val id: ChatId get() = ":$connId" + + val connPQEnabled: Boolean + get() = pqSndEnabled == true && pqRcvEnabled == true + companion object { - val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null) + val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null, pqSupport = false, pqEncryption = false) } } @@ -1822,7 +1830,7 @@ data class ChatItem ( is CIContent.SndGroupInvitation -> false is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) { is RcvDirectEvent.ContactDeleted -> false - is RcvDirectEvent.ProfileUpdated -> true + is RcvDirectEvent.ProfileUpdated -> false } is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is RcvGroupEvent.MemberAdded -> false @@ -1853,6 +1861,10 @@ data class ChatItem ( is CIContent.SndModerated -> false is CIContent.RcvModerated -> false is CIContent.RcvBlocked -> false + is CIContent.SndDirectE2EEInfo -> false + is CIContent.RcvDirectE2EEInfo -> false + is CIContent.SndGroupE2EEInfo -> false + is CIContent.RcvGroupE2EEInfo -> false is CIContent.InvalidJSON -> false } @@ -2283,6 +2295,10 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndModerated") object SndModerated: CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvModerated") object RcvModerated: CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvBlocked") object RcvBlocked: CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndDirectE2EEInfo") class SndDirectE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvDirectE2EEInfo") class RcvDirectE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndGroupE2EEInfo") class SndGroupE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvGroupE2EEInfo") class RcvGroupE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null } override val text: String get() = when (this) { @@ -2312,6 +2328,10 @@ sealed class CIContent: ItemContent { is SndModerated -> generalGetString(MR.strings.moderated_description) is RcvModerated -> generalGetString(MR.strings.moderated_description) is RcvBlocked -> generalGetString(MR.strings.blocked_by_admin_item_description) + is SndDirectE2EEInfo -> directE2EEInfoToText(e2eeInfo) + is RcvDirectE2EEInfo -> directE2EEInfoToText(e2eeInfo) + is SndGroupE2EEInfo -> e2eeInfoNoPQText + is RcvGroupE2EEInfo -> e2eeInfoNoPQText is InvalidJSON -> "invalid data" } @@ -2330,6 +2350,15 @@ sealed class CIContent: ItemContent { } companion object { + fun directE2EEInfoToText(e2EEInfo: E2EEInfo): String = + if (e2EEInfo.pqEnabled) { + generalGetString(MR.strings.e2ee_info_pq) + } else { + e2eeInfoNoPQText + } + + private val e2eeInfoNoPQText: String = generalGetString(MR.strings.e2ee_info_no_pq) + fun featureText(feature: Feature, enabled: String, param: Int?): String = if (feature.hasParam) { "${feature.text}: ${timeText(param)}" @@ -2744,6 +2773,9 @@ enum class CIGroupInvitationStatus { @SerialName("expired") Expired; } +@Serializable +class E2EEInfo (val pqEnabled: Boolean) {} + object MsgContentSerializer : KSerializer { override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) { element("MCText", buildClassSerialDescriptor("MCText") { @@ -3097,6 +3129,7 @@ sealed class RcvConnEvent { @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase): RcvConnEvent() @Serializable @SerialName("ratchetSync") class RatchetSync(val syncStatus: RatchetSyncState): RcvConnEvent() @Serializable @SerialName("verificationCodeReset") object VerificationCodeReset: RcvConnEvent() + @Serializable @SerialName("pqEnabled") class PQEnabled(val enabled: Boolean): RcvConnEvent() val text: String get() = when (this) { is SwitchQueue -> when (phase) { @@ -3105,6 +3138,11 @@ sealed class RcvConnEvent { } is RatchetSync -> ratchetSyncStatusToText(syncStatus) is VerificationCodeReset -> generalGetString(MR.strings.rcv_conn_event_verification_code_reset) + is PQEnabled -> if (enabled) { + generalGetString(MR.strings.conn_event_enabled_pq) + } else { + generalGetString(MR.strings.conn_event_disabled_pq) + } } } @@ -3122,6 +3160,7 @@ fun ratchetSyncStatusToText(ratchetSyncStatus: RatchetSyncState): String { sealed class SndConnEvent { @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase, val member: GroupMemberRef? = null): SndConnEvent() @Serializable @SerialName("ratchetSync") class RatchetSync(val syncStatus: RatchetSyncState, val member: GroupMemberRef? = null): SndConnEvent() + @Serializable @SerialName("pqEnabled") class PQEnabled(val enabled: Boolean): SndConnEvent() val text: String get() = when (this) { @@ -3150,6 +3189,12 @@ sealed class SndConnEvent { } ratchetSyncStatusToText(syncStatus) } + + is PQEnabled -> if (enabled) { + generalGetString(MR.strings.conn_event_enabled_pq) + } else { + generalGetString(MR.strings.conn_event_disabled_pq) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 90a4261025..d695b2c608 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -156,6 +156,7 @@ class AppPreferences { val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false) val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false) val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null) + val pqExperimentalEnabled = mkBoolPreference(SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED, false) val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name) val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.SIMPLEX.name) @@ -312,6 +313,7 @@ class AppPreferences { private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades" private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct" private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName" + private const val SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED = "PQExperimentalEnabled" private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme" private const val SHARED_PREFS_SYSTEM_DARK_THEME = "SystemDarkTheme" private const val SHARED_PREFS_THEMES = "Themes" @@ -451,7 +453,21 @@ object ChatController { } try { val msg = recvMsg(ctrl) - if (msg != null) processReceivedMsg(msg) + if (msg != null) { + val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { + processReceivedMsg(msg) + } + if (finishedWithoutTimeout == null) { + Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.possible_slow_function_title), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + shareText = true + ) + } + } + } } catch (e: Exception) { Log.e(TAG, "ChatController recvMsg/processReceivedMsg exception: " + e.stackTraceToString()); } catch (e: Throwable) { @@ -617,14 +633,17 @@ object ChatController { throw Error("failed to set remote hosts folder: ${r.responseType} ${r.details}") } - suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) { - val r = sendCmd(null, CC.ApiSetXFTPConfig(cfg)) - if (r is CR.CmdOk) return - throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}") - } - suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable)) + suspend fun apiSetPQEncryption(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetPQEncryption(enable)) + + suspend fun apiSetContactPQ(rh: Long?, contactId: Long, enable: Boolean): Contact? { + val r = sendCmd(rh, CC.ApiSetContactPQ(contactId, enable)) + if (r is CR.ContactPQAllowed) return r.contact + apiErrorAlert("apiSetContactPQ", "Error allowing contact PQ", r) + return null + } + suspend fun apiExportArchive(config: ArchiveConfig) { val r = sendCmd(null, CC.ApiExportArchive(config)) if (r is CR.CmdOk) return @@ -1685,7 +1704,7 @@ object ChatController { chatModel.networkStatuses[s.agentConnId] = s.networkStatus } } - is CR.NewChatItem -> { + is CR.NewChatItem -> withBGApi { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem if (active(r.user)) { @@ -1700,7 +1719,7 @@ object ChatController { ((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) || (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { - withBGApi { receiveFile(rhId, r.user, file.fileId, auto = true) } + receiveFile(rhId, r.user, file.fileId, auto = true) } if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) @@ -2008,6 +2027,10 @@ object ChatController { } } } + is CR.ContactPQEnabled -> + if (active(r.user)) { + chatModel.updateContact(rhId, r.contact) + } is CR.ChatCmdError -> when { r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.CRITICAL -> { chatModel.processedCriticalError.newError(r.chatError.agentError, r.chatError.agentError.offerRestart) @@ -2157,10 +2180,6 @@ object ChatController { } } - fun getXFTPCfg(): XFTPFileConfig { - return XFTPFileConfig(minFileSize = 0) - } - fun getNetCfg(): NetCfg { val useSocksProxy = appPrefs.networkUseSocksProxy.get() val proxyHostPort = appPrefs.networkProxyHostPort.get() @@ -2269,8 +2288,9 @@ sealed class CC { class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() class SetRemoteHostsFolder(val remoteHostsFolder: String): CC() - class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() class ApiSetEncryptLocalFiles(val enable: Boolean): CC() + class ApiSetPQEncryption(val enable: Boolean): CC() + class ApiSetContactPQ(val contactId: Long, val enable: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() @@ -2399,8 +2419,9 @@ sealed class CC { is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" is SetRemoteHostsFolder -> "/remote_hosts_folder $remoteHostsFolder" - is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" + is ApiSetPQEncryption -> "/pq ${onOff(enable)}" + is ApiSetContactPQ -> "/_pq @$contactId ${onOff(enable)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" @@ -2534,8 +2555,9 @@ sealed class CC { is SetTempFolder -> "setTempFolder" is SetFilesFolder -> "setFilesFolder" is SetRemoteHostsFolder -> "setRemoteHostsFolder" - is ApiSetXFTPConfig -> "apiSetXFTPConfig" is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" + is ApiSetPQEncryption -> "apiSetPQEncryption" + is ApiSetContactPQ -> "apiSetContactPQ" is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" @@ -2700,9 +2722,6 @@ sealed class ChatPagination { @Serializable class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) -@Serializable -class XFTPFileConfig(val minFileSize: Long) - @Serializable class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) @@ -4004,6 +4023,10 @@ sealed class CR { @Serializable @SerialName("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR() @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR() @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(val rcsState: RemoteCtrlSessionState, val rcStopReason: RemoteCtrlStopReason): CR() + // pq + @Serializable @SerialName("contactPQAllowed") class ContactPQAllowed(val user: UserRef, val contact: Contact, val pqEncryption: Boolean): CR() + @Serializable @SerialName("contactPQEnabled") class ContactPQEnabled(val user: UserRef, val contact: Contact, val pqEnabled: Boolean): CR() + // misc @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List, val agentMigrations: List): CR() @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() @@ -4153,6 +4176,8 @@ sealed class CR { is RemoteCtrlSessionCode -> "remoteCtrlSessionCode" is RemoteCtrlConnected -> "remoteCtrlConnected" is RemoteCtrlStopped -> "remoteCtrlStopped" + is ContactPQAllowed -> "contactPQAllowed" + is ContactPQEnabled -> "contactPQEnabled" is VersionInfo -> "versionInfo" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" @@ -4317,6 +4342,8 @@ sealed class CR { "\nsessionCode: $sessionCode" is RemoteCtrlConnected -> json.encodeToString(remoteCtrl) is RemoteCtrlStopped -> noDetails() + is ContactPQAllowed -> withUser(user, "contact: ${contact.id}\npqEncryption: $pqEncryption") + is ContactPQEnabled -> withUser(user, "contact: ${contact.id}\npqEnabled: $pqEnabled") is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" + "chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" + "agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 63fcb90bbe..7e2ba462c9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -91,8 +91,8 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat if (appPlatform.isDesktop) { controller.apiSetRemoteHostsFolder(remoteHostsDir.absolutePath) } - controller.apiSetXFTPConfig(controller.getXFTPCfg()) controller.apiSetEncryptLocalFiles(controller.appPrefs.privacyEncryptLocalFiles.get()) + controller.apiSetPQEncryption(controller.appPrefs.pqExperimentalEnabled.get()) // If we migrated successfully means previous re-encryption process on database level finished successfully too if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) val user = chatController.apiGetActiveUser(null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index f195c723f6..bbd5d93018 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -58,6 +58,7 @@ fun ChatInfoView( val currentUser = remember { chatModel.currentUser }.value val connStats = remember(contact.id, connectionStats) { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() + val pqExperimentalEnabled = chatModel.controller.appPrefs.pqExperimentalEnabled.get() if (chat != null && currentUser != null) { val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) { mutableStateOf(chatModel.contactNetworkStatus(contact)) @@ -80,6 +81,7 @@ fun ChatInfoView( localAlias, connectionCode, developerTools, + pqExperimentalEnabled, onLocalAliasChanged = { setContactAlias(chat, it, chatModel) }, @@ -138,6 +140,17 @@ fun ChatInfoView( } }) }, + allowContactPQ = { + showAllowContactPQAlert(allowContactPQ = { + withBGApi { + val ct = chatModel.controller.apiSetContactPQ(chatRh, contact.contactId, true) + if (ct != null) { + chatModel.updateContact(chatRh, contact) + } + close.invoke() + } + }) + }, verifyClicked = { ModalManager.end.showModalCloseable { close -> remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct -> @@ -288,6 +301,7 @@ fun ChatInfoLayout( localAlias: String, connectionCode: String?, developerTools: Boolean, + pqExperimentalEnabled: Boolean, onLocalAliasChanged: (String) -> Unit, openPreferences: () -> Unit, deleteContact: () -> Unit, @@ -296,6 +310,7 @@ fun ChatInfoLayout( abortSwitchContactAddress: () -> Unit, syncContactConnection: () -> Unit, syncContactConnectionForce: () -> Unit, + allowContactPQ: () -> Unit, verifyClicked: () -> Unit, ) { val cStats = connStats.value @@ -345,6 +360,18 @@ fun ChatInfoLayout( SectionDividerSpaced() } + val conn = contact.activeConn + if (pqExperimentalEnabled && conn != null) { + SectionView("Quantum resistant E2E encryption") { + InfoRow("E2E encryption", if (conn.connPQEnabled) "Quantum resistant" else "Standard") + if (!conn.pqEncryption) { + AllowContactPQButton(allowContactPQ) + SectionTextFooter("After allowing quantum resistant e2e encryption, it will be enabled after several messages if your contact also allows it.") + } + SectionDividerSpaced() + } + } + if (contact.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { SimpleXLinkQRCode(contact.contactLink) @@ -601,6 +628,17 @@ fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) { ) } +@Composable +fun AllowContactPQButton(allowContactPQ: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_warning), + "Allow PQ encryption", + click = allowContactPQ, + textColor = WarningOrange, + iconColor = WarningOrange + ) +} + @Composable fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) { SettingsActionItem( @@ -704,6 +742,16 @@ fun showSyncConnectionForceAlert(syncConnectionForce: () -> Unit) { ) } +fun showAllowContactPQAlert(allowContactPQ: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = "Allow quantum resistant encryption?", + text = "This is an experimental feature, it is not recommended to enable it for important chats.", + confirmText = "Allow", + onConfirm = allowContactPQ, + destructive = true, + ) +} + @Preview @Composable fun PreviewChatInfoLayout() { @@ -721,6 +769,7 @@ fun PreviewChatInfoLayout() { localAlias = "", connectionCode = "123", developerTools = false, + pqExperimentalEnabled = false, connStats = remember { mutableStateOf(null) }, contactNetworkStatus = NetworkStatus.Connected(), onLocalAliasChanged = {}, @@ -732,6 +781,7 @@ fun PreviewChatInfoLayout() { abortSwitchContactAddress = {}, syncContactConnection = {}, syncContactConnectionForce = {}, + allowContactPQ = {}, verifyClicked = {}, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 534185429c..a438ea2bfc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -267,7 +267,7 @@ fun ComposeView( fun loadLinkPreview(url: String, wait: Long? = null) { if (pendingLinkUrl.value == url) { composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null)) - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi(slow = 60_000) { if (wait != null) delay(wait) val lp = getLinkPreview(url) if (lp != null && pendingLinkUrl.value == url) { @@ -551,7 +551,7 @@ fun ComposeView( } fun sendMessage(ttl: Int?) { - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi(slow = 120_000) { sendMessageAsync(null, false, ttl) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 6add33d83d..3f283f704e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -54,7 +54,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea }, inviteMembers = { allowModifyMembers = false - withLongRunningApi(slow = 30_000, deadlock = 120_000) { + withLongRunningApi(slow = 120_000) { for (contactId in selectedContacts) { val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value) if (member != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index d602d78d89..98c0fb4679 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -152,7 +152,7 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl text = generalGetString(MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved), confirmText = generalGetString(MR.strings.leave_group_button), onConfirm = { - withBGApi { + withLongRunningApi(60_000) { chatModel.controller.leaveGroup(rhId, groupInfo.groupId) close?.invoke() } @@ -424,69 +424,47 @@ private fun MemberVerifiedShield() { @Composable private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { - // revert from this: - DefaultDropdownMenu(showMenu) { - if (member.canBeRemoved(groupInfo)) { - ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { - removeMemberAlert(rhId, groupInfo, member) - showMenu.value = false - }) + if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) { + val canBlockForAll = member.canBlockForAll(groupInfo) + val canRemove = member.canBeRemoved(groupInfo) + if (canBlockForAll || canRemove) { + DefaultDropdownMenu(showMenu) { + if (canBlockForAll) { + if (member.blockedByAdmin) { + ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), onClick = { + unblockForAllAlert(rhId, groupInfo, member) + showMenu.value = false + }) + } else { + ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { + blockForAllAlert(rhId, groupInfo, member) + showMenu.value = false + }) + } + } + if (canRemove) { + ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + removeMemberAlert(rhId, groupInfo, member) + showMenu.value = false + }) + } + } } - if (member.memberSettings.showMessages) { - ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { - blockMemberAlert(rhId, groupInfo, member) - showMenu.value = false - }) - } else { - ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { - unblockMemberAlert(rhId, groupInfo, member) - showMenu.value = false - }) + } else if (!member.blockedByAdmin) { + DefaultDropdownMenu(showMenu) { + if (member.memberSettings.showMessages) { + ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { + blockMemberAlert(rhId, groupInfo, member) + showMenu.value = false + }) + } else { + ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { + unblockMemberAlert(rhId, groupInfo, member) + showMenu.value = false + }) + } } } - // revert to this: vvv -// if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) { -// val canBlockForAll = member.canBlockForAll(groupInfo) -// val canRemove = member.canBeRemoved(groupInfo) -// if (canBlockForAll || canRemove) { -// DefaultDropdownMenu(showMenu) { -// if (canBlockForAll) { -// if (member.blockedByAdmin) { -// ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), onClick = { -// unblockForAllAlert(rhId, groupInfo, member) -// showMenu.value = false -// }) -// } else { -// ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { -// blockForAllAlert(rhId, groupInfo, member) -// showMenu.value = false -// }) -// } -// } -// if (canRemove) { -// ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { -// removeMemberAlert(rhId, groupInfo, member) -// showMenu.value = false -// }) -// } -// } -// } -// } else if (!member.blockedByAdmin) { -// DefaultDropdownMenu(showMenu) { -// if (member.memberSettings.showMessages) { -// ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { -// blockMemberAlert(rhId, groupInfo, member) -// showMenu.value = false -// }) -// } else { -// ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { -// unblockMemberAlert(rhId, groupInfo, member) -// showMenu.value = false -// }) -// } -// } -// } - // ^^^ } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 6759d54749..b1034b914b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -387,25 +387,11 @@ fun GroupMemberInfoLayout( } } - // revert from this: - SectionDividerSpaced(maxBottomPadding = false) - SectionView { - if (member.memberSettings.showMessages) { - BlockMemberButton(blockMember) - } else { - UnblockMemberButton(unblockMember) - } - if (member.canBeRemoved(groupInfo)) { - RemoveMemberButton(removeMember) - } + if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) { + AdminDestructiveSection() + } else { + NonAdminBlockSection() } - // revert to this: vvv -// if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) { -// AdminDestructiveSection() -// } else { -// NonAdminBlockSection() -// } - // ^^^ if (developerTools) { SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 6a1db9249e..f7909eed12 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -94,7 +94,7 @@ fun CIFileView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvComplete || (file.fileStatus is CIFileStatus.SndStored && file.fileProtocol == FileProtocol.LOCAL) -> { - withLongRunningApi(slow = 60_000, deadlock = 600_000) { + withLongRunningApi(slow = 600_000) { var filePath = getLoadedFilePath(file) if (chatModel.connectedToRemote() && filePath == null) { file.loadRemoteFile(true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index a5a9521084..a79e509d02 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -41,7 +41,7 @@ fun CIVideoView( val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) } if (chatModel.connectedToRemote()) { LaunchedEffect(file) { - withLongRunningApi(slow = 60_000, deadlock = 600_000) { + withLongRunningApi(slow = 600_000) { if (file != null && file.loaded && getLoadedFilePath(file) == null) { file.loadRemoteFile(false) filePath.value = getLoadedFilePath(file) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 568f003026..cce4307d1f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -213,7 +213,7 @@ fun ChatItemView( showMenu.value = false } if (chatModel.connectedToRemote() && fileSource == null) { - withLongRunningApi(slow = 60_000, deadlock = 600_000) { + withLongRunningApi(slow = 600_000) { cItem.file?.loadRemoteFile(true) fileSource = getLoadedFileSource(cItem.file) shareIfExists() @@ -452,6 +452,11 @@ fun ChatItemView( is CIContent.SndModerated -> DeletedItem() is CIContent.RcvModerated -> DeletedItem() is CIContent.RcvBlocked -> DeletedItem() + // TODO proper items + is CIContent.SndDirectE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) + is CIContent.RcvDirectE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) + is CIContent.SndGroupE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) + is CIContent.RcvGroupE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index a4cea68ff2..fa72ab2b3c 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 @@ -12,6 +12,7 @@ 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.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign @@ -22,6 +23,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -189,6 +191,7 @@ class AlertManager { title: String, text: String? = null, confirmText: String = generalGetString(MR.strings.ok), hostDevice: Pair? = null, + shareText: Boolean? = null ) { showAlert { AlertDialog( @@ -202,10 +205,19 @@ class AlertManager { delay(200) focusRequester.requestFocus() } + // Can pass shareText = false to prevent showing Share button if it's needed in a specific case + val showShareButton = text != null && (shareText == true || (shareText == null && text.length > 500)) Row( Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), - horizontalArrangement = Arrangement.Center + horizontalArrangement = if (showShareButton) Arrangement.SpaceBetween else Arrangement.Center ) { + val clipboard = LocalClipboardManager.current + if (showShareButton && text != null) { + TextButton(onClick = { + clipboard.shareText(text) + hideAlert() + }) { Text(stringResource(MR.strings.share_verb)) } + } TextButton( onClick = { hideAlert() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt index b645cb1ee6..5fa097fb6b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt @@ -16,7 +16,7 @@ class ProcessedErrors (val interval: Long) { fun newError(error: T, offerRestart: Boolean) { timer.cancel() - timer = withLongRunningApi(slow = 70_000, deadlock = 130_000) { + timer = withLongRunningApi(slow = 130_000) { val delayBeforeNext = (lastShownTimestamp + interval) - System.currentTimeMillis() if ((lastShownOfferRestart || !offerRestart) && delayBeforeNext >= 0) { delay(delayBeforeNext) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 42e51efb86..ae09163591 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -37,30 +37,22 @@ fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job = CoroutineScope(singleThreadDispatcher).launch(block = { wrapWithLogging(action, it) }) } -fun withLongRunningApi(slow: Long = Long.MAX_VALUE, deadlock: Long = Long.MAX_VALUE, action: suspend CoroutineScope.() -> Unit): Job = +fun withLongRunningApi(slow: Long = Long.MAX_VALUE, action: suspend CoroutineScope.() -> Unit): Job = Exception().let { - CoroutineScope(Dispatchers.Default).launch(block = { wrapWithLogging(action, it, slow = slow, deadlock = deadlock) }) + CoroutineScope(Dispatchers.Default).launch(block = { wrapWithLogging(action, it, slow = slow) }) } -private suspend fun wrapWithLogging(action: suspend CoroutineScope.() -> Unit, exception: java.lang.Exception, slow: Long = 10_000, deadlock: Long = 60_000) = coroutineScope { +private suspend fun wrapWithLogging(action: suspend CoroutineScope.() -> Unit, exception: java.lang.Exception, slow: Long = 20_000) = coroutineScope { val start = System.currentTimeMillis() - val job = launch { - delay(deadlock) - Log.e(TAG, "Possible deadlock of the thread, not finished after ${deadlock / 1000}s:\n${exception.stackTraceToString()}") - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.possible_deadlock_title), - text = generalGetString(MR.strings.possible_deadlock_desc).format(deadlock / 1000, exception.stackTraceToString()), - ) - } action() - job.cancel() - if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { - val end = System.currentTimeMillis() - if (end - start > slow) { - Log.e(TAG, "Possible problem with execution of the thread, took ${(end - start) / 1000}s:\n${exception.stackTraceToString()}") + val end = System.currentTimeMillis() + if (end - start > slow) { + Log.e(TAG, "Possible problem with execution of the thread, took ${(end - start) / 1000}s:\n${exception.stackTraceToString()}") + if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.possible_slow_function_title), text = generalGetString(MR.strings.possible_slow_function_desc).format((end - start) / 1000, exception.stackTraceToString()), + shareText = true ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index cc268e9a9d..421d4feec3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -58,6 +58,14 @@ fun DeveloperView( SettingsPreferenceItem(painterResource(MR.images.ic_report), stringResource(MR.strings.show_internal_errors), appPreferences.showInternalErrors) SettingsPreferenceItem(painterResource(MR.images.ic_avg_pace), stringResource(MR.strings.show_slow_api_calls), appPreferences.showSlowApiCalls) } + + SectionSpacer() + SectionView("Experimental".uppercase()) { + SettingsPreferenceItem(painterResource(MR.images.ic_vpn_key_filled), "Post-quantum E2EE", m.controller.appPrefs.pqExperimentalEnabled, onChange = { enable -> + withBGApi { m.controller.apiSetPQEncryption(enable) } + }) + SectionTextFooter("In this version applies only to new contacts.") + } } SectionBottomSpacer() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 76aa905b81..5f4b6c01e3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -96,7 +96,7 @@ fun PrivacySettingsView( val currentUser = chatModel.currentUser.value if (currentUser != null) { fun setSendReceiptsContacts(enable: Boolean, clearOverrides: Boolean) { - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi(slow = 60_000) { val mrs = UserMsgReceiptSettings(enable, clearOverrides) chatModel.controller.apiSetUserContactReceipts(currentUser, mrs) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) @@ -119,7 +119,7 @@ fun PrivacySettingsView( } fun setSendReceiptsGroups(enable: Boolean, clearOverrides: Boolean) { - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi(slow = 60_000) { val mrs = UserMsgReceiptSettings(enable, clearOverrides) chatModel.controller.apiSetUserGroupReceipts(currentUser, mrs) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 94a8ab76b4..5903c67e9c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -1588,8 +1588,6 @@ سطح المكتب مشغول يحتوي سطح المكتب على إصدار غير مدعوم. يُرجى التأكد من استخدام نفس الإصدار على كلا الجهازين العضو السابق %1$s - مأزق - يستغرق تنفيذ التعليمات البرمجية وقتًا طويلاً جدًا: %1$d ثانية. من المحتمل أن التطبيق مجمّد: %2$s وظيفة بطيئة خيارات المطور تغيّر العضو %1$s إلى %2$s 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 584bcb318f..49552a592d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -54,6 +54,9 @@ Decryption error Encryption re-negotiation error + This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery. + This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery. + Private notes @@ -147,8 +150,6 @@ Delete file Error deleting user profile Error updating user privacy - Deadlock - Execution of code takes too long time: %1$d seconds. Probably, the app is frozen: %2$s Slow function Execution of function takes too long time: %1$d seconds: %2$s @@ -1241,6 +1242,8 @@ agreeing encryption for %s… encryption agreed for %s security code changed + quantum resistant e2e encryption + standard end-to-end encryption observer diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index 05101075a0..e75634b536 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -1555,7 +1555,6 @@ Чатът е спрян. Ако вече сте използвали тази база данни на друго устройство, трябва да я прехвърлите обратно, преди да стартирате чата отново. Настолното устройство има грешен код за връзка Настолното устройство е с неподдържана версия. Моля, уверете се, че използвате една и съща версия и на двете устройства - Изпълнението на кода отнема твърде много време: %1$d секунди. Вероятно приложението е замразено: %2$s Бавна функция Изпълнението на функцията отнема твърде много време: %1$d секунди: %2$s Покажи вътрешните грешки @@ -1591,5 +1590,4 @@ \nПрепоръчително е да рестартирате приложението. Опции за разработчици Показване на бавни API заявки - Грешка в заключено положение \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 836671ad48..5ec604c259 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1672,9 +1672,7 @@ Langsame Funktion Zeige langsame API-Aufrufe an unbekannt - Blockade Optionen für Entwickler - Die Code-Ausführung dauert zu lange: %1$d Sekunden. Wahrscheinlich ist die App eingefroren: %2$s unbekannter Gruppenmitglieds-Status Mit verschlüsselten Dateien und Medien. Private Notizen diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 43068ac7b6..b43eb64082 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1559,11 +1559,9 @@ %s.]]> Connexion interrompue État médiocre de la connexion avec le bureau - Impasse La version de l\'ordinateur de bureau n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils. Le bureau a été déconnecté Options pour les développeurs - Le code prend trop de temps à s\'exécuter : %1$d secondes. Il est probable que l\'application soit figée : %2$s Erreur interne %s n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils.]]> Afficher les erreurs internes diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 9647e8a406..4c8ceed529 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1583,9 +1583,7 @@ Lassú funkció Lassú API-hívások megjelenítése %s mobil eszköz inaktív]]> - Elakadt Fejlesztői beállítások - A kód végrehajtása túl sokáig tart: %1$d másodperc. Valószínűleg az alkalmazás lefagyott: %2$s A funkció végrehajtása túl sokáig tart: %1$d másodperc: %2$s %s mobil eszköz elfoglalt]]> Legutóbbi tag %1$s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 15029be968..0ee9a6a625 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1591,9 +1591,7 @@ Funzione lenta Mostra chiamate API lente sconosciuto - L\'esecuzione del codice impiega troppo tempo: %1$d secondi. Probabilmente l\'app è congelata: %2$s stato sconosciuto - Stallo Opzioni sviluppatore Note private Interfaccia in ungherese e turco diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 2951b13155..6781899b21 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -1571,9 +1571,7 @@ PC版が処理中 PC版が切断されました ご利用のPC版のバージョンがサポートされてません。両端末が同じバージョンかどうか、ご確認ください。 - デッドロック状態 開発者向けの設定 - 処理時間が異常にかかるようです: %1$d 秒。アプリが固まった恐れがあります: %2$s %s がただいま処理中]]> 機能の処理時間が以上にかかってます: %1$d 秒: %2$s 内部エラーを表示 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index d31e465957..7ee6ecdb35 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1574,7 +1574,6 @@ %s ontbreekt]]> %s is in slechte staat]]> De verbinding met desktop is verbroken - Impasse Uitvoering van functie duurt te lang: %1$d seconden: %2$s Langzame functie Ontwikkelaars opties @@ -1588,7 +1587,6 @@ Chat opnieuw starten %s]]> De verbinding met de desktop is in slechte staat - Het uitvoeren van de code duurt te lang: %1$d seconden. Waarschijnlijk is de app vastgelopen: %2$s Desktop heeft verkeerde uitnodigingscode %s heeft een niet-ondersteunde versie. Zorg ervoor dat u op beide apparaten dezelfde versie gebruikt]]> Time-out bereikt tijdens het verbinden met de desktop diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index a09b20f5ec..48607217ce 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1606,7 +1606,6 @@ Komputer ma niewspieraną wersję. Proszę upewnić się, że używasz tych samych wersji na obu urządzeniach %d wiadomości zablokowanych przez admina Błąd tworzenia wiadomości - Wykonanie kodu zajmuje za dużo czasu: %1$d sekund. Prawdopodobnie aplikacja jest zamrożona: %2$s Wykonanie kodu zajmuje za dużo czasu: %1$d sekund: %2$s Prywatne notatki nieznany status @@ -1621,7 +1620,6 @@ %s jest nieaktywny]]> %s ma niewspieraną wersję. Proszę, upewnij się, że używasz tej samej wersji na obydwu urządzeniach]]> nieznany - Blokada kontakt %1$s zmieniony na %2$s usunięto adres kontaktu usunięto zdjęcie profilu diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index af0f3c6ee7..98e6223d87 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1680,8 +1680,6 @@ ошибка отображения сообщения ошибка отображения содержания Отсоединён по причине: %s - Взаимная блокировка - Выполнение задачи занимает долгое время: %1$d секунд. Возможно, приложение заблокировано: %2$s Выполнение задачи занимает долгое время: %1$d секунд: %2$s Медленный вызов контакт %1$s изменён на %2$s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index b1baec68fc..6075de500b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1586,8 +1586,6 @@ %s的连接状态不佳]]> %s时超时]]> 显示名无效。请另选一个名称。 - 死锁 - 代码执行花费的时间过久:%1$d秒。应用可能卡住了:%2$s 慢函数 显示缓慢的 API 调用 过往成员 %1$s diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 41e87b4a13..44073aa990 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -39,7 +39,8 @@ fun showApp() { WindowExceptionHandler { e -> AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.app_was_crashed), - text = e.stackTraceToString() + text = e.stackTraceToString(), + shareText = true ) Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString()) window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index 29e9d5bae1..30e1ad0352 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -42,7 +42,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL } var fileSource = getLoadedFileSource(cItem.file) if (chatModel.connectedToRemote() && fileSource == null) { - withLongRunningApi(slow = 60_000, deadlock = 600_000) { + withLongRunningApi(slow = 600_000) { cItem.file?.loadRemoteFile(true) fileSource = getLoadedFileSource(cItem.file) saveIfExists() @@ -51,7 +51,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL }) } -actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withLongRunningApi(slow = 60_000, deadlock = 600_000) { +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withLongRunningApi(slow = 600_000) { var fileSource = getLoadedFileSource(cItem.file) if (chatModel.connectedToRemote() && fileSource == null) { cItem.file?.loadRemoteFile(true) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 784bd7c91c..5a556d5f82 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.5.3 -android.version_code=181 +android.version_name=5.5.6 +android.version_code=187 -desktop.version_name=5.5.3 -desktop.version_code=29 +desktop.version_name=5.5.6 +desktop.version_code=32 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 diff --git a/blog/20210512-simplex-chat-terminal-ui.md b/blog/20210512-simplex-chat-terminal-ui.md index a6a5aea5a4..1357f6c9b3 100644 --- a/blog/20210512-simplex-chat-terminal-ui.md +++ b/blog/20210512-simplex-chat-terminal-ui.md @@ -10,7 +10,7 @@ permalink: "/blog/20210512-simplex-chat-terminal-ui.html" **Published:** May 12, 2021 -For the last six months [me](https://github.com/epoberezkin) and my son [Efim](https://github.com/efim-poberezkin) have been working to bring you a working prototype of SimpleX Chat. We're excited to announce SimpleX Chat terminal client is now available [here](https://github.com/simplex-chat/simplex-chat) on Linux, Windows and Mac (you can either build from source or download the binary for Linux, Windows or Mac from the latest release). +For the last six months [me](https://github.com/epoberezkin) and my son Efim have been working to bring you a working prototype of SimpleX Chat. We're excited to announce SimpleX Chat terminal client is now available [here](https://github.com/simplex-chat/simplex-chat) on Linux, Windows and Mac (you can either build from source or download the binary for Linux, Windows or Mac from the latest release). We’ve been using the terminal client between us and a few other people for a couple of months now, eating our own “dog food”, and have developed up to version 0.3.1, with most of the messaging protocol features we originally planned diff --git a/blog/20220928-simplex-chat-v4-encrypted-database.md b/blog/20220928-simplex-chat-v4-encrypted-database.md index bdf7e9790c..6f8064454f 100644 --- a/blog/20220928-simplex-chat-v4-encrypted-database.md +++ b/blog/20220928-simplex-chat-v4-encrypted-database.md @@ -78,7 +78,7 @@ You can run SimpleX Chat CLI as a local WebSockets server on any port, we use 52 simplex-chat -p 5225 ``` -Then you can create a JavaScript or TypeScript application that would connect to it and control it via a simple WebSocket API. TypeScript SDK defines all necessary types and convenience functions to use in your applications. See this [sample bot](https://github.com/simplex-chat/simplex-chat/blob/stable/packages/simplex-chat-client/typescript/examples/squaring-bot.js) and [README page](https://github.com/simplex-chat/simplex-chat/tree/ep/blog-v4/packages/simplex-chat-client/typescript). +Then you can create a JavaScript or TypeScript application that would connect to it and control it via a simple WebSocket API. TypeScript SDK defines all necessary types and convenience functions to use in your applications. See this [sample bot](https://github.com/simplex-chat/simplex-chat/blob/stable/packages/simplex-chat-client/typescript/examples/squaring-bot.js) and README page. SimpleX Chat API allows you to: diff --git a/blog/20221206-simplex-chat-v4.3-voice-messages.md b/blog/20221206-simplex-chat-v4.3-voice-messages.md index 32bbe058e5..1ca25ce5d0 100644 --- a/blog/20221206-simplex-chat-v4.3-voice-messages.md +++ b/blog/20221206-simplex-chat-v4.3-voice-messages.md @@ -18,7 +18,7 @@ Since we published [the security assessment of SimpleX Chat](https://simplex.cha - Privacy Guides added SimpleX Chat to [the recommended private and secure messengers](https://www.privacyguides.org/real-time-communication/#simplex-chat). - Mike Kuketz – a well-known security expert – published [the review of SimpleX Chat](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) and added it to [the messenger matrix](https://www.messenger-matrix.de). -- Supernova published [the review](https://supernova.tilde.team/detailed_reviews.html#simplex) and increased [SimpleX Chat recommendation ratings](https://supernova.tilde.team/messengers.html). +- Supernova published [the review](https://supernovas.space/detailed_reviews.html#simplex) and increased [SimpleX Chat recommendation ratings](https://supernovas.space/messengers.html). ## What's new in v4.3 diff --git a/blog/README.md b/blog/README.md index 3afa61eeff..8066f0592a 100644 --- a/blog/README.md +++ b/blog/README.md @@ -146,7 +146,7 @@ November reviews: - [Privacy Guides](https://www.privacyguides.org/real-time-communication/#simplex-chat) recommendations. - [Review by Mike Kuketz](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/). - [The messenger matrix](https://www.messenger-matrix.de). -- [Supernova review](https://supernova.tilde.team/detailed_reviews.html#simplex) and [messenger ratings](https://supernova.tilde.team/messengers.html). +- [Supernova review](https://supernovas.space/detailed_reviews.html#simplex) and [messenger ratings](https://supernovas.space/messengers.html). --- diff --git a/blog/lang/fr-fr/README_fr.md b/blog/lang/fr-fr/README_fr.md index d3deefe3f0..bebc42d7bc 100644 --- a/blog/lang/fr-fr/README_fr.md +++ b/blog/lang/fr-fr/README_fr.md @@ -26,7 +26,7 @@ Critiques de novembre : - Recommandations de [Privacy Guides](https://www.privacyguides.org/real-time-communication/#simplex-chat). - [Revue par Mike Kuketz](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/). - [La matrice des messageries](https://www.messenger-matrix.de). -- [Revue de Supernova](https://supernova.tilde.team/detailed_reviews.html#simplex) et [évaluations des messageries](https://supernova.tilde.team/messengers.html). +- [Revue de Supernova](https://supernovas.space/detailed_reviews.html#simplex) et [évaluations des messageries](https://supernovas.space/messengers.html). Sortie de la v4.3 : diff --git a/cabal.project b/cabal.project index 5cca86b68f..3916c9fb6d 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: e64b6cba4b7e4107f78ae596ab2a6a28ef24ff78 + tag: 78eb4f764fd52385a8687d2605a0e6edc1808431 source-repository-package type: git diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 0429731761..6726fa02fe 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -1,13 +1,13 @@ --- title: Download SimpleX apps permalink: /downloads/index.html -revision: 25.11.2023 +revision: 11.02.2024 --- -| Updated 25.11.2023 | Languages: EN | +| Updated 11.02.2024 | Languages: EN | # Download SimpleX apps -The latest stable version is v5.5. +The latest stable version is v5.5.3. You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). @@ -21,24 +21,24 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-aarch64.dmg) (Apple Silicon), [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-x86_64.dmg) (Intel). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-windows-x86_64.msi). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-windows-x86_64.msi). ## Mobile apps **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](/docs/CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-macos-x86-64), aarch64 - [compile from source](/docs/CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-windows-x86-64). diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index e68508ccc3..0cb855d729 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -143,6 +143,12 @@ SimpleX Clients also form a network using SMP relays and IP or some other overla [Wikipedia](https://en.wikipedia.org/wiki/Overlay_network) +# Non-repudiation + +The property of the cryptographic or communication system that allows the recipient of the message to prove to any third party that the sender identified by some cryptographic key sent the message. It is the opposite to [repudiation](#repudiation). While in some context non-repudiation may be desirable (e.g., for contractually binding messages), in the context of private communications it may be undesirable. + +[Wikipedia](https://en.wikipedia.org/wiki/Non-repudiation) + ## Pairwise pseudonymous identifier Generalizing [the definition](https://csrc.nist.gov/glossary/term/pairwise_pseudonymous_identifier) from NIST Digital Identity Guidelines, it is an opaque unguessable identifier generated by a service used to access a resource by only one party. @@ -185,6 +191,12 @@ Network topology of the communication system when peers communicate via proxies [Post-compromise security](#post-compromise-security). +## Repudiation + +The property of the cryptographic or communication system that allows the sender of the message to plausibly deny having sent the message, because while the recipient can verify that the message was sent by the sender, they cannot prove it to any third party - the recipient has a technical ability to forge the same encrypted message. This is an important quality of private communications, as it allows to have the conversation that can later be denied, similarly to having a private face-to-face conversation. + +See also [non-repudiation](#non-repudiation). + ## User identity In a communication system it refers to anything that uniquely identifies the users to the network. Depending on the communication network, it can be a phone number, email address, username, public key or a random opaque identifier. Most messaging networks rely on some form of user identity. SimpleX appears to be the only messaging network that does not rely on any kind of user identity - see [this comparison](https://en.wikipedia.org/wiki/Comparison_of_instant_messaging_protocols). diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md index cf33df1ee7..3379f9ae04 100644 --- a/docs/JOIN_TEAM.md +++ b/docs/JOIN_TEAM.md @@ -70,7 +70,7 @@ Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin J ## How to join the team -1. [Install the app](../README.md#install-the-app), try using it with the friends and [join some user groups](https://github.com/simplex-chat/simplex-chat#join-user-groups) – you will discover a lot of things that need improvements. +1. [Install the app](https://github.com/simplex-chat/simplex-chat#install-the-app), try using it with the friends and [join some user groups](https://github.com/simplex-chat/simplex-chat#join-user-groups) – you will discover a lot of things that need improvements. 2. Also look through [GitHub issues](https://github.com/simplex-chat/simplex-chat/issues) submitted by the users to see what would you want to contribute as a test. diff --git a/docs/SERVER.md b/docs/SERVER.md index 00e3e0f6ee..e476c7250c 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -17,43 +17,43 @@ _Please note_: when you change the servers in the app configuration, it only aff ## Installation -0. First, install `smp-server`: +1. First, install `smp-server`: - Manual deployment (see below) - Semi-automatic deployment: - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) - - [Linode StackScript](https://github.com/simplex-chat/simplexmq#deploy-smp-server-on-linode) + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) Manual installation requires some preliminary actions: -0. Install binary: +1. Install binary: - Using offical binaries: ```sh - curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server ``` - Compiling from source: Please refer to [Build from source: Using your distribution](https://github.com/simplex-chat/simplexmq#using-your-distribution) -1. Create user and group for `smp-server`: +2. Create user and group for `smp-server`: ```sh sudo useradd -m smp ``` -2. Create necessary directories and assign permissions: +3. Create necessary directories and assign permissions: ```sh sudo mkdir -p /var/opt/simplex /etc/opt/simplex sudo chown smp:smp /var/opt/simplex /etc/opt/simplex ``` -3. Allow `smp-server` port in firewall: +4. Allow `smp-server` port in firewall: ```sh # For Ubuntu @@ -63,7 +63,7 @@ Manual installation requires some preliminary actions: sudo firewall-cmd --reload ``` -4. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/smp-server.service` file with the following content: +5. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/smp-server.service` file with the following content: ```sh [Unit] @@ -398,25 +398,82 @@ To import `csv` to `Grafana` one should: 2. Allow local mode by appending following: - ```sh - [plugin.marcusolsson-csv-datasource] - allow_local_mode = true - ``` + ```sh + [plugin.marcusolsson-csv-datasource] + allow_local_mode = true + ``` - ... to `/etc/grafana/grafana.ini` + ... to `/etc/grafana/grafana.ini` 3. Add a CSV data source: - - In the side menu, click the Configuration tab (cog icon) - - Click Add data source in the top-right corner of the Data Sources tab - - Enter "CSV" in the search box to find the CSV data source - - Click the search result that says "CSV" - - In URL, enter a file that points to CSV content + - In the side menu, click the Configuration tab (cog icon) + - Click Add data source in the top-right corner of the Data Sources tab + - Enter "CSV" in the search box to find the CSV data source + - Click the search result that says "CSV" + - In URL, enter a file that points to CSV content 4. You're done! You should be able to create your own dashboard with statistics. For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) +# Updating your SMP server + +To update your smp-server to latest version, choose your installation method and follow the steps: + + - Manual deployment + 1. Stop the server: + ```sh + sudo systemctl stop smp-server + ``` + 2. Update the binary: + ```sh + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server + ``` + 3. Start the server: + ```sh + sudo systemctl start smp-server + ``` + + - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) + 1. Execute the followin command: + ```sh + sudo simplex-servers-update + ``` + 2. Done! + + - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) + 1. Stop and remove the container: + ```sh + docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/smp-server --format="\{\{.ID\}\}")) + ``` + 2. Pull latest image: + ```sh + docker pull simplexchat/smp-server:latest + ``` + 3. Start new container: + ```sh + docker run -d \ + -p 5223:5223 \ + -v $HOME/simplex/smp/config:/etc/opt/simplex:z \ + -v $HOME/simplex/smp/logs:/var/opt/simplex:z \ + simplexchat/smp-server:latest + ``` + + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + 1. Pull latest images: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex pull + ``` + 2. Restart the containers: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans + ``` + 3. Remove obsolete images: + ```sh + docker image prune + ``` + ### Configuring the app to use the server To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. diff --git a/docs/XFTP-SERVER.md b/docs/XFTP-SERVER.md index 2977ff15da..a0ad0e0cf7 100644 --- a/docs/XFTP-SERVER.md +++ b/docs/XFTP-SERVER.md @@ -24,6 +24,7 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba - Semi-automatic deployment: - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) Manual installation requires some preliminary actions: @@ -32,7 +33,7 @@ Manual installation requires some preliminary actions: - Using offical binaries: ```sh - curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server ``` - Compiling from source: @@ -418,6 +419,65 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) + +# Updating your XFTP server + +To update your XFTP server to latest version, choose your installation method and follow the steps: + + - Manual deployment + 1. Stop the server: + ```sh + sudo systemctl stop xftp-server + ``` + 2. Update the binary: + ```sh + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server + ``` + 3. Start the server: + ```sh + sudo systemctl start xftp-server + ``` + + - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) + 1. Execute the followin command: + ```sh + sudo simplex-servers-update + ``` + 2. Done! + + - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) + 1. Stop and remove the container: + ```sh + docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/xftp-server --format="\{\{.ID\}\}")) + ``` + 2. Pull latest image: + ```sh + docker pull simplexchat/xftp-server:latest + ``` + 3. Start new container: + ```sh + docker run -d \ + -p 443:443 \ + -v $HOME/simplex/xftp/config:/etc/opt/simplex-xftp:z \ + -v $HOME/simplex/xftp/logs:/var/opt/simplex-xftp:z \ + -v $HOME/simplex/xftp/files:/srv/xftp:z \ + simplexchat/xftp-server:latest + ``` + + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + 1. Pull latest images: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex pull + ``` + 2. Restart the containers: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans + ``` + 3. Remove obsolete images: + ```sh + docker image prune + ``` + ### Configuring the app to use the server Please see: [SMP Server: Configuring the app to use the server](./SERVER.md#configuring-the-app-to-use-the-server). diff --git a/docs/rfcs/2023-09-30-pq-double-ratchet.md b/docs/rfcs/2023-09-30-pq-double-ratchet.md index 255051320d..95e7aa3d1d 100644 --- a/docs/rfcs/2023-09-30-pq-double-ratchet.md +++ b/docs/rfcs/2023-09-30-pq-double-ratchet.md @@ -82,7 +82,7 @@ def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb, state.PQRs = GENERATE_PQKEM() state.PQRr = bob_pq_kem_encapsulation_key state.PQRss = random // shared secret for KEM - state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret + state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret // above added for KEM // below augments DH key agreement with PQ shared secret state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss) @@ -103,7 +103,7 @@ def RatchetInitBobPQ2HE(state, SK, bob_dh_key_pair, shared_hka, shared_nhkb, bob state.PQRs = bob_pq_kem_key_pair state.PQRr = None state.PQRss = None - state.PQRenc_ss = None + state.PQRct = None // above added for KEM state.RK = SK state.CKs = None @@ -132,10 +132,10 @@ def RatchetEncryptPQ2HE(state, plaintext, AD): // encapsulation key from PQRs and encapsulated shared secret is added to header header = HEADER_PQ2( dh = state.DHRs.public, + kem = state.PQRs.public, // added for KEM #2 + ct = state.PQRct // added for KEM #1 pn = state.PN, n = state.Ns, - encaps = state.PQRs.encaps, // added for KEM #1 - enc_ss = state.PQRenc_ss // added for KEM #2 ) enc_header = HENCRYPT(state.HKs, header) state.Ns += 1 @@ -162,6 +162,16 @@ def RatchetDecryptPQ2HE(state, enc_header, ciphertext, AD): state.Nr += 1 return DECRYPT(mk, ciphertext, CONCAT(AD, enc_header)) +// DecryptHeader is the same as in double ratchet specification +def DecryptHeader(state, enc_header): + header = HDECRYPT(state.HKr, enc_header) + if header != None: + return header, False + header = HDECRYPT(state.NHKr, enc_header) + if header != None: + return header, True + raise Error() + def DHRatchetPQ2HE(state, header): state.PN = state.Ns state.Ns = 0 @@ -170,16 +180,16 @@ def DHRatchetPQ2HE(state, header): state.HKr = state.NHKr state.DHRr = header.dh // save new encapsulation key from header - state.PQRr = header.encaps + state.PQRr = header.kem // decapsulate shared secret from header - KEM #2 - ss = PQKEM-DEC(state.PQRs.decaps, header.enc_ss) + ss = PQKEM-DEC(state.PQRs.private, header.ct) // use decapsulated shared secret with receiving ratchet state.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss) state.DHRs = GENERATE_DH() // below is added for KEM state.PQRs = GENERATE_PQKEM() // generate new PQ key pair state.PQRss = random // shared secret for KEM - state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret KEM #1 + state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret KEM #1 // above is added for KEM // use new shared secret with sending ratchet state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || state.PQRss) @@ -201,7 +211,7 @@ The main downside is the absense of performance-efficient implementation for aar ## Implementation considerations for SimpleX Chat -As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality) or sending additional messages. +As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality), or sending additional messages, or compressing the current JSON encoding, e.g. with zstd algorithm. That might be the primary reason why this scheme was not adopted by Signal, as it would have resulted in substantial traffic growth – to the best of our knowledge, Signal messages are not padded to a fixed size. @@ -209,6 +219,8 @@ Sharing the initial keys in case of SimpleX Chat it is equivalent to sharing the It is possible to postpone sharing the encapsulation key until the first message from Alice (confirmation message in SMP protocol), the party sending connection request. The upside here is that the invitation link size would not increase. The downside is that the user profile shared in this confirmation will not be encrypted with PQ-resistant algorithm. To mitigate it, the hadnshake protocol can be modified to postpone sending the user profile until the second message from Alice (HELLO message in SMP protocol). +Another consideration is pairwise ratchets in groups. Key generation in sntrup761 is quite slow - on slow devices it can probably be as slow as 10 keys per second, so using this primitive in groups larger than 10 members would result in slow performance. An option could be not to use ratchets in groups at all, but that would result in the lack of protection in small groups that simply combine multiple devices of 1-3 people. So a better option would be to support dynamically adding and removing sntrup761 keys for pairwise ratchets in groups, which means that when sending each message a boolean flag needs to be passed whether to use PQ KEM or not. + ## Summary If chosen PQ KEM proves secure against quantum computer attacks, then the proposed augmented double ratchet will also be secure against quantum computer attack, including break-in recovery property, while keeping deniability and forward secrecy, because the [same proof](https://eprint.iacr.org/2016/1013.pdf) as for double ratchet algorithm would hold here, provided KEM is secure. diff --git a/docs/rfcs/2023-11-21-inactive-group-members.md b/docs/rfcs/2023-11-21-inactive-group-members.md index 66e58848af..12333855a8 100644 --- a/docs/rfcs/2023-11-21-inactive-group-members.md +++ b/docs/rfcs/2023-11-21-inactive-group-members.md @@ -108,3 +108,33 @@ Sending member builds messages history starting starting from requested/remember \*** Same XGrpMsgHistory protocol event could be sent by host to new members, after sending introductions. + +--- + +Update 2024-02-12: + +### Group "pings" + +Alternatively to tracking unanswered messages counts per member, which is complex and in some cases as discussed above ineffective, group members could periodically send group wide pings indicating their active presence. + +```haskell +XGrpPing :: ChatMsgEvent 'Json +``` + +Members track: + +- inactive flag (as above - set on QUOTA errors as well) +- last_snd_ts on group +- last_rcv_ts on group member + +Clients run a worker process for checking last_snd_ts in each of their groups, and send pings to groups on a periodic basis. + +- part of cleanup manager or separate process? +- on each worker step, for each group matching criteria to send ping, send ping with a random delay to reduce correlation between groups (spawn a separate thread with a random delay for each group) +- criteria for sending ping: last_snd_ts earlier than group_ping_interval ago +- configure group_ping_interval to, for example, 23 hours (so that if user opens app each day at same time client will match criteria to send pings daily) + +Clients receiving pings: + +- update last_rcv_ts +- when sending a message to group, check only for timestamp difference (no unanswered snd msg count logic as above) diff --git a/docs/rfcs/2024-02-12-database-migration.md b/docs/rfcs/2024-02-12-database-migration.md new file mode 100644 index 0000000000..7d4dcc8d01 --- /dev/null +++ b/docs/rfcs/2024-02-12-database-migration.md @@ -0,0 +1,130 @@ +# Database migration and other operations + +## Problem + +Migrating database to another device is very complex for most people - it is multi-step and error-prone. + +In addition to that, any database operation is confusing as it requires stopping chat. + +## Solution + +Let users migrate database to another device by scanning QR code. + +Simplify other database operations by removing the need to compose multiple actions, stop chat, etc. + +To support it, we already added the way to represent the file as link/QR code (by uploading file description to XFTP, and supporting "recursive" descriptions). + +There will be these actions in the Database settings (no stop/start chat toggle): + +- Export database. +- Import database. +- Migrate from another device. +- Set passphrase (or Change passphrase if it was set). +- Remove passphrase from device / Store passphrase on the device. + +Stop chat toggle will be moved to dev tools. + +Migrate to another device will be available in the top part of the settings, + + +### Database export + +Currently, it requires these steps: + +1. Open Database settings. +2. Stop chat (many users don't understand it). +3. Tap "Export database" in settings. +4. Look at the alert that says "set passphrase". +5. Tap Ok. +6. Tap Set passphrase. +7. Enter passphrase and confirm. +8. Exit back to Database settings. +9. Tap "Export database" again. +10. Choose file location and save. +11. Tap "New archive". +12. Remove exported archive. + +These steps are all very confusing, and if they were to stay as composable steps, they belong to dev tools. + +Instead we can offer these simple steps: + +1. Open Database settings. +2. Tap "Export database". +3. Alert will appear saying: "The chat will stop, and you will need to set (or verify) database passphrase. Continue?". +4. Tap "Ok". +5. Enter passphrase and confirm in the window that appears (or verify if it was already set, possibly allowing to skip this step). +7. Choose whether to save file or upload to XFTP and generate link. +8. File: choose file location and save. + Link: show upload progress and then show link to copy. +9. Alert will appear saying: "Database exported!", exported archive will be automatically removed. + +So instead of asking users to understand the required sequence of steps, we will guide them through the required process. + +### Database import + +1. Open Database settings. +2. Tap "Import database". +3. Alert will appear saying: "The chat will stop, you will import?". +4. File: choose file location and tap "Import". + Link: paste link (or scan QR code) and tap "Import". +5. Confirm to replace database. +6. Start chat automatically once imported. + +### Set or change passphrase + +1. Open Database settings. +2. Tap "Set passphrase" or "Change passphrase" (if it was set). +3. Choose - store passphrase on the device or enter it every time the app starts. + +### Remove / store passphrase from the device + +To remove: + +1. Open Database settings. +2. Tap "Remove passphrase". +3. Confirm to remove passphrase in alert. +4. Button is replaced with Store. + +To store: + +1. Open Database settings. +2. Tap "Store passphrase". +3. Enter current passphrase - it is verified. +4. Button is replaced with Remove. + +### Migrate database to / from another device + +#### User experience + +This function is the most important, and it should be available from the main section in settings, under "Use from desktop" (or under "Link from mobile" on desktop). + +On the receiving device it will be available via Database settings and also on the Onboarding screen, so users don't need to create a profile. + +The steps are: + +On the source device: +1. Tap "Migrate to another device". +2. The chat will stop showing "Stopping chat" to the user. +3. If passphrase was: + - not set: make user set it in a separate screen. + - set: make user verify it. +5. Show the screen to confirm the upload. +6. Upload progress (full screen circular progress showing the share, with the %s and total/uploaded size) will be shown. +7. Once upload is completed, show QR code (with option to copy link), instruct to tap "Migrate from another device" on the receiving device. + +On the receiving device: +2. Tap "Migrate from another device". +2. The chat will stop (if not from Onboarding) showing "Stopping chat" to the user. +4. Scan QR code (with option to paste link on desktop only). +5. Show similar download progress, but probably in reversed direction - design TBC. +6. Once download is completed, show "Replace the current database" (if not from Onboarding). +7. Once imported, start chat automatically, and once chat started show "Tap remove database on source device". + +On the source device: +1. Tap "Remove database" on the showing screen (this should also remove uploaded file). + +#### Implementation considerations + +The latest updates allow uploading and downloading XFTP files without messages. + +So to perform the above, the second instance of the chat controller will be required, that probably requires supporting additional/optional chat controller parameter in the APIs that are required for that process. diff --git a/docs/rfcs/2024-02-13-inactive-group-members-2.md b/docs/rfcs/2024-02-13-inactive-group-members-2.md new file mode 100644 index 0000000000..6f7fc2f377 --- /dev/null +++ b/docs/rfcs/2024-02-13-inactive-group-members-2.md @@ -0,0 +1,38 @@ +# Inactive group members (simplified) + +[Original doc](./2023-11-21-inactive-group-members.md) + +## Problem + +Groups traffic is higher than necessary due to sending messages to inactive group members. + +## Solution + +### Improve connection deletion + +- When leaving or deleting group, batch db operations to optimize performance. +- In agent - fix race where connection can be deleted while it has remaining pending messages. + - Current agent logic is to immediately delete connection if it has no rcv queues left. + - Simplest should be to make a smart version of `deleteConn` for this improvement, checking `snd_messages` table for remaining messages, and keep connection around in case there are. + - While this may improve delivery of group leave and delete messages, it may as well have undesirable side effects for other use cases, as any pending messages will be sent prior to deleting connection. For example, user sends several messages on bad network, decides to delete contact, messages are still delivered when user is on good network before deletion, even though this contradicts user's intent and messages hadn't left user's device at the time of deletion. Considering this race when it happens is identical to simply leaving groups by deleting app, or deleting user profile only locally, it may be a bad idea to affect regular contact deletion for this use case. + +### Track member inactivity + +- Mark members as inactive on QUOTA errors, reset as active on QCONT + - track `group_members.inactive` flag per group member + - on SMP.QUOTA error agent to notify client with ERR CONN QUOTA (new ConnectionErrorType QUOTA) + - on receiving QCONT agent to notify client (new event) + - apart from QCONT, reset on any message or receipt +- Don't send to member if inactive + - don't send only content messages (x.msg.new, etc.) and always send messages altering group state? + - or don't send any messages? +- Track number of skipped messages per member and first skipped message + - count `group_members.skipped_msg_cnt` + - only count messages of same types/criteria that are included into history + - track `group_members.skipped_first_shared_msg_id` (only content or including service messages?) +- Send XGrpMsgSkipped before next message + - check `skipped_msg_cnt` > 0 and `skipped_first_shared_msg_id` is not null to only send once, reset after sending + +```haskell +XGrpMsgSkipped :: SharedMsgId -> Int64 -> ChatMsgEvent 'Json -- from, count +``` diff --git a/docs/rfcs/2024-02-19-settings.md b/docs/rfcs/2024-02-19-settings.md new file mode 100644 index 0000000000..002e381ce2 --- /dev/null +++ b/docs/rfcs/2024-02-19-settings.md @@ -0,0 +1,60 @@ +# Migrating app settings to another device + +## Problem + +This is related to simplified database migration UX in the [previous RFC](./2024-02-12-database-migration.md). + +Currently, when database is imported after the onboarding is complete, users can configure the app prior to the import. + +Some of the settings are particularly important for privacy and security: +- SOCKS proxy settings +- Automatic image etc. downloads +- Link previews + +With the new UX, the chat will start automatically, without giving users a chance to configure the app. That means that we have to migrate settings to a new device as well, as part of the archive. + +## Solution + +There are several possible approaches: +- put settings to the database via the API +- save settings as some file with cross-platform format (e.g. JSON or YAML or properties used on desktop). + +The second approach seems much simpler than maintaining the settings in the database. + +If we save a file, then there are two options: +- native apps maintain cross-platform schemas for this file, support any JSON and parse it in a safe way (so that even invalid or incorrect JSON - e.g., array instead of object - or invalid types in some properties do not cause the failure of properties that are correct). +- this schema and type will be maintained in the core library, that will be responsible for storing and reading the settings and passing to native UI as correct record of a given type. + +The downside of the second approach is that addition of any property that needs to be migrated will have to be done on any change in either of the platforms. The downside of the first approach is that neither app platform will be self-sufficient any more, and not only iOS/Android would have to take into account code, but also each other code. + +If we go with the second approach, there will be these types: + +```haskell +data AppSettings = AppSettings + { networkConfig :: NetworkConfig, -- existing type in Haskell and all UIs + privacyConfig :: PrivacyConfig -- new type, etc. + -- ... additional properties after the initial release should be added as Maybe, as all extensions + } + +data ArchiveConfig = ArchiveConfig + { -- existing properties + archivePath :: FilePath, + disableCompression :: Maybe Bool, + parentTempDirectory :: Maybe FilePath, + -- new property + appSettings :: AppSettings + -- for export, these settings will contain the settings passed from the UI and will be saved to JSON file as simplex_v1_settings.json in the archive + -- for import, these settings will contain the defaults that will be used if some property or subproperty is missing in JSON + } + +-- importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError] -- current type +importArchive :: ChatMonad m => ArchiveConfig -> m ArchiveImportResult -- new type + +-- | CRArchiveImported {archiveErrors :: [ArchiveError]} -- current type + | CRArchiveImported {importResult :: ArchiveImportResult} -- new type + +data ArchiveImportResult = ArchiveImportResult + { archiveErrors :: [ArchiveError], + appSettings :: Maybe AppSettings + } +``` \ No newline at end of file diff --git a/docs/rfcs/2024-02-28-pq-integration.md b/docs/rfcs/2024-02-28-pq-integration.md new file mode 100644 index 0000000000..010f8105b7 --- /dev/null +++ b/docs/rfcs/2024-02-28-pq-integration.md @@ -0,0 +1,98 @@ +# PQ integration in chat + +## Problem + +- Group size not known when joining +- Communicate intent and current state of each conversation + +## Solution + +### Group size not known when joining + +- Add to XGrpInv GroupInvitation + - pros: easy + - cons: size can change before joining, but can ignore as it's still a good estimate + +or + +- Send before introductions + - new protocol message + - XGrpIntro :: GrpIntro -> ChatMsgEvent 'Json -- (GrpIntro is a box type with Int, for possible extension) + - or put into XGrpInfo + - XGrpInfo :: GroupProfile -> GroupStats -> ChatMsgEvent 'Json -- GroupData? + - can update profile between invitation if it happened before joining + - can later add logic to "verify" stats? + - may be over-complicated until since there "supposed" use cases are out-of-scope / not planned / not known + +- What should be default if it's not known? (e.g. admin has older version) + - On -> then off when member count reaches 20? + +### Communicate intent and current state of each conversation + +- Current state items + - RCEPQEnabled (see #3845) both for direct conversation and per member (regular event items, merged in UI) + - created when PQ changes for contact/member (e.g. received from agent on MsgMeta / SENT) + - experimental toggle is planned: it doesn't affect contacts/members with already enabled PQ + - contact enabled PQ always overrides toggle (can't downgrade) + - member enabled PQ also overrides, but can downgrade if group size increases past 20 + +- New items communicating state of e2e encryption in conversation + - should be well pronounced in UI, not merged + - should always say that conversation is e2e encrypted + - in direct chats: + - reflect actual state of PQ at the time of creation + - created during connection handshake when receiving first info about PQ in MsgMeta / some other event (TBC agent api) + - will not update if state changes (e.g. upgrades), as toggle is planned to be removed, PQ can't be downgraded, all will support soon + - flag in contacts table "e2e_info_created" to only create it once? + - should create for legacy contacts or not? + - in groups: + - reflect intent (should say "PQ will be used for members who support") based on number of members (see above) + toggle + - created at the same time as feature items? race with history may be possible, but we don't observe it? need to double check or ignore + - if based on XGrpInv GroupInvitation (first option above), can create item even before joining + - also will not update (as conversation progresses and it will scroll far up anyway) even if group size changes and it's disabled + - flag in groups table "e2e_info_created" to only create it once? and state is only reflected by RCEPQEnabled items? + - or create new such item if group size increases and PQ is off / decreases and PQ is on? + - "large group" thresholds have to different for group size increasing (e.g. 20) and decreases (e.g. 15), to avoid constant switching on the border. + +- Example texts for "e2e encryption info" chat items: + - for direct conversations: + - with PQ (and also forward a couple releases when more clients have upgraded): + ``` + Messages in this conversation are end-to-end encrypted. + Post-quantum encryption is enabled. + ``` + - no PQ (experimental toggle disabled): + ``` + -//- (e2ee) + Post-quantum encryption is not enabled. [Also possibly:] Enabling post-quantum encryption in experimental settings will enable it in this conversation if your contact supports it. + ``` + - no PQ (experimental toggle enabled): + ``` + -//- + Post-quantum encryption will be enabled when your contact upgrades. + ``` + "upgrades" / "supports it" / "starts to support it" + - can be of different color, but seems unnecessary + - created once at the start of conversation + - created once for old contacts when PQ is enabled? + - for groups: + - with PQ (small group; toggle enabled or later, as above): + ``` + -//- + Post-quantum encryption will be enabled for members who support it. + ``` + can remove qualification later when most clients have upgraded + - no PQ (large group): + ``` + -//- + Post-quantum encryption is not enabled (group is too large). + ``` + - created each time group changes between small/large, or once? + - created for old groups when experimental toggle is first turned on, and first message is received? + + +- Save PQ encryption on chat items (messages)? + - in meta for direct + group rcv + - in group_snd_item_statuses for group snd? + - display in chat item details (info) + - may be overkill if aggressive upgrade strategy is planned diff --git a/package.yaml b/package.yaml index 1d44ae8a0c..a4df72cda8 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.5.3.0 +version: 5.6.0.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/packages/simplex-chat-client/typescript/src/command.ts b/packages/simplex-chat-client/typescript/src/command.ts index b49a3605b6..bd17a55926 100644 --- a/packages/simplex-chat-client/typescript/src/command.ts +++ b/packages/simplex-chat-client/typescript/src/command.ts @@ -12,7 +12,6 @@ export type ChatCommand = | APIStopChat | SetTempFolder | SetFilesFolder - | APISetXFTPConfig | SetIncognito | APIExportArchive | APIImportArchive @@ -112,7 +111,6 @@ type ChatCommandTag = | "apiStopChat" | "setTempFolder" | "setFilesFolder" - | "apiSetXFTPConfig" | "setIncognito" | "apiExportArchive" | "apiImportArchive" @@ -242,15 +240,6 @@ export interface SetFilesFolder extends IChatCommand { filePath: string } -export interface APISetXFTPConfig extends IChatCommand { - type: "apiSetXFTPConfig" - config?: XFTPFileConfig -} - -export interface XFTPFileConfig { - minFileSize: number -} - export interface SetIncognito extends IChatCommand { type: "setIncognito" incognito: boolean @@ -707,8 +696,6 @@ export function cmdString(cmd: ChatCommand): string { return `/_temp_folder ${cmd.tempFolder}` case "setFilesFolder": return `/_files_folder ${cmd.filePath}` - case "apiSetXFTPConfig": - return `/_xftp ${onOff(cmd.config)}${maybeJSON(cmd.config)}` case "setIncognito": return `/incognito ${onOff(cmd.incognito)}` case "apiExportArchive": diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 7550cdb87b..174db533a3 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -103,7 +103,7 @@ build() { for arch in $arches; do - tag_full="$(git tag --points-at HEAD)" + tag_full="$(git tag --points-at HEAD | head -n1)" tag_version="${tag_full%%-*}" if [ "$arch" = "armv7a" ] && [ -n "$tag_full" ] ; then diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 67e6d21977..c7678d1201 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e64b6cba4b7e4107f78ae596ab2a6a28ef24ff78" = "0fxgklq65bh2f4kx36vjicdxqmi88m91xs601hm81v5pn6kk0ppd"; + "https://github.com/simplex-chat/simplexmq.git"."78eb4f764fd52385a8687d2605a0e6edc1808431" = "09nmrk65nbn6mp8mwwk09d5zx9cgm38i6xgmndk6jzlhnfl5fiy6"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8035892414..26300dc146 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.5.3.0 +version: 5.6.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -26,6 +26,7 @@ flag swift library exposed-modules: Simplex.Chat + Simplex.Chat.AppSettings Simplex.Chat.Archive Simplex.Chat.Bot Simplex.Chat.Bot.KnownContacts @@ -133,6 +134,10 @@ library Simplex.Chat.Migrations.M20240104_members_profile_update Simplex.Chat.Migrations.M20240115_block_member_for_all Simplex.Chat.Migrations.M20240122_indexes + Simplex.Chat.Migrations.M20240214_redirect_file_id + Simplex.Chat.Migrations.M20240222_app_settings + Simplex.Chat.Migrations.M20240226_users_restrict + Simplex.Chat.Migrations.M20240228_pq Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -148,6 +153,7 @@ library Simplex.Chat.Remote.Transport Simplex.Chat.Remote.Types Simplex.Chat.Store + Simplex.Chat.Store.AppSettings Simplex.Chat.Store.Connections Simplex.Chat.Store.Direct Simplex.Chat.Store.Files diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index dcd392629c..56914d2d9d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -6,6 +6,7 @@ {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -21,6 +22,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader +import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A @@ -67,6 +69,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Remote import Simplex.Chat.Remote.Types import Simplex.Chat.Store +import Simplex.Chat.Store.AppSettings import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files @@ -79,9 +82,10 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.Chat.Util (encryptFile, shuffle) -import Simplex.FileTransfer.Client.Main (maxFileSize) +import Simplex.FileTransfer.Client.Main (maxFileSize, maxFileSizeHard) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) -import Simplex.FileTransfer.Description (ValidFileDescription, gb, kb, mb) +import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription) +import qualified Simplex.FileTransfer.Description as FD import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentClientStore, getAgentWorkersDetails, getAgentWorkersSummary, temporaryAgentError) @@ -94,14 +98,18 @@ import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Client (defaultNetworkConfig) +import Simplex.Messaging.Compression (withCompressCtx) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKNoPQ, pattern IKPQOff, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) +import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) import qualified Simplex.Messaging.Protocol as SMP +import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util @@ -142,8 +150,6 @@ defaultChatConfig = xftpDescrPartSize = 14000, inlineFiles = defaultInlineFilesConfig, autoAcceptFileSize = 0, - xftpFileConfig = Just defaultXFTPFileConfig, - tempDir = Nothing, showReactions = False, showReceipts = False, logLevel = CLLImportant, @@ -171,7 +177,10 @@ _defaultSMPServers = ] _defaultNtfServers :: [NtfServer] -_defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"] +_defaultNtfServers = + [ "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion", + "ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion" + ] maxImageSize :: Integer maxImageSize = 261120 * 2 -- auto-receive on mobiles @@ -201,7 +210,7 @@ newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Boo newChatController ChatDatabase {chatStore, agentStore} user - cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir, deviceNameForRemote} + cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, deviceNameForRemote} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, deviceName, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} @@ -236,9 +245,9 @@ newChatController chatActivated <- newTVarIO True showLiveItems <- newTVarIO False encryptLocalFiles <- newTVarIO False - userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg - tempDirectory <- newTVarIO tempDir + tempDirectory <- newTVarIO Nothing contactMergeEnabled <- newTVarIO True + pqExperimentalEnabled <- newTVarIO PQSupportOff pure ChatController { firstTime, @@ -272,10 +281,10 @@ newChatController chatActivated, showLiveItems, encryptLocalFiles, - userXFTPFileConfig, tempDirectory, logFilePath = logFile, - contactMergeEnabled + contactMergeEnabled, + pqExperimentalEnabled } where configServers :: DefaultAgentServers @@ -362,7 +371,7 @@ subscribeUsers onlyNeeded users = do subscribe vr us subscribe vr us' where - subscribe :: VersionRange -> [User] -> m () + subscribe :: (PQSupport -> VersionRangeChat) -> [User] -> m () subscribe vr = mapM_ $ runExceptT . subscribeUserConnections vr onlyNeeded Agent.subscribeConnections startFilesToReceive :: forall m. ChatMonad' m => [User] -> m () @@ -440,10 +449,11 @@ parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse -processChatCommand cmd = chatVersionRange >>= (`processChatCommand'` cmd) +processChatCommand cmd = + chatVersionRange >>= (`processChatCommand'` cmd) {-# INLINE processChatCommand #-} -processChatCommand' :: forall m. ChatMonad m => VersionRange -> ChatCommand -> m ChatResponse +processChatCommand' :: forall m. ChatMonad m => (PQSupport -> VersionRangeChat) -> ChatCommand -> m ChatResponse processChatCommand' vr = \case ShowActiveUser -> withUser' $ pure . CRActiveUser CreateActiveUser NewUser {profile, sameServers, pastTimestamp} -> do @@ -582,13 +592,22 @@ processChatCommand' vr = \case createDirectoryIfMissing True rf chatWriteVar remoteHostsFolder $ Just rf ok_ - APISetXFTPConfig cfg -> do - asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) - ok_ APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_ - SetContactMergeEnabled onOff -> do - asks contactMergeEnabled >>= atomically . (`writeTVar` onOff) - ok_ + SetContactMergeEnabled onOff -> chatWriteVar contactMergeEnabled onOff >> ok_ + APISetPQEncryption onOff -> chatWriteVar pqExperimentalEnabled onOff >> ok_ + APISetContactPQ ctId pqEnc -> withUser $ \user -> do + ct@Contact {activeConn} <- withStore $ \db -> getContact db vr user ctId + case activeConn of + Just conn@Connection {connId, pqSupport, pqEncryption} + | pqEncryption == pqEnc -> pure $ CRContactPQAllowed user ct pqEnc + | otherwise -> do + let pqSup = PQSupport $ pqEnc == PQEncOn || pqSupport == PQSupportOn + conn' = conn {pqSupport = pqSup, pqEncryption = pqEnc} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + withStore' $ \db -> updateConnSupportPQ db connId pqSup pqEnc + pure $ CRContactPQAllowed user ct' pqEnc + Nothing -> throwChatError $ CEContactNotActive ct + SetContactPQ cName pqEnc -> withContactName cName (`APISetContactPQ` pqEnc) APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do ts <- liftIO getCurrentTime @@ -598,8 +617,11 @@ processChatCommand' vr = \case fileErrs <- importArchive cfg setStoreChanged pure $ CRArchiveImported fileErrs + APISaveAppSettings as -> withStore' (`saveAppSettings` as) >> ok_ + APIGetAppSettings platformDefaults -> CRAppSettings <$> withStore' (`getAppSettings` platformDefaults) APIDeleteStorage -> withStoreChanged deleteStorage APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg + TestStorageEncryption key -> sqlCipherTestKey key >> ok_ ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) SlowSQLQueries -> do @@ -621,7 +643,7 @@ processChatCommand' vr = \case APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do - directChat <- withStore (\db -> getDirectChat db user cId pagination search) + directChat <- withStore (\db -> getDirectChat db vr user cId pagination search) pure $ CRApiChat user (AChat SCTDirect directChat) CTGroup -> do groupChat <- withStore (\db -> getGroupChat db vr user cId pagination search) @@ -645,53 +667,27 @@ processChatCommand' vr = \case memStatuses -> pure $ Just $ map (uncurry MemberDeliveryStatus) memStatuses _ -> pure Nothing pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses} - APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of + APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user -> withChatLock "sendMessage" $ case cType of CTDirect -> do - ct@Contact {contactId, contactUsed} <- withStore $ \db -> getContact db user chatId + ct@Contact {contactId, contactUsed} <- withStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgNew_ unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct if isVoice mc && not (featureAllowed SCFVoice forUser ct) then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFVoice)) else do - (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct + (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer ct timed_ <- sndContactCITimed live ct itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ - (msg@SndMessage {sharedMsgId}, _) <- sendDirectContactMessage ct (XMsgNew msgContainer) + (msg, _) <- sendDirectContactMessage user ct (XMsgNew msgContainer) ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live - case ft_ of - Just ft@FileTransferMeta {fileInline = Just IFMSent} -> - sendDirectFileInline ct ft sharedMsgId - _ -> pure () forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) where - setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) + setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd)) setupSndFileTransfer ct = forM file_ $ \file -> do - (fileSize, fileMode) <- checkSndFile mc file 1 - case fileMode of - SendFileSMP fileInline -> smpSndFileTransfer file fileSize fileInline - SendFileXFTP -> xftpSndFileTransfer user file fileSize 1 $ CGContact ct - where - smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled - smpSndFileTransfer (CryptoFile file Nothing) fileSize fileInline = do - subMode <- chatReadVar subscriptionMode - (agentConnId_, fileConnReq) <- - if isJust fileInline - then pure (Nothing, Nothing) - else bimap Just Just <$> withAgent (\a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode) - let fileName = takeFileName file - fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} - chSize <- asks $ fileChunkSize . config - withStore $ \db -> do - ft@FileTransferMeta {fileId} <- liftIO $ createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize subMode - fileStatus <- case fileInline of - Just IFMSent -> createSndDirectInlineFT db ct ft $> CIFSSndTransfer 0 1 - _ -> pure CIFSSndStored - let fileSource = Just $ CF.plain file - ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol = FPSMP} - pure (fileInvitation, ciFile, ft) + fileSize <- checkSndFile file + xftpSndFileTransfer user file fileSize 1 $ CGContact ct prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> m (MsgContainer, Maybe (CIQuote 'CTDirect)) prepareMsg fInv_ timed_ = case quotedItemId_ of Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) @@ -718,80 +714,41 @@ processChatCommand' vr = \case | isVoice mc && not (groupFeatureAllowed SGFVoice gInfo) = notAllowedError GFVoice | not (isVoice mc) && isJust file_ && not (groupFeatureAllowed SGFFiles gInfo) = notAllowedError GFFiles | otherwise = do - (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer g (length $ filter memberCurrent ms) + (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer g (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainer, quotedItem_) <- prepareGroupMsg user gInfo mc quotedItemId_ fInv_ timed_ live - (msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) + (msg, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live withStore' $ \db -> forM_ sentToMembers $ \GroupMember {groupMemberId} -> createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew - mapM_ (sendGroupFileInline ms sharedMsgId) ft_ forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) notAllowedError f = pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText f)) - setupSndFileTransfer :: Group -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) - setupSndFileTransfer g@(Group gInfo _) n = forM file_ $ \file -> do - (fileSize, fileMode) <- checkSndFile mc file $ fromIntegral n - case fileMode of - SendFileSMP fileInline -> smpSndFileTransfer file fileSize fileInline - SendFileXFTP -> xftpSndFileTransfer user file fileSize n $ CGGroup g - where - smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled - smpSndFileTransfer (CryptoFile file Nothing) fileSize fileInline = do - let fileName = takeFileName file - fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline, fileDescr = Nothing} - fileStatus = if fileInline == Just IFMSent then CIFSSndTransfer 0 1 else CIFSSndStored - chSize <- asks $ fileChunkSize . config - withStore' $ \db -> do - ft@FileTransferMeta {fileId} <- createSndGroupFileTransfer db userId gInfo file fileInvitation chSize - let fileSource = Just $ CF.plain file - ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol = FPSMP} - pure (fileInvitation, ciFile, ft) - sendGroupFileInline :: [GroupMember] -> SharedMsgId -> FileTransferMeta -> m () - sendGroupFileInline ms sharedMsgId ft@FileTransferMeta {fileInline} = - when (fileInline == Just IFMSent) . forM_ ms $ \m -> - processMember m `catchChatError` (toView . CRChatError (Just user)) - where - processMember m@GroupMember {activeConn = Just conn@Connection {connStatus}} = - when (connStatus == ConnReady || connStatus == ConnSndReady) $ do - void . withStore' $ \db -> createSndGroupInlineFT db m conn ft - sendMemberFileInline m conn ft sharedMsgId - processMember _ = pure () + setupSndFileTransfer :: Group -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd)) + setupSndFileTransfer g n = forM file_ $ \file -> do + fileSize <- checkSndFile file + xftpSndFileTransfer user file fileSize n $ CGGroup g CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" where - xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - xftpSndFileTransfer user 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 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 fileSource = Just $ CryptoFile filePath cfArgs - ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP} + xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd) + xftpSndFileTransfer user file fileSize n contactOrGroup = do + (fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n $ Just contactOrGroup case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> - withStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft fileDescr + withStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = when ((connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn)) $ withStore' $ - \db -> createSndFTDescrXFTP db user (Just m) conn ft fileDescr + \db -> createSndFTDescrXFTP db user (Just m) conn ft dummyFileDescr saveMemberFD _ = pure () - pure (fInv, ciFile, ft) - unzipMaybe3 :: Maybe (a, b, c) -> (Maybe a, Maybe b, Maybe c) - unzipMaybe3 (Just (a, b, c)) = (Just a, Just b, Just c) - unzipMaybe3 _ = (Nothing, Nothing, Nothing) + pure (fInv, ciFile) APICreateChatItem folderId (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user -> do forM_ quotedItemId_ $ \_ -> throwError $ ChatError $ CECommandError "not supported" nf <- withStore $ \db -> getNoteFolder db user folderId @@ -810,7 +767,7 @@ processChatCommand' vr = \case pure . CRNewChatItem user $ AChatItem SCTLocal SMDSnd (LocalChat nf) ci APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> withChatLock "updateChatItem" $ case cType of CTDirect -> do - ct@Contact {contactId} <- withStore $ \db -> getContact db user chatId + ct@Contact {contactId} <- withStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ cci <- withStore $ \db -> getDirectCIWithReactions db user ct itemId case cci of @@ -820,7 +777,7 @@ processChatCommand' vr = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) ci' <- withStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ @@ -868,12 +825,12 @@ processChatCommand' vr = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of CTDirect -> do - (ct, CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteDirectCI user ct ci True False (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do assertDirectAllowed user MDSnd ct XMsgDel_ - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgDel itemSharedMId Nothing) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XMsgDel itemSharedMId Nothing) if featureAllowed SCFFullDelete forUser ct then deleteDirectCI user ct ci True False else markDirectCIDeleted user ct ci msgId True =<< liftIO getCurrentTime @@ -905,7 +862,7 @@ processChatCommand' vr = \case (_, _) -> throwChatError CEInvalidChatItemDelete APIChatItemReaction (ChatRef cType chatId) itemId add reaction -> withUser $ \user -> withChatLock "chatItemReaction" $ case cType of CTDirect -> - withStore (\db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId) >>= \case + withStore (\db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId) >>= \case (ct, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do unless (featureAllowed SCFReactions forUser ct) $ throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) @@ -913,7 +870,7 @@ processChatCommand' vr = \case throwChatError (CECommandError "reaction not allowed - chat item has no content") rs <- withStore' $ \db -> getDirectReactions db ct itemSharedMId True checkReactionAllowed rs - (SndMessage {msgId}, _) <- sendDirectContactMessage ct $ XMsgReact itemSharedMId Nothing reaction add + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct $ XMsgReact itemSharedMId Nothing reaction add createdAt <- liftIO getCurrentTime reactions <- withStore' $ \db -> do setDirectReaction db ct itemSharedMId True reaction add msgId createdAt @@ -982,7 +939,7 @@ processChatCommand' vr = \case APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withStore $ \db -> do - ct <- getContact db user chatId + ct <- getContact db vr user chatId liftIO $ updateContactUnreadChat db user ct unreadChat ok user CTGroup -> do @@ -998,18 +955,19 @@ processChatCommand' vr = \case _ -> pure $ chatCmdError (Just user) "not supported" APIDeleteChat (ChatRef cType chatId) notify -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct <- withStore $ \db -> getContact db user chatId + ct <- withStore $ \db -> getContact db vr user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct withChatLock "deleteChat direct" . procCmd $ do - deleteFilesAndConns user filesInfo - when (contactReady ct && contactActive ct && notify) $ - void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) - contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct) - deleteAgentConnectionsAsync user contactConnIds + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo + let doSendDel = contactReady ct && contactActive ct && notify + when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchChatError` const (pure ()) + contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db vr userId ct) + deleteAgentConnectionsAsync' user contactConnIds doSendDel -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct - withStore' $ \db -> deleteContact db user ct + withStore $ \db -> deleteContact db user ct pure $ CRContactDeleted user ct CTContactConnection -> withChatLock "deleteChat contactConnection" . procCmd $ do conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withStore $ \db -> getPendingContactConnection db userId chatId @@ -1024,10 +982,12 @@ processChatCommand' vr = \case unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo withChatLock "deleteChat group" . procCmd $ do - deleteFilesAndConns user filesInfo - when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo + let doSendDel = memberActive membership && isOwner + when doSendDel . void $ sendGroupMessage' user gInfo members XGrpDel deleteGroupLinkIfExists user gInfo - deleteMembersConnections user members + deleteMembersConnections' user members doSendDel updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure () -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) @@ -1035,46 +995,49 @@ processChatCommand' vr = \case withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members withStore' $ \db -> deleteGroup db user gInfo let contactIds = mapMaybe memberContactId members - deleteAgentConnectionsAsync user . concat =<< mapM deleteUnusedContact contactIds + (errs1, (errs2, connIds)) <- second unzip . partitionEithers <$> withStoreBatch (\db -> map (deleteUnusedContact db) contactIds) + let errs = errs1 <> mapMaybe (fmap ChatErrorStore) errs2 + unless (null errs) $ toView $ CRChatErrors (Just user) errs + deleteAgentConnectionsAsync user $ concat connIds pure $ CRGroupDeletedUser user gInfo where - deleteUnusedContact :: ContactId -> m [ConnId] - deleteUnusedContact contactId = - (withStore (\db -> getContact db user contactId) >>= delete) - `catchChatError` (\e -> toView (CRChatError (Just user) e) $> []) + deleteUnusedContact :: DB.Connection -> ContactId -> IO (Either ChatError (Maybe StoreError, [ConnId])) + deleteUnusedContact db contactId = runExceptT . withExceptT ChatErrorStore $ do + ct <- getContact db vr user contactId + ifM + ((directOrUsed ct ||) . isJust <$> liftIO (checkContactHasGroups db user ct)) + (pure (Nothing, [])) + (getConnections ct) where - delete ct - | directOrUsed ct = pure [] - | otherwise = - withStore' (\db -> checkContactHasGroups db user ct) >>= \case - Just _ -> pure [] - Nothing -> do - conns <- withStore' $ \db -> getContactConnections db userId ct - withStore' (\db -> setContactDeleted db user ct) - `catchChatError` (toView . CRChatError (Just user)) - pure $ map aConnId conns + getConnections :: Contact -> ExceptT StoreError IO (Maybe StoreError, [ConnId]) + getConnections ct = do + conns <- liftIO $ getContactConnections db vr userId ct + e_ <- (setContactDeleted db user ct $> Nothing) `catchStoreError` (pure . Just) + pure (e_, map aConnId conns) CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct <- withStore $ \db -> getContact db user chatId + ct <- withStore $ \db -> getContact db vr user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - deleteFilesAndConns user filesInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo withStore' $ \db -> deleteContactCIs db user ct pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) CTGroup -> do gInfo <- withStore $ \db -> getGroupInfo db vr user chatId filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo - deleteFilesAndConns user filesInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo withStore' $ \db -> deleteGroupCIs db user gInfo - membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo + membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo) CTLocal -> do nf <- withStore $ \db -> getNoteFolder db user chatId filesInfo <- withStore' $ \db -> getNoteFolderFileInfo db user nf withChatLock "clearChat local" . procCmd $ do - mapM_ (deleteFile user) filesInfo + deleteFilesLocally filesInfo withStore' $ \db -> deleteNoteFolderFiles db userId nf withStore' $ \db -> deleteNoteFolderCIs db user nf pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf) @@ -1097,7 +1060,7 @@ processChatCommand' vr = \case pure $ CRContactRequestRejected user cReq APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call - ct <- withStore $ \db -> getContact db user contactId + ct <- withStore $ \db -> getContact db vr user contactId assertDirectAllowed user MDSnd ct XCallInv_ if featureAllowed SCFCalls forUser ct then do @@ -1108,7 +1071,7 @@ processChatCommand' vr = \case dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair} callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} - (msg, _) <- sendDirectContactMessage ct (XCallInv callId invitation) + (msg, _) <- sendDirectContactMessage user ct (XCallInv callId invitation) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls @@ -1135,7 +1098,7 @@ processChatCommand' vr = \case offer = CallOffer {callType, rtcSession, callDhPubKey} callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey} aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0 - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallOffer callId offer) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XCallOffer callId offer) withStore' $ \db -> updateDirectChatItemsRead db user contactId $ Just (chatItemId, chatItemId) updateDirectChatItemView user ct chatItemId aciContent False $ Just msgId pure $ Just call {callState = callState'} @@ -1146,28 +1109,28 @@ processChatCommand' vr = \case CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do let callState' = CallNegotiated {localCallType, peerCallType, localCallSession = rtcSession, peerCallSession, sharedKey} aciContent = ACIContent SMDSnd $ CISndCall CISCallNegotiated 0 - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallAnswer callId CallAnswer {rtcSession}) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XCallAnswer callId CallAnswer {rtcSession}) updateDirectChatItemView user ct chatItemId aciContent False $ Just msgId pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState APISendCallExtraInfo contactId rtcExtraInfo -> -- any call party - withCurrentCall contactId $ \_ ct call@Call {callId, callState} -> case callState of + withCurrentCall contactId $ \user ct call@Call {callId, callState} -> case callState of CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do -- TODO update the list of ice servers in localCallSession - void . sendDirectContactMessage ct $ XCallExtra callId CallExtraInfo {rtcExtraInfo} + void . sendDirectContactMessage user ct $ XCallExtra callId CallExtraInfo {rtcExtraInfo} let callState' = CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} pure $ Just call {callState = callState'} CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do -- TODO update the list of ice servers in localCallSession - void . sendDirectContactMessage ct $ XCallExtra callId CallExtraInfo {rtcExtraInfo} + void . sendDirectContactMessage user ct $ XCallExtra callId CallExtraInfo {rtcExtraInfo} let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState APIEndCall contactId -> -- any call party withCurrentCall contactId $ \user ct call@Call {callId} -> do - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XCallEnd callId) updateCallItemStatus user ct call WCSDisconnected $ Just msgId pure Nothing APIGetCallInvitations -> withUser $ \_ -> do @@ -1181,7 +1144,7 @@ processChatCommand' vr = \case _ -> Nothing rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withStore $ \db -> do user <- getUserByContactId db contactId - contact <- getContact db user contactId + contact <- getContact db vr user contactId pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} APIGetNetworkStatuses -> withUser $ \_ -> CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses @@ -1190,11 +1153,11 @@ processChatCommand' vr = \case updateCallItemStatus user ct call receivedStatus Nothing $> Just call APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) APISetContactPrefs contactId prefs' -> withUser $ \user -> do - ct <- withStore $ \db -> getContact db user contactId + ct <- withStore $ \db -> getContact db vr user contactId updateContactPrefs user ct prefs' APISetContactAlias contactId localAlias -> withUser $ \user@User {userId} -> do ct' <- withStore $ \db -> do - ct <- getContact db user contactId + ct <- getContact db vr user contactId liftIO $ updateContactAlias db userId ct localAlias pure $ CRContactAliasUpdated user ct' APISetConnectionAlias connId localAlias -> withUser $ \user@User {userId} -> do @@ -1235,9 +1198,8 @@ processChatCommand' vr = \case ok user SetUserProtoServers serversConfig -> withUser $ \User {userId} -> processChatCommand $ APISetUserProtoServers userId serversConfig - APITestProtoServer userId srv@(AProtoServerWithAuth p server) -> withUserId userId $ \user -> - withServerProtocol p $ - CRServerTestResult user srv <$> withAgent (\a -> testProtocolServer a (aUserId user) server) + APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user -> + CRServerTestResult user srv <$> withAgent (\a -> testProtocolServer a (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand $ APITestProtoServer userId srv APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> @@ -1270,7 +1232,7 @@ processChatCommand' vr = \case APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of CTDirect -> do ct <- withStore $ \db -> do - ct <- getContact db user chatId + ct <- getContact db vr user chatId liftIO $ updateContactSettings db user chatId chatSettings pure ct forM_ (contactConnId ct) $ \connId -> @@ -1288,13 +1250,13 @@ processChatCommand' vr = \case APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do m <- withStore $ \db -> do liftIO $ updateGroupMemberSettings db user gId gMemberId settings - getGroupMember db user gId gMemberId + getGroupMember db vr user gId gMemberId let ntfOn = showMessages $ memberSettings m toggleNtf user m ntfOn ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact - ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + ct@Contact {activeConn} <- withStore $ \db -> getContact db vr user contactId incognitoProfile <- case activeConn of Nothing -> pure Nothing Just Connection {customUserProfileId} -> @@ -1305,55 +1267,55 @@ processChatCommand' vr = \case (g, s) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> liftIO (getGroupSummary db user gId) pure $ CRGroupInfo user g s APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) pure $ CRGroupMemberInfo user g m connectionStats APISwitchContact contactId -> withUser $ \user -> do - ct <- withStore $ \db -> getContact db user contactId + ct <- withStore $ \db -> getContact db vr user contactId case contactConnId ct of Just connId -> do connectionStats <- withAgent $ \a -> switchConnectionAsync a "" connId pure $ CRContactSwitchStarted user ct connectionStats Nothing -> throwChatError $ CEContactNotActive ct APISwitchGroupMember gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId case memberConnId m of Just connId -> do connectionStats <- withAgent (\a -> switchConnectionAsync a "" connId) pure $ CRGroupMemberSwitchStarted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive APIAbortSwitchContact contactId -> withUser $ \user -> do - ct <- withStore $ \db -> getContact db user contactId + ct <- withStore $ \db -> getContact db vr user contactId case contactConnId ct of Just connId -> do connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRContactSwitchAborted user ct connectionStats Nothing -> throwChatError $ CEContactNotActive ct APIAbortSwitchGroupMember gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId case memberConnId m of Just connId -> do connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRGroupMemberSwitchAborted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive APISyncContactRatchet contactId force -> withUser $ \user -> withChatLock "syncContactRatchet" $ do - ct <- withStore $ \db -> getContact db user contactId - case contactConnId ct of - Just connId -> do - cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId force + ct <- withStore $ \db -> getContact db vr user contactId + case contactConn ct of + Just conn@Connection {pqSupport} -> do + cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (aConnId conn) pqSupport force createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> withChatLock "syncGroupMemberRatchet" $ do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId case memberConnId m of Just connId -> do - cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId force + cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId PQSupportOff force createInternalChatItem user (CDGroupSnd g) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m) Nothing pure $ CRGroupMemberRatchetSyncStarted user g m cStats _ -> throwChatError CEGroupMemberNotActive APIGetContactCode contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + ct@Contact {activeConn} <- withStore $ \db -> getContact db vr user contactId case activeConn of Just conn@Connection {connId} -> do code <- getConnectionCode $ aConnId conn @@ -1367,7 +1329,7 @@ processChatCommand' vr = \case pure $ CRContactCode user ct' code Nothing -> throwChatError $ CEContactNotActive ct APIGetGroupMemberCode gId gMemberId -> withUser $ \user -> do - (g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId case activeConn of Just conn@Connection {connId} -> do code <- getConnectionCode $ aConnId conn @@ -1381,24 +1343,24 @@ processChatCommand' vr = \case pure $ CRGroupMemberCode user g m' code _ -> throwChatError CEGroupMemberNotActive APIVerifyContact contactId code -> withUser $ \user -> do - ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + ct@Contact {activeConn} <- withStore $ \db -> getContact db vr user contactId case activeConn of Just conn -> verifyConnectionCode user conn code Nothing -> throwChatError $ CEContactNotActive ct APIVerifyGroupMember gId gMemberId code -> withUser $ \user -> do - GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId + GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user gId gMemberId case activeConn of Just conn -> verifyConnectionCode user conn code _ -> throwChatError CEGroupMemberNotActive APIEnableContact contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + ct@Contact {activeConn} <- withStore $ \db -> getContact db vr user contactId case activeConn of Just conn -> do withStore' $ \db -> setConnectionAuthErrCounter db user conn 0 ok user Nothing -> throwChatError $ CEContactNotActive ct APIEnableGroupMember gId gMemberId -> withUser $ \user -> do - GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId + GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user gId gMemberId case activeConn of Just conn -> do withStore' $ \db -> setConnectionAuthErrCounter db user conn 0 @@ -1409,7 +1371,7 @@ processChatCommand' vr = \case SetShowMemberMessages gName mName showMessages -> withUser $ \user -> do (gId, mId) <- getGroupAndMemberId user gName mName gInfo <- withStore $ \db -> getGroupInfo db vr user gId - m <- withStore $ \db -> getGroupMember db user gId mId + m <- withStore $ \db -> getGroupMember db vr user gId mId let GroupInfo {membership = GroupMember {memberRole = membershipRole}} = gInfo when (membershipRole >= GRAdmin) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages let settings = (memberSettings m) {showMessages} @@ -1437,8 +1399,10 @@ processChatCommand' vr = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode + pqSup <- chatReadVar pqExperimentalEnabled + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing (IKNoPQ pqSup) subMode + -- TODO PQ pass minVersion from the current range + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode initialChatVersion pqSup pure $ CRInvitation user cReq conn AddContact incognito -> withUser $ \User {userId} -> processChatCommand $ APIAddContact userId incognito @@ -1465,10 +1429,16 @@ processChatCommand' vr = \case -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing False - dm <- directMessage $ XInfo profileToSend - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode - pure $ CRSentConfirmation user conn + pqSup <- chatReadVar pqExperimentalEnabled + withAgent' (\a -> connRequestPQSupport a pqSup cReq) >>= \case + Nothing -> throwChatError CEInvalidConnReq + -- TODO PQ the error above should be CEIncompatibleConnReqVersion, also the same API should be called in Plan + Just (agentV, pqSup') -> do + let chatV = agentToChatVersion agentV + dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup' subMode + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode chatV pqSup' + pure $ CRSentConfirmation user conn APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do @@ -1480,7 +1450,7 @@ processChatCommand' vr = \case _ -> processChatCommand $ APIConnect userId incognito aCReqUri Connect _ Nothing -> throwChatError CEInvalidConnReq APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do - ct@Contact {activeConn, profile = LocalProfile {contactLink}} <- withStore $ \db -> getContact db user contactId + ct@Contact {activeConn, profile = LocalProfile {contactLink}} <- withStore $ \db -> getContact db vr user contactId when (isJust activeConn) $ throwChatError (CECommandError "contact already has connection") case contactLink of Just cReq -> connectContactViaAddress user incognito ct cReq @@ -1496,18 +1466,19 @@ processChatCommand' vr = \case DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> - CRContactsList user <$> withStore' (`getUserContacts` user) + CRContactsList user <$> withStore' (\db -> getUserContacts db vr user) ListContacts -> withUser $ \User {userId} -> processChatCommand $ APIListContacts userId APICreateMyAddress userId -> withUserId userId $ \user -> withChatLock "createMyAddress" . procCmd $ do subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing subMode + -- TODO v5.7 pass IPPQOn + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing IKPQOff subMode withStore $ \db -> createUserContactLink db user connId cReq subMode pure $ CRUserContactLinkCreated user cReq CreateMyAddress -> withUser $ \User {userId} -> processChatCommand $ APICreateMyAddress userId APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do - conns <- withStore (`getUserAddressConnections` user) + conns <- withStore $ \db -> getUserAddressConnections db vr user withChatLock "deleteMyAddress" $ do deleteAgentConnectionsAsync user $ map aConnId conns withStore' (`deleteUserAddress` user) @@ -1573,7 +1544,7 @@ processChatCommand' vr = \case _ -> throwChatError $ CECommandError "not supported" SendMemberContactMessage gName mName msg -> withUser $ \user -> do (gId, mId) <- getGroupAndMemberId user gName mName - m <- withStore $ \db -> getGroupMember db user gId mId + m <- withStore $ \db -> getGroupMember db vr user gId mId let mc = MCText msg case memberContactId m of Nothing -> do @@ -1592,7 +1563,7 @@ processChatCommand' vr = \case let mc = MCText msg processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do - contacts <- withStore' (`getUserContacts` user) + contacts <- withStore' $ \db -> getUserContacts db vr user let cts = filter (\ct -> contactReady ct && contactActive ct && directOrUsed ct) contacts ChatConfig {logLevel} <- asks config withChatLock "sendMessageBroadcast" . procCmd $ do @@ -1604,7 +1575,7 @@ processChatCommand' vr = \case sendAndCount user ll (s, f) ct = (sendToContact user ct $> (s + 1, f)) `catchChatError` \e -> when (ll <= CLLInfo) (toView $ CRChatError (Just user) e) $> (s, f + 1) sendToContact user ct = do - (sndMsg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) + (sndMsg, _) <- sendDirectContactMessage user ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) void $ saveSndChatItem user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withStore $ \db -> getContactIdByName db user cName @@ -1638,12 +1609,13 @@ processChatCommand' vr = \case -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing groupInfo <- withStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile + createInternalChatItem user (CDGroupSnd groupInfo) (CISndGroupE2EEInfo $ E2EInfo {pqEnabled = PQEncOff}) Nothing pure $ CRGroupCreated user groupInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand $ APINewGroup userId incognito gProfile APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do -- TODO for large groups: no need to load all members to determine if contact is a member - (group, contact) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db user contactId + (group, contact) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db vr user contactId assertDirectAllowed user MDSnd contact XGrpInv_ let Group gInfo members = group Contact {localDisplayName = cName} = contact @@ -1657,7 +1629,7 @@ processChatCommand' vr = \case Nothing -> do gVar <- asks random subMode <- chatReadVar subscriptionMode - (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode + (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOff subMode member <- withStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member @@ -1674,17 +1646,18 @@ processChatCommand' vr = \case withChatLock "joinGroup" . procCmd $ do (invitation, ct) <- withStore $ \db -> do inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db vr user groupId - (inv,) <$> getContactViaMember db user fromMember + (inv,) <$> getContactViaMember db vr user fromMember let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation GroupMember {memberId = membershipMemId} = membership Contact {activeConn} = ct case activeConn of Just Connection {peerChatVRange} -> do subMode <- chatReadVar subscriptionMode - dm <- directMessage $ XGrpAcpt membershipMemId - agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode + dm <- encodeConnInfo $ XGrpAcpt membershipMemId + agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm PQSupportOff subMode + let chatV = vr PQSupportOff `peerConnChatVersion` peerChatVRange withStore' $ \db -> do - createMemberConnection db userId fromMember agentConnId (fromJVersionRange peerChatVRange) subMode + createMemberConnection db userId fromMember agentConnId chatV peerChatVRange subMode updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` \_ -> pure () @@ -1706,7 +1679,7 @@ processChatCommand' vr = \case withStore' $ \db -> updateGroupMemberRole db user m memRole case mStatus of GSMemInvited -> do - withStore (\db -> (,) <$> mapM (getContact db user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case + withStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case (Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName _ -> do @@ -1732,7 +1705,7 @@ processChatCommand' vr = \case toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) bm' <- withStore $ \db -> do liftIO $ updateGroupMemberBlocked db user groupId memberId mrs - getGroupMember db user groupId memberId + getGroupMember db vr user groupId memberId toggleNtf user bm' (not blocked) pure CRMemberBlockedForAllUser {user, groupInfo = gInfo, member = bm', blocked} where @@ -1754,20 +1727,22 @@ processChatCommand' vr = \case (msg, _) <- sendGroupMessage user gInfo members $ XGrpMemDel mId ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) - deleteMemberConnection user m + deleteMemberConnection' user m True -- undeleted "member connected" chat item will prevent deletion of member record deleteOrUpdateMemberRecord user m pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved} APILeaveGroup groupId -> withUser $ \user@User {userId} -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId + filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo withChatLock "leaveGroup" . procCmd $ do + cancelFilesInProgress user filesInfo (msg, _) <- sendGroupMessage' user gInfo members XGrpLeave ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) -- TODO delete direct connections that were unused deleteGroupLinkIfExists user gInfo -- member records are not deleted to keep history - deleteMembersConnections user members + deleteMembersConnections' user members True withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser user gInfo {membership = membership {memberStatus = GSMemLeft}} APIListMembers groupId -> withUser $ \user -> @@ -1796,7 +1771,7 @@ processChatCommand' vr = \case APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> CRGroupsList user <$> withStore' (\db -> getUserGroupsWithSummary db vr user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do - ct_ <- forM cName_ $ \cName -> withStore $ \db -> getContactByName db user cName + ct_ <- forM cName_ $ \cName -> withStore $ \db -> getContactByName db vr user cName processChatCommand $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do g <- withStore $ \db -> getGroup db vr user groupId @@ -1816,7 +1791,7 @@ processChatCommand' vr = \case groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode let crClientData = encodeJSON $ CRDataGroup groupLinkId - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) subMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) IKPQOff subMode withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole subMode pure $ CRGroupLinkCreated user gInfo cReq mRole APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withChatLock "groupLinkMemberRole " $ do @@ -1835,15 +1810,15 @@ processChatCommand' vr = \case (_, groupLink, mRole) <- withStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo groupLink mRole APICreateMemberContact gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId assertUserGroupRole g GRAuthor unless (groupFeatureAllowed SGFDirectMessages g) $ throwChatError $ CECommandError "direct messages not allowed" case memberConn m of Just mConn@Connection {peerChatVRange} -> do - unless (isCompatibleRange (fromJVersionRange peerChatVRange) xGrpDirectInvVRange) $ throwChatError CEPeerChatVRangeIncompatible + unless (maxVersion peerChatVRange >= groupDirectInvVersion) $ throwChatError CEPeerChatVRangeIncompatible when (isJust $ memberContactId m) $ throwChatError $ CECommandError "member contact already exists" subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOff subMode -- [incognito] reuse membership incognito profile ct <- withStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode -- TODO not sure it is correct to set connections status here? @@ -1856,7 +1831,7 @@ processChatCommand' vr = \case case memberConn m of Just mConn -> do let msg = XGrpDirectInv cReq msgContent_ - (sndMsg, _) <- sendDirectMessage mConn msg $ GroupId groupId + (sndMsg, _, _) <- sendDirectMemberMessage mConn msg groupId withStore' $ \db -> setContactGrpInvSent db ct True let ct' = ct {contactGrpInvSent = True} forM_ msgContent_ $ \mc -> do @@ -1955,16 +1930,16 @@ processChatCommand' vr = \case | otherwise -> do fileAgentConnIds <- cancelSndFile user ftm fts True deleteAgentConnectionsAsync user fileAgentConnIds - sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - withStore (\db -> getChatRefByFileId db user fileId) >>= \case - ChatRef CTDirect contactId -> do - contact <- withStore $ \db -> getContact db user contactId - void . sendDirectContactMessage contact $ XFileCancel sharedMsgId - ChatRef CTGroup groupId -> do - Group gInfo ms <- withStore $ \db -> getGroup db vr user groupId + withStore (\db -> liftIO $ lookupChatRefByFileId db user fileId) >>= \case + Nothing -> pure () + Just (ChatRef CTDirect contactId) -> do + (contact, sharedMsgId) <- withStore $ \db -> (,) <$> getContact db vr user contactId <*> getSharedMsgIdByFileId db userId fileId + void . sendDirectContactMessage user contact $ XFileCancel sharedMsgId + Just (ChatRef CTGroup groupId) -> do + (Group gInfo ms, sharedMsgId) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getSharedMsgIdByFileId db userId fileId void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId - _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" - ci <- withStore $ \db -> getChatItemByFileId db vr user fileId + Just _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" + ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId pure $ CRSndFileCancelled user ci ftm fts where fileCancelledOrCompleteSMP SndFileTransfer {fileStatus = s} = @@ -1975,7 +1950,7 @@ processChatCommand' vr = \case | otherwise -> case xftpRcvFile of Nothing -> do cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user) - ci <- withStore $ \db -> getChatItemByFileId db vr user fileId + ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId pure $ CRRcvFileCancelled user ci ftr Just XFTPRcvFile {agentRcvFileId} -> do forM_ (liveRcvFileTransferPath ftr) $ \filePath -> do @@ -1988,18 +1963,21 @@ processChatCommand' vr = \case updateCIFileStatus db user fileId CIFSRcvInvitation updateRcvFileStatus db fileId FSNew updateRcvFileAgentId db fileId Nothing - getChatItemByFileId db vr user fileId + lookupChatItemByFileId db vr user fileId pure $ CRRcvFileCancelled user ci ftr FileStatus fileId -> withUser $ \user -> do - ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> getChatItemByFileId db vr user fileId - case file of - Just CIFile {fileProtocol = FPLocal} -> - throwChatError $ CECommandError "not supported for local files" - Just CIFile {fileProtocol = FPXFTP} -> - pure $ CRFileTransferStatusXFTP user ci - _ -> do + withStore (\db -> lookupChatItemByFileId db vr user fileId) >>= \case + Nothing -> do fileStatus <- withStore $ \db -> getFileTransferProgress db user fileId pure $ CRFileTransferStatus user fileStatus + Just ci@(AChatItem _ _ _ ChatItem {file}) -> case file of + Just CIFile {fileProtocol = FPLocal} -> + throwChatError $ CECommandError "not supported for local files" + Just CIFile {fileProtocol = FPXFTP} -> + pure $ CRFileTransferStatusXFTP user ci + _ -> do + fileStatus <- withStore $ \db -> getFileTransferProgress db user fileId + pure $ CRFileTransferStatus user fileStatus ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do let p = (fromLocalProfile profile :: Profile) {displayName = displayName, fullName = fullName} @@ -2012,7 +1990,7 @@ processChatCommand' vr = \case let p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference f (Just allowed) $ preferences' user} updateProfile user p SetContactFeature (ACF f) cName allowed_ -> withUser $ \user -> do - ct@Contact {userPreferences} <- withStore $ \db -> getContactByName db user cName + ct@Contact {userPreferences} <- withStore $ \db -> getContactByName db vr user cName let prefs' = setPreference f allowed_ $ Just userPreferences updateContactPrefs user ct prefs' SetGroupFeature (AGF f) gName enabled -> @@ -2024,7 +2002,7 @@ processChatCommand' vr = \case p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference' SCFTimedMessages (Just pref) $ preferences' user} updateProfile user p SetContactTimedMessages cName timedMessagesEnabled_ -> withUser $ \user -> do - ct@Contact {userPreferences = userPreferences@Preferences {timedMessages}} <- withStore $ \db -> getContactByName db user cName + ct@Contact {userPreferences = userPreferences@Preferences {timedMessages}} <- withStore $ \db -> getContactByName db vr user cName let currentTTL = timedMessages >>= \TimedMessagesPreference {ttl} -> ttl pref_ = tmeToPref currentTTL <$> timedMessagesEnabled_ prefs' = setPreference' SCFTimedMessages pref_ $ Just userPreferences @@ -2054,6 +2032,16 @@ processChatCommand' vr = \case StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ + APIUploadStandaloneFile userId file@CryptoFile {filePath} -> withUserId userId $ \user -> do + fsFilePath <- toFSFilePath filePath + fileSize <- liftIO $ CF.getFileContentsSize file {filePath = fsFilePath} + when (fileSize > toInteger maxFileSizeHard) $ throwChatError $ CEFileSize filePath + (_, _, fileTransferMeta) <- xftpSndFileTransfer_ user file fileSize 1 Nothing + pure CRSndStandaloneFileCreated {user, fileTransferMeta} + APIStandaloneFileInfo FileDescriptionURI {clientData} -> pure . CRStandaloneFileInfo $ clientData >>= J.decodeStrict . encodeUtf8 + APIDownloadStandaloneFile userId uri file -> withUserId userId $ \user -> do + ft <- receiveViaURI user uri file + pure $ CRRcvStandaloneFileCreated user ft QuitChat -> liftIO exitSuccess ShowVersion -> do -- simplexmqCommitQ makes iOS builds crash m( @@ -2159,7 +2147,7 @@ processChatCommand' vr = \case case groupLinkId of -- contact address Nothing -> - withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case + withStore' (\db -> getConnReqContactXContactId db vr user cReqHash) >>= \case (Just contact, _) -> pure $ CRContactAlreadyExists user contact (_, xContactId_) -> procCmd $ do let randomXContactId = XContactId <$> drgRandomBytes 16 @@ -2167,7 +2155,7 @@ processChatCommand' vr = \case connect' Nothing cReqHash xContactId False -- group link Just gLinkId -> - withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case + withStore' (\db -> getConnReqContactXContactId db vr user cReqHash) >>= \case (Just _contact, _) -> procCmd $ do -- allow repeat contact request newXContactId <- XContactId <$> drgRandomBytes 16 @@ -2178,51 +2166,46 @@ processChatCommand' vr = \case connect' (Just gLinkId) cReqHash xContactId True where connect' groupLinkId cReqHash xContactId inGroup = do - (connId, incognitoProfile, subMode) <- requestContact user incognito cReq xContactId inGroup - conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode + pqSup <- if inGroup then pure PQSupportOff else chatReadVar pqExperimentalEnabled + (connId, incognitoProfile, subMode, chatV) <- requestContact user incognito cReq xContactId inGroup pqSup + conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode chatV pqSup pure $ CRSentInvitation user conn incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> m ChatResponse connectContactViaAddress user incognito ct cReq = withChatLock "connectViaContact" $ do newXContactId <- XContactId <$> drgRandomBytes 16 - (connId, incognitoProfile, subMode) <- requestContact user incognito cReq newXContactId False + pqSup <- chatReadVar pqExperimentalEnabled + (connId, incognitoProfile, subMode, chatV) <- requestContact user incognito cReq newXContactId False pqSup let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode + ct' <- withStore $ \db -> createAddressContactConnection db vr user ct connId cReqHash newXContactId incognitoProfile subMode chatV pqSup pure $ CRSentInvitationToContact user ct' incognitoProfile - requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> m (ConnId, Maybe Profile, SubscriptionMode) - requestContact user incognito cReq xContactId inGroup = do + requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQSupport -> m (ConnId, Maybe Profile, SubscriptionMode, VersionChat) + requestContact user incognito cReq xContactId inGroup pqSup = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing inGroup - dm <- directMessage (XContact profileToSend $ Just xContactId) - subMode <- chatReadVar subscriptionMode - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode - pure (connId, incognitoProfile, subMode) + -- 0) toggle disabled - PQSupportOff + -- 1) toggle enabled, address supports PQ (connRequestPQSupport returns Just True) - PQSupportOn, enable support with compression + -- 2) toggle enabled, address doesn't support PQ - PQSupportOn but without compression, with version range indicating support + withAgent' (\a -> connRequestPQSupport a pqSup cReq) >>= \case + Nothing -> throwChatError CEInvalidConnReq + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + dm <- encodeConnInfoPQ pqSup chatV (XContact profileToSend $ Just xContactId) + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup subMode + pure (connId, incognitoProfile, subMode, chatV) contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft - checkSndFile :: MsgContent -> CryptoFile -> Integer -> m (Integer, SendFileMode) - checkSndFile mc (CryptoFile f cfArgs) n = do + checkSndFile :: CryptoFile -> m Integer + checkSndFile (CryptoFile f cfArgs) = do fsFilePath <- toFSFilePath f unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f - ChatConfig {fileChunkSize, inlineFiles} <- asks config - xftpCfg <- readTVarIO =<< asks userXFTPFileConfig fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs when (fromInteger fileSize > maxFileSize) $ throwChatError $ CEFileSize f - let chunks = -((-fileSize) `div` fileChunkSize) - fileInline = inlineFileMode mc inlineFiles chunks n - fileMode = case xftpCfg of - Just cfg - | isJust cfArgs -> SendFileXFTP - | fileInline == Just IFMSent || fileSize < minFileSize cfg || n <= 0 -> SendFileSMP fileInline - | otherwise -> SendFileXFTP - _ -> SendFileSMP fileInline - pure (fileSize, fileMode) - inlineFileMode mc InlineFilesConfig {offerChunks, sendChunks, totalSendChunks} chunks n - | chunks > offerChunks = Nothing - | chunks <= sendChunks && chunks * n <= totalSendChunks && isVoice mc = Just IFMSent - | otherwise = Just IFMOffer + pure fileSize updateProfile :: User -> Profile -> m ChatResponse updateProfile user p' = updateProfile_ user p' $ withStore $ \db -> updateUserProfile db user p' updateProfile_ :: User -> Profile -> m User -> m ChatResponse @@ -2231,18 +2214,21 @@ processChatCommand' vr = \case | otherwise = do when (n /= n') $ checkValidName n' -- read contacts before user update to correctly merge preferences - contacts <- withStore' (`getUserContacts` user) + contacts <- withStore' $ \db -> getUserContacts db vr user user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') withChatLock "updateProfile" . procCmd $ do - let changedCts = foldr (addChangedProfileContact user') [] contacts - idsEvts = map ctSndMsg changedCts - msgReqs_ <- zipWith ctMsgReq changedCts <$> createSndMessages idsEvts - (errs, cts) <- partitionEithers . zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ - unless (null errs) $ toView $ CRChatErrors (Just user) errs - let changedCts' = filter (\ChangedProfileContact {ct, ct'} -> directOrUsed ct' && mergedPreferences ct' /= mergedPreferences ct) cts - createContactsSndFeatureItems user' changedCts' - let summary = + let changedCts_ = L.nonEmpty $ foldr (addChangedProfileContact user') [] contacts + summary <- case changedCts_ of + Nothing -> pure $ UserProfileUpdateSummary 0 0 [] + Just changedCts -> do + let idsEvts = L.map ctSndMsg changedCts + msgReqs_ <- L.zipWith ctMsgReq changedCts <$> createSndMessages idsEvts + (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ + unless (null errs) $ toView $ CRChatErrors (Just user) errs + let changedCts' = filter (\ChangedProfileContact {ct, ct'} -> directOrUsed ct' && mergedPreferences ct' /= mergedPreferences ct) cts + createContactsSndFeatureItems user' changedCts' + pure UserProfileUpdateSummary { updateSuccesses = length cts, updateFailures = length errs, @@ -2261,11 +2247,12 @@ processChatCommand' vr = \case mergedProfile = userProfileToSend user Nothing (Just ct) False ct' = updateMergedPreferences user' ct mergedProfile' = userProfileToSend user' Nothing (Just ct') False - ctSndMsg :: ChangedProfileContact -> (ConnOrGroupId, ChatMsgEvent 'Json) - ctSndMsg ChangedProfileContact {mergedProfile', conn = Connection {connId}} = (ConnectionId connId, XInfo mergedProfile') + ctSndMsg :: ChangedProfileContact -> (ConnOrGroupId, PQSupport, ChatMsgEvent 'Json) + ctSndMsg ChangedProfileContact {mergedProfile', conn = Connection {connId, pqSupport}} = (ConnectionId connId, pqSupport, XInfo mergedProfile') ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError MsgReq - ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> - (conn, MsgFlags {notification = hasNotification XInfo_}, msgBody, msgId) + ctMsgReq ChangedProfileContact {conn} = + fmap $ \SndMessage {msgId, msgBody} -> + (conn, MsgFlags {notification = hasNotification XInfo_}, msgBody, msgId) updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' @@ -2278,7 +2265,7 @@ processChatCommand' vr = \case mergedProfile' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withChatLock "updateProfile" $ do - void (sendDirectContactMessage ct' $ XInfo mergedProfile') `catchChatError` (toView . CRChatError (Just user)) + void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` (toView . CRChatError (Just user)) when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> Group -> GroupProfile -> m ChatResponse @@ -2320,7 +2307,7 @@ processChatCommand' vr = \case withCurrentCall ctId action = do (user, ct) <- withStore $ \db -> do user <- getUserByContactId db ctId - (user,) <$> getContact db user ctId + (user,) <$> getContact db vr user ctId calls <- asks currentCalls withChatLock "currentCall" $ atomically (TM.lookup ctId calls) >>= \case @@ -2356,10 +2343,19 @@ processChatCommand' vr = \case groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName pure (groupId, groupMemberId) sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> m () - sendGrpInvitation user ct@Contact {localDisplayName} GroupInfo {groupId, groupProfile, membership} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + sendGrpInvitation user ct@Contact {localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let GroupMember {memberRole = userRole, memberId = userMemberId} = membership - groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile Nothing - (msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv + groupInv = + GroupInvitation + { fromMember = MemberIdRole userMemberId userRole, + invitedMember = MemberIdRole memberId memRole, + connRequest = cReq, + groupProfile, + groupLinkId = Nothing, + groupSize = Just currentMemCount + } + (msg, _) <- sendDirectContactMessage user ct $ XGrpInv groupInv let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveSndChatItem user (CDDirectSnd ct) msg content toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) @@ -2417,7 +2413,8 @@ processChatCommand' vr = \case deleteChatUser :: User -> Bool -> m ChatResponse deleteChatUser user delSMPQueues = do filesInfo <- withStore' (`getUserFileInfo` user) - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues withStore' (`deleteUserRecord` user) when (activeUser user) $ chatWriteVar currentUser Nothing @@ -2427,7 +2424,7 @@ processChatCommand' vr = \case (chatId, chatSettings) <- case cType of CTDirect -> withStore $ \db -> do ctId <- getContactIdByName db user name - Contact {chatSettings} <- getContact db user ctId + Contact {chatSettings} <- getContact db vr user ctId pure (ctId, chatSettings) CTGroup -> withStore $ \db -> do @@ -2454,7 +2451,7 @@ processChatCommand' vr = \case where cReqSchemas :: (ConnReqInvitation, ConnReqInvitation) cReqSchemas = - ( CRInvitationUri crData {crScheme = CRSSimplex} e2e, + ( CRInvitationUri crData {crScheme = SSSimplex} e2e, CRInvitationUri crData {crScheme = simplexChat} e2e ) connectPlan user (ACR SCMContact (CRContactUri crData)) = do @@ -2468,7 +2465,7 @@ processChatCommand' vr = \case Nothing -> withStore' (\db -> getContactConnEntityByConnReqHash db vr user cReqHashes) >>= \case Nothing -> - withStore' (\db -> getContactWithoutConnViaAddress db user cReqSchemas) >>= \case + withStore' (\db -> getContactWithoutConnViaAddress db vr user cReqSchemas) >>= \case Nothing -> pure $ CPContactAddress CAPOk Just ct -> pure $ CPContactAddress (CAPContactViaAddress ct) Just (RcvDirectMsgConnection _conn Nothing) -> pure $ CPContactAddress CAPConnectingConfirmReconnect @@ -2499,7 +2496,7 @@ processChatCommand' vr = \case where cReqSchemas :: (ConnReqContact, ConnReqContact) cReqSchemas = - ( CRContactUri crData {crScheme = CRSSimplex}, + ( CRContactUri crData {crScheme = SSSimplex}, CRContactUri crData {crScheme = simplexChat} ) cReqHashes :: (ConnReqUriHash, ConnReqUriHash) @@ -2625,50 +2622,72 @@ setAllExpireCIFlags b = do keys <- M.keys <$> readTVar expireFlags forM_ keys $ \k -> TM.insert k b expireFlags -deleteFilesAndConns :: ChatMonad m => User -> [CIFileInfo] -> m () -deleteFilesAndConns user filesInfo = do - connIds <- mapM (deleteFile user) filesInfo - deleteAgentConnectionsAsync user $ concat connIds - -deleteFile :: ChatMonad m => User -> CIFileInfo -> m [ConnId] -deleteFile user fileInfo = deleteFile' user fileInfo False - -deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId] -deleteFile' user ciFileInfo@CIFileInfo {filePath} sendCancel = do - aConnIds <- cancelFile' user ciFileInfo sendCancel - forM_ filePath $ \fPath -> - deleteFileLocally fPath `catchChatError` (toView . CRChatError (Just user)) - pure aConnIds - -deleteFileLocally :: forall m. ChatMonad m => FilePath -> m () -deleteFileLocally fPath = - withFilesFolder $ \filesFolder -> liftIO $ do - let fsFilePath = filesFolder fPath - removeFile fsFilePath `catchAll` \_ -> - removePathForcibly fsFilePath `catchAll_` pure () +cancelFilesInProgress :: forall m. ChatMonad m => User -> [CIFileInfo] -> m () +cancelFilesInProgress user filesInfo = do + let filesInfo' = filter (not . fileEnded) filesInfo + (sfs, rfs) <- splitFTTypes <$> withStoreBatch (\db -> map (getFT db) filesInfo') + forM_ rfs $ \RcvFileTransfer {fileId} -> closeFileHandle fileId rcvFiles `catchChatError` \_ -> pure () + void . withStoreBatch' $ \db -> map (updateSndFileCancelled db) sfs + void . withStoreBatch' $ \db -> map (updateRcvFileCancelled db) rfs + let xsfIds = mapMaybe (\(FileTransferMeta {fileId, xftpSndFile}, _) -> (,fileId) <$> xftpSndFile) sfs + xrfIds = mapMaybe (\RcvFileTransfer {fileId, xftpRcvFile} -> (,fileId) <$> xftpRcvFile) rfs + agentXFTPDeleteSndFilesRemote user xsfIds + agentXFTPDeleteRcvFiles xrfIds + let smpSFConnIds = concatMap (\(ft, sfts) -> mapMaybe (smpSndFileConnId ft) sfts) sfs + smpRFConnIds = mapMaybe smpRcvFileConnId rfs + deleteAgentConnectionsAsync user smpSFConnIds + deleteAgentConnectionsAsync user smpRFConnIds where + fileEnded CIFileInfo {fileStatus} = case fileStatus of + Just (AFS _ status) -> ciFileEnded status + Nothing -> True + getFT :: DB.Connection -> CIFileInfo -> IO (Either ChatError FileTransfer) + getFT db CIFileInfo {fileId} = runExceptT . withExceptT ChatErrorStore $ getFileTransfer db user fileId + updateSndFileCancelled :: DB.Connection -> (FileTransferMeta, [SndFileTransfer]) -> IO () + updateSndFileCancelled db (FileTransferMeta {fileId}, sfts) = do + updateFileCancelled db user fileId CIFSSndCancelled + forM_ sfts updateSndFTCancelled + where + updateSndFTCancelled :: SndFileTransfer -> IO () + updateSndFTCancelled ft = unless (sndFTEnded ft) $ do + updateSndFileStatus db ft FSCancelled + deleteSndFileChunks db ft + updateRcvFileCancelled :: DB.Connection -> RcvFileTransfer -> IO () + updateRcvFileCancelled db ft@RcvFileTransfer {fileId} = do + updateFileCancelled db user fileId CIFSRcvCancelled + updateRcvFileStatus db fileId FSCancelled + deleteRcvFileChunks db ft + splitFTTypes :: [Either ChatError FileTransfer] -> ([(FileTransferMeta, [SndFileTransfer])], [RcvFileTransfer]) + splitFTTypes = foldr addFT ([], []) . rights + where + addFT f (sfs, rfs) = case f of + FTSnd ft@FileTransferMeta {cancelled} sfts | not cancelled -> ((ft, sfts) : sfs, rfs) + FTRcv ft@RcvFileTransfer {cancelled} | not cancelled -> (sfs, ft : rfs) + _ -> (sfs, rfs) + smpSndFileConnId :: FileTransferMeta -> SndFileTransfer -> Maybe ConnId + smpSndFileConnId FileTransferMeta {xftpSndFile} sft@SndFileTransfer {agentConnId = AgentConnId acId, fileInline} + | isNothing xftpSndFile && isNothing fileInline && not (sndFTEnded sft) = Just acId + | otherwise = Nothing + smpRcvFileConnId :: RcvFileTransfer -> Maybe ConnId + smpRcvFileConnId ft@RcvFileTransfer {xftpRcvFile, rcvFileInline} + | isNothing xftpRcvFile && isNothing rcvFileInline = liveRcvFileTransferConnId ft + | otherwise = Nothing + sndFTEnded SndFileTransfer {fileStatus} = fileStatus == FSCancelled || fileStatus == FSComplete + +deleteFilesLocally :: forall m. ChatMonad m => [CIFileInfo] -> m () +deleteFilesLocally files = + withFilesFolder $ \filesFolder -> + liftIO . forM_ files $ \CIFileInfo {filePath} -> + mapM_ (delete . (filesFolder )) filePath + where + delete :: FilePath -> IO () + delete fPath = + removeFile fPath `catchAll` \_ -> + removePathForcibly fPath `catchAll_` pure () -- perform an action only if filesFolder is set (i.e. on mobile devices) withFilesFolder :: (FilePath -> m ()) -> m () withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action -cancelFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId] -cancelFile' user CIFileInfo {fileId, fileStatus} sendCancel = - case fileStatus of - Just fStatus -> cancel' fStatus `catchChatError` (\e -> toView (CRChatError (Just user) e) $> []) - Nothing -> pure [] - where - cancel' :: ACIFileStatus -> m [ConnId] - cancel' (AFS dir status) = - if ciFileEnded status - then pure [] - else case dir of - SMDSnd -> do - (ftm@FileTransferMeta {cancelled}, fts) <- withStore (\db -> getSndFileTransfer db user fileId) - if cancelled then pure [] else cancelSndFile user ftm fts sendCancel - SMDRcv -> do - ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) - if cancelled then pure [] else maybeToList <$> cancelRcvFileTransfer user ft - updateCallItemStatus :: ChatMonad m => User -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m () updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do aciContent_ <- callStatusItemContent user ct chatItemId receivedStatus @@ -2741,7 +2760,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI -- direct file protocol (Nothing, Just connReq) -> do subMode <- chatReadVar subscriptionMode - dm <- directMessage $ XFileAcpt fName + dm <- encodeConnInfo $ XFileAcpt fName connIds <- joinAgentConnectionAsync user True connReq dm subMode filePath <- getRcvFilePath fileId filePath_ fName True withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnJoined filePath subMode @@ -2761,13 +2780,13 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI chatRef <- withStoreCtx (Just "acceptFileReceive, getChatRefByFileId") $ \db -> getChatRefByFileId db user fileId case (chatRef, grpMemberId) of (ChatRef CTDirect contactId, Nothing) -> do - ct <- withStoreCtx (Just "acceptFileReceive, getContact") $ \db -> getContact db user contactId - acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage ct msg + ct <- withStoreCtx (Just "acceptFileReceive, getContact") $ \db -> getContact db vr user contactId + acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage user ct msg (ChatRef CTGroup groupId, Just memId) -> do - GroupMember {activeConn} <- withStoreCtx (Just "acceptFileReceive, getGroupMember") $ \db -> getGroupMember db user groupId memId + GroupMember {activeConn} <- withStoreCtx (Just "acceptFileReceive, getGroupMember") $ \db -> getGroupMember db vr user groupId memId case activeConn of Just conn -> do - acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMessage conn msg $ GroupId groupId + acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMemberMessage conn msg groupId _ -> throwChatError $ CEFileInternal "member connection not active" _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" where @@ -2807,6 +2826,19 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} startReceivingFile user fileId withStoreCtx' (Just "receiveViaCompleteFD, updateRcvFileAgentId") $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) +receiveViaURI :: ChatMonad m => User -> FileDescriptionURI -> CryptoFile -> m RcvFileTransfer +receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile {cryptoArgs} = do + fileId <- withStore $ \db -> createRcvStandaloneFileTransfer db userId cf fileSize chunkSize + aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) description cryptoArgs + withStore $ \db -> do + liftIO $ do + updateRcvFileStatus db fileId FSConnected + updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 + updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) + getRcvFileTransfer db user fileId + where + FD.ValidFileDescription FD.FileDescription {size = FD.FileSize fileSize, chunkSize = FD.FileSize chunkSize} = description + startReceivingFile :: ChatMonad m => User -> FileTransferId -> m () startReceivingFile user fileId = do vr <- chatVersionRange @@ -2851,20 +2883,26 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of getTmpHandle fPath = openFile fPath AppendMode `catchThrow` (ChatError . CEFileInternal . show) acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> m Contact -acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile contactUsed = do +acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId, pqSupport} incognitoProfile contactUsed = do subMode <- chatReadVar subscriptionMode + pqSup <- chatReadVar pqExperimentalEnabled + vr <- chatVersionRange let profileToSend = profileToSendOnAccept user incognitoProfile False - dm <- directMessage $ XInfo profileToSend - acId <- withAgent $ \a -> acceptContact a True invId dm subMode - withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode contactUsed + chatV = vr pqSup `peerConnChatVersion` cReqChatVRange + pqSup' = pqSup `CR.pqSupportAnd` pqSupport + dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend + acId <- withAgent $ \a -> acceptContact a True invId dm pqSup' subMode + withStore' $ \db -> createAcceptedContact db user acId chatV cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile subMode pqSup' contactUsed -acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> m Contact -acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed = do +acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> PQSupport -> m Contact +acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqSup = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile False - (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode + vr <- chatVersionRange + chatV <- (\pq -> vr pq `peerConnChatVersion` cReqChatVRange) <$> chatReadVar pqExperimentalEnabled + (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode pqSup chatV withStore' $ \db -> do - ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode contactUsed + ct@Contact {activeConn} <- createAcceptedContact db user acId chatV cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed forM_ activeConn $ \Connection {connId} -> setCommandConnId db user cmdId connId pure ct @@ -2872,19 +2910,30 @@ acceptGroupJoinRequestAsync :: ChatMonad m => User -> GroupInfo -> UserContactRe acceptGroupJoinRequestAsync user gInfo@GroupInfo {groupProfile, membership} - ucr@UserContactRequest {agentInvitationId = AgentInvId invId} + ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} gLinkMemRole incognitoProfile = do gVar <- asks random (groupMemberId, memberId) <- withStore $ \db -> createAcceptedMember db gVar user gInfo ucr gLinkMemRole + currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let Profile {displayName} = profileToSendOnAccept user incognitoProfile True GroupMember {memberRole = userRole, memberId = userMemberId} = membership - msg = XGrpLinkInv $ GroupLinkInvitation (MemberIdRole userMemberId userRole) displayName (MemberIdRole memberId gLinkMemRole) groupProfile + msg = + XGrpLinkInv $ + GroupLinkInvitation + { fromMember = MemberIdRole userMemberId userRole, + fromMemberName = displayName, + invitedMember = MemberIdRole memberId gLinkMemRole, + groupProfile, + groupSize = Just currentMemCount + } subMode <- chatReadVar subscriptionMode - connIds <- agentAcceptContactAsync user True invId msg subMode + vr <- chatVersionRange + chatV <- (\pq -> vr pq `peerConnChatVersion` cReqChatVRange) <$> chatReadVar pqExperimentalEnabled + connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV withStore $ \db -> do - liftIO $ createAcceptedMemberConnection db user connIds ucr groupMemberId subMode - getGroupMemberById db user groupMemberId + liftIO $ createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode + getGroupMemberById db vr user groupMemberId profileToSendOnAccept :: User -> Maybe IncognitoProfile -> Bool -> Profile profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ip) Nothing @@ -2895,12 +2944,14 @@ profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> deleteGroupLink' :: ChatMonad m => User -> GroupInfo -> m () deleteGroupLink' user gInfo = do - conn <- withStore $ \db -> getGroupLinkConnection db user gInfo + vr <- chatVersionRange + conn <- withStore $ \db -> getGroupLinkConnection db vr user gInfo deleteGroupLink_ user gInfo conn deleteGroupLinkIfExists :: ChatMonad m => User -> GroupInfo -> m () deleteGroupLinkIfExists user gInfo = do - conn_ <- eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupLinkConnection db user gInfo) + vr <- chatVersionRange + conn_ <- eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupLinkConnection db vr user gInfo) mapM_ (deleteGroupLink_ user gInfo) conn_ deleteGroupLink_ :: ChatMonad m => User -> GroupInfo -> Connection -> m () @@ -2929,7 +2980,7 @@ agentSubscriber = do type AgentBatchSubscribe m = AgentClient -> [ConnId] -> ExceptT AgentErrorType m (Map ConnId (Either AgentErrorType ())) -subscribeUserConnections :: forall m. ChatMonad m => VersionRange -> Bool -> AgentBatchSubscribe m -> User -> m () +subscribeUserConnections :: forall m. ChatMonad m => (PQSupport -> VersionRangeChat) -> Bool -> AgentBatchSubscribe m -> User -> m () subscribeUserConnections vr onlyNeeded agentBatchSubscribe user@User {userId} = do -- get user connections ce <- asks $ subscriptionEvents . config @@ -2985,12 +3036,12 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user@User {userId} = } getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns = do - cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts + cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") (`getUserContacts` vr) let cts' = mapMaybe (\ct -> (,ct) <$> contactConnId ct) $ filter contactActive cts pure (map fst cts', M.fromList cts') getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact) getUserContactLinkConns = do - (cs, ucs) <- unzip <$> withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContactLinks") getUserContactLinks + (cs, ucs) <- unzip <$> withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContactLinks") (`getUserContactLinks` vr) let connIds = map aConnId cs pure (connIds, M.fromList $ zip connIds ucs) getGroupMemberConns :: m ([Group], [ConnId], Map ConnId GroupMember) @@ -3130,9 +3181,10 @@ cleanupManager = do timedItems <- withStoreCtx' (Just "cleanupManager, getTimedItems") $ \db -> getTimedItems db user startTimedThreadCutoff forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchChatError` const (pure ()) cleanupDeletedContacts user = do - contacts <- withStore' (`getDeletedContacts` user) + vr <- chatVersionRange + contacts <- withStore' $ \db -> getDeletedContacts db vr user forM_ contacts $ \ct -> - withStore' (\db -> deleteContactWithoutGroups db user ct) + withStore (\db -> deleteContactWithoutGroups db user ct) `catchChatError` (toView . CRChatError (Just user)) cleanupMessages = do ts <- liftIO getCurrentTime @@ -3173,7 +3225,7 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do vr <- chatVersionRange case cType of CTDirect -> do - (ct, CChatItem _ ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, CChatItem _ ci) <- withStore $ \db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId deleteDirectCI user ct ci True True >>= toView CTGroup -> do (gInfo, CChatItem _ ci) <- withStore $ \db -> (,) <$> getGroupInfo db vr user chatId <*> getGroupChatItem db user chatId itemId @@ -3196,11 +3248,11 @@ expireChatItems user@User {userId} ttl sync = do -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs waitChatStartedAndActivated - contacts <- withStoreCtx' (Just "expireChatItems, getUserContacts") (`getUserContacts` user) + contacts <- withStoreCtx' (Just "expireChatItems, getUserContacts") $ \db -> getUserContacts db vr user loop contacts $ processContact expirationDate waitChatStartedAndActivated - groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") (\db -> getUserGroupDetails db vr user Nothing Nothing) - loop groups $ processGroup expirationDate createdAtCutoff + groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") $ \db -> getUserGroupDetails db vr user Nothing Nothing + loop groups $ processGroup vr expirationDate createdAtCutoff where loop :: [a] -> (a -> m ()) -> m () loop [] _ = pure () @@ -3219,15 +3271,17 @@ expireChatItems user@User {userId} ttl sync = do processContact expirationDate ct = do waitChatStartedAndActivated filesInfo <- withStoreCtx' (Just "processContact, getContactExpiredFileInfo") $ \db -> getContactExpiredFileInfo db user ct expirationDate - deleteFilesAndConns user filesInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo withStoreCtx' (Just "processContact, deleteContactExpiredCIs") $ \db -> deleteContactExpiredCIs db user ct expirationDate - processGroup :: UTCTime -> UTCTime -> GroupInfo -> m () - processGroup expirationDate createdAtCutoff gInfo = do + processGroup :: (PQSupport -> VersionRangeChat) -> UTCTime -> UTCTime -> GroupInfo -> m () + processGroup vr expirationDate createdAtCutoff gInfo = do waitChatStartedAndActivated filesInfo <- withStoreCtx' (Just "processGroup, getGroupExpiredFileInfo") $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff - deleteFilesAndConns user filesInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo withStoreCtx' (Just "processGroup, deleteGroupExpiredCIs") $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff - membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db user gInfo + membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db vr user gInfo forM_ membersToDelete $ \m -> withStoreCtx' (Just "processGroup, deleteGroupMember") $ \db -> deleteGroupMember db user m processAgentMessage :: forall m. ChatMonad m => ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () @@ -3272,7 +3326,7 @@ processAgentMsgSndFile _corrId aFileId msg = where process :: User -> m () process user = do - (ft@FileTransferMeta {fileId, cancelled}, sfts) <- withStore $ \db -> do + (ft@FileTransferMeta {fileId, xftpRedirectFor, cancelled}, sfts) <- withStore $ \db -> do fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId getSndFileTransfer db user fileId vr <- chatVersionRange @@ -3281,58 +3335,70 @@ processAgentMsgSndFile _corrId aFileId msg = let status = CIFSSndTransfer {sndProgress, sndTotal} ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status - getChatItemByFileId db vr user fileId + lookupChatItemByFileId db vr user fileId toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal SFDONE sndDescr rfds -> do withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr) - ci@(AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) <- - withStore $ \db -> getChatItemByFileId db vr user fileId - case (msgId_, itemDeleted) of - (Just sharedMsgId, Nothing) -> do - when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send" - -- TODO either update database status or move to SFPROG - toView $ CRSndFileProgressXFTP user ci ft 1 1 - case (rfds, sfts, d, cInfo) of - (rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do - withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) - msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct - withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId - withAgent (`xftpDeleteSndFileInternal` aFileId) - (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do - ms <- withStore' $ \db -> getGroupMembers db user g - let rfdsMemberFTs = zip rfds $ memberFTs ms - extraRFDs = drop (length rfdsMemberFTs) rfds - withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) - forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchChatError` (toView . CRChatError (Just user)) - ci' <- withStore $ \db -> do - liftIO $ updateCIFileStatus db user fileId CIFSSndComplete - getChatItemByFileId db vr user fileId - withAgent (`xftpDeleteSndFileInternal` aFileId) - toView $ CRSndFileCompleteXFTP user ci' ft - where - memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] - memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') + ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId + case ci of + Nothing -> do + withAgent (`xftpDeleteSndFileInternal` aFileId) + withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText rfds) + case rfds of + [] -> sendFileError "no receiver descriptions" fileId vr ft + rfd : _ -> case [fd | fd@(FD.ValidFileDescription FD.FileDescription {chunks = [_]}) <- rfds] of + [] -> case xftpRedirectFor of + Nothing -> xftpSndFileRedirect user fileId rfd >>= toView . CRSndFileRedirectStartXFTP user ft + Just _ -> sendFileError "Prohibit chaining redirects" fileId vr ft + rfds' -> do + -- we have 1 chunk - use it as URI whether it is redirect or not + ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor + toView $ CRSndStandaloneFileComplete user ft' $ map (decodeLatin1 . strEncode . FD.fileDescriptionURI) rfds' + Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) -> + case (msgId_, itemDeleted) of + (Just sharedMsgId, Nothing) -> do + when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send" + -- TODO either update database status or move to SFPROG + toView $ CRSndFileProgressXFTP user ci ft 1 1 + case (rfds, sfts, d, cInfo) of + (rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do + withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) + msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage user ct + withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId + withAgent (`xftpDeleteSndFileInternal` aFileId) + (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do + ms <- withStore' $ \db -> getGroupMembers db vr user g + let rfdsMemberFTs = zip rfds $ memberFTs ms + extraRFDs = drop (length rfdsMemberFTs) rfds + withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) + forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchChatError` (toView . CRChatError (Just user)) + ci' <- withStore $ \db -> do + liftIO $ updateCIFileStatus db user fileId CIFSSndComplete + getChatItemByFileId db vr user fileId + withAgent (`xftpDeleteSndFileInternal` aFileId) + toView $ CRSndFileCompleteXFTP user ci' ft where - mConns' = mapMaybe useMember ms - sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts - useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}} - | (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) = Just (groupMemberId, conn) - | otherwise = Nothing - useMember _ = Nothing - sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m () - sendToMember (rfd, (conn, sft)) = - void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn msg' $ GroupId groupId - _ -> pure () - _ -> pure () -- TODO error? + memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] + memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') + where + mConns' = mapMaybe useMember ms + sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts + useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}} + | (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) = Just (groupMemberId, conn) + | otherwise = Nothing + useMember _ = Nothing + sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m () + sendToMember (rfd, (conn, sft)) = + void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> do + (sndMsg, msgDeliveryId, _) <- sendDirectMemberMessage conn msg' groupId + pure (sndMsg, msgDeliveryId) + _ -> pure () + _ -> pure () -- TODO error? SFERR e | temporaryAgentError e -> throwChatError $ CEXFTPSndFile fileId (AgentSndFileId aFileId) e - | otherwise -> do - ci <- withStore $ \db -> do - liftIO $ updateFileCancelled db user fileId CIFSSndError - getChatItemByFileId db vr user fileId - withAgent (`xftpDeleteSndFileInternal` aFileId) - toView $ CRSndFileError user ci + | otherwise -> + sendFileError (tshow e) fileId vr ft where fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text fileDescrText = safeDecodeUtf8 . strEncode @@ -3350,6 +3416,14 @@ processAgentMsgSndFile _corrId aFileId msg = case L.nonEmpty fds of Just fds' -> loopSend fds' Nothing -> pure msgDeliveryId + sendFileError :: Text -> Int64 -> (PQSupport -> VersionRangeChat) -> FileTransferMeta -> m () + sendFileError err fileId vr ft = do + logError $ "Sent file error: " <> err + ci <- withStore $ \db -> do + liftIO $ updateFileCancelled db user fileId CIFSSndError + lookupChatItemByFileId db vr user fileId + withAgent (`xftpDeleteSndFileInternal` aFileId) + toView $ CRSndFileError user ci ft splitFileDescr :: ChatMonad m => RcvFileDescrText -> m (NonEmpty FileDescr) splitFileDescr rfdText = do @@ -3383,32 +3457,32 @@ processAgentMsgRcvFile _corrId aFileId msg = let status = CIFSRcvTransfer {rcvProgress, rcvTotal} ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status - getChatItemByFileId db vr user fileId - toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal + lookupChatItemByFileId db vr user fileId + toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal ft RFDONE xftpPath -> case liveRcvFileTransferPath ft of Nothing -> throwChatError $ CEInternalError "no target path for received XFTP file" Just targetPath -> do fsTargetPath <- toFSFilePath targetPath renameFile xftpPath fsTargetPath - ci <- withStore $ \db -> do + ci_ <- withStore $ \db -> do liftIO $ do updateRcvFileStatus db fileId FSComplete updateCIFileStatus db user fileId CIFSRcvComplete - getChatItemByFileId db vr user fileId + lookupChatItemByFileId db vr user fileId agentXFTPDeleteRcvFile aFileId fileId - toView $ CRRcvFileComplete user ci + toView $ maybe (CRRcvStandaloneFileComplete user fsTargetPath ft) (CRRcvFileComplete user) ci_ RFERR e | temporaryAgentError e -> throwChatError $ CEXFTPRcvFile fileId (AgentRcvFileId aFileId) e | otherwise -> do ci <- withStore $ \db -> do liftIO $ updateFileCancelled db user fileId CIFSRcvError - getChatItemByFileId db vr user fileId + lookupChatItemByFileId db vr user fileId agentXFTPDeleteRcvFile aFileId fileId - toView $ CRRcvFileError user ci e + toView $ CRRcvFileError user ci e ft -processAgentMessageConn :: forall m. ChatMonad m => VersionRange -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () +processAgentMessageConn :: forall m. ChatMonad m => (PQSupport -> VersionRangeChat) -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do entity <- withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of @@ -3439,21 +3513,38 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = agentMsgConnStatus :: ACommand 'Agent e -> Maybe ConnStatus agentMsgConnStatus = \case CONF {} -> Just ConnRequested - INFO _ -> Just ConnSndReady - CON -> Just ConnReady + INFO {} -> Just ConnSndReady + CON _ -> Just ConnReady _ -> Nothing + processCONFpqSupport :: Connection -> PQSupport -> m Connection + processCONFpqSupport conn@Connection {connId, pqSupport = pq} pq' + | pq == PQSupportOn && pq' == PQSupportOff = do + let pqEnc' = CR.pqSupportToEnc pq' + withStore' $ \db -> updateConnSupportPQ db connId pq' pqEnc' + pure (conn {pqSupport = pq', pqEncryption = pqEnc'} :: Connection) + | pq /= pq' = do + messageWarning "processCONFpqSupport: unexpected pqSupport change" + pure conn + | otherwise = pure conn + + processINFOpqSupport :: Connection -> PQSupport -> m () + processINFOpqSupport Connection {pqSupport = pq} pq' = + when (pq /= pq') $ messageWarning "processINFOpqSupport: unexpected pqSupport change" + processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case + processDirectMessage agentMsg connEntity conn@Connection {connId, connChatVersion, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of - CONF confId _ connInfo -> do + CONF confId pqSupport _ connInfo -> do + conn' <- processCONFpqSupport conn pqSupport -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing False - conn' <- saveConnInfo conn connInfo + conn'' <- saveConnInfo conn' connInfo -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn' confId $ XInfo profileToSend - INFO connInfo -> do + allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend + INFO pqSupport connInfo -> do + processINFOpqSupport conn pqSupport _conn' <- saveConnInfo conn connInfo pure () MSG meta _msgFlags msgBody -> do @@ -3494,60 +3585,64 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv hostConnId (Just directConnReq) xGrpMemIntroCont CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" MSG msgMeta _msgFlags msgBody -> do - checkIntegrityCreateItem (CDDirectRcv ct) msgMeta - cmdId <- createAckCmd conn + let MsgMeta {pqEncryption} = msgMeta + (ct', conn') <- updateContactPQRcv user ct conn pqEncryption + checkIntegrityCreateItem (CDDirectRcv ct') msgMeta + cmdId <- createAckCmd conn' withAckMessage agentConnId cmdId msgMeta $ do - (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn msgMeta cmdId msgBody - let ct' = ct {activeConn = Just conn'} :: Contact - assertDirectAllowed user MDRcv ct' $ toCMEventTag event - updateChatLock "directMessage" event + (conn'', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn' msgMeta cmdId msgBody + let ct'' = ct' {activeConn = Just conn''} :: Contact + assertDirectAllowed user MDRcv ct'' $ toCMEventTag event + updateChatLock "direct message" event case event of - XMsgNew mc -> newContentMessage ct' mc msg msgMeta - XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct' sharedMsgId mContent msg msgMeta ttl live - XMsgDel sharedMsgId _ -> messageDelete ct' sharedMsgId msg msgMeta - XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct' sharedMsgId reaction add msg msgMeta + XMsgNew mc -> newContentMessage ct'' mc msg msgMeta + XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live + XMsgDel sharedMsgId _ -> messageDelete ct'' sharedMsgId msg msgMeta + XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile - XFile fInv -> processFileInvitation' ct' fInv msg msgMeta - XFileCancel sharedMsgId -> xFileCancel ct' sharedMsgId - XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct' sharedMsgId fileConnReq_ fName - XInfo p -> xInfo ct' p - XDirectDel -> xDirectDel ct' msg msgMeta - XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta - XInfoProbe probe -> xInfoProbe (COMContact ct') probe - XInfoProbeCheck probeHash -> xInfoProbeCheck (COMContact ct') probeHash - XInfoProbeOk probe -> xInfoProbeOk (COMContact ct') probe - XCallInv callId invitation -> xCallInv ct' callId invitation msg msgMeta - XCallOffer callId offer -> xCallOffer ct' callId offer msg - XCallAnswer callId answer -> xCallAnswer ct' callId answer msg - XCallExtra callId extraInfo -> xCallExtra ct' callId extraInfo msg - XCallEnd callId -> xCallEnd ct' callId msg - BFileChunk sharedMsgId chunk -> bFileChunk ct' sharedMsgId chunk msgMeta + XFile fInv -> processFileInvitation' ct'' fInv msg msgMeta + XFileCancel sharedMsgId -> xFileCancel ct'' sharedMsgId + XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct'' sharedMsgId fileConnReq_ fName + XInfo p -> xInfo ct'' p + XDirectDel -> xDirectDel ct'' msg msgMeta + XGrpInv gInv -> processGroupInvitation ct'' gInv msg msgMeta + XInfoProbe probe -> xInfoProbe (COMContact ct'') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMContact ct'') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMContact ct'') probe + XCallInv callId invitation -> xCallInv ct'' callId invitation msg msgMeta + XCallOffer callId offer -> xCallOffer ct'' callId offer msg + XCallAnswer callId answer -> xCallAnswer ct'' callId answer msg + XCallExtra callId extraInfo -> xCallExtra ct'' callId extraInfo msg + XCallEnd callId -> xCallEnd ct'' callId msg + BFileChunk sharedMsgId chunk -> bFileChunk ct'' sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) - let Contact {chatSettings = ChatSettings {sendRcpts}} = ct' + let Contact {chatSettings = ChatSettings {sendRcpts}} = ct'' pure $ fromMaybe (sendRcptsContacts user) sendRcpts && hasDeliveryReceipt (toCMEventTag event) RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ directMsgReceived ct conn msgMeta msgRcpt - CONF confId _ connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - conn' <- updatePeerChatVRange conn chatVRange + CONF confId pqSupport _ connInfo -> do + conn' <- processCONFpqSupport conn pqSupport + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn' connInfo + conn'' <- updatePeerChatVRange conn' chatVRange case chatMsgEvent of -- confirming direct connection with a member XGrpMemInfo _memId _memProfile -> do -- TODO check member ID -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn' confId XOk + allowAgentConnectionAsync user conn'' confId XOk XInfo profile -> do ct' <- processContactProfileUpdate ct profile False `catchChatError` const (pure ct) -- [incognito] send incognito profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId let p = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') False - allowAgentConnectionAsync user conn' confId $ XInfo p + allowAgentConnectionAsync user conn'' confId $ XInfo p void $ withStore' $ \db -> resetMemberContactFields db ct' _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" - INFO connInfo -> do + INFO pqSupport connInfo -> do + processINFOpqSupport conn pqSupport ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of @@ -3559,33 +3654,38 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void $ processContactProfileUpdate ct profile False XOk -> pure () _ -> messageError "INFO for existing contact must have x.grp.mem.info, x.info or x.ok" - CON -> + CON pqEnc -> withStore' (\db -> getViaGroupMember db vr user ct) >>= \case Nothing -> do + when (pqEnc == PQEncOn) $ withStore' $ \db -> updateConnPQEnabledCON db connId pqEnc + let conn' = conn {pqSndEnabled = Just pqEnc, pqRcvEnabled = Just pqEnc} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - setContactNetworkStatus ct NSConnected - toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) - when (directOrUsed ct) $ createFeatureEnabledItems ct - when (contactConnInitiated conn) $ do - let Connection {groupLinkId} = conn + setContactNetworkStatus ct' NSConnected + toView $ CRContactConnected user ct' (fmap fromLocalProfile incognitoProfile) + when (directOrUsed ct') $ do + createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo pqEnc) Nothing + createFeatureEnabledItems ct' + when (contactConnInitiated conn') $ do + let Connection {groupLinkId} = conn' doProbeContacts = isJust groupLinkId - probeMatchingContactsAndMembers ct (contactConnIncognito ct) doProbeContacts - withStore' $ \db -> resetContactConnInitiated db user conn + probeMatchingContactsAndMembers ct' (contactConnIncognito ct') doProbeContacts + withStore' $ \db -> resetContactConnInitiated db user conn' forM_ viaUserContactLink $ \userContactLinkId -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId let (UserContactLink {autoAccept}, groupId_, gLinkMemRole) = ucl forM_ autoAccept $ \(AutoAccept {autoReply = mc_}) -> forM_ mc_ $ \mc -> do - (msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) - ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) - toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) + (msg, _) <- sendDirectContactMessage user ct' (XMsgNew $ MCSimple (extMsgContent mc Nothing)) + ci <- saveSndChatItem user (CDDirectSnd ct') msg (CISndMsgContent mc) + toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct') ci) forM_ groupId_ $ \groupId -> do groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId subMode <- chatReadVar subscriptionMode groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode gVar <- asks random - withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode + withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode Just (gInfo, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct @@ -3614,18 +3714,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processErr cryptoErr = do let e@(mde, n) = agentMsgDecryptError cryptoErr ci_ <- withStore $ \db -> - getDirectChatItemsLast db user contactId 1 "" + getDirectChatItemLast db user contactId >>= liftIO . mapM (\(ci, content') -> updateDirectChatItem' db user contactId ci content' False Nothing) - . (mdeUpdatedCI e <=< headMaybe) + . mdeUpdatedCI e case ci_ of Just ci -> toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) _ -> do toView $ CRContactRatchetSync user ct (RatchetSyncProgress rss cStats) createInternalChatItem user (CDDirectRcv ct) (CIRcvDecryptionError mde n) Nothing - headMaybe = \case - x : _ -> Just x - _ -> Nothing ratchetSyncEventItem ct' = do toView $ CRContactRatchetSync user ct' (RatchetSyncProgress rss cStats) createInternalChatItem user (CDDirectRcv ct') (CIRcvConnEvent $ RCERatchetSync rss) Nothing @@ -3655,7 +3752,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupConnReq@(CRInvitationUri _ _) -> case cmdFunction of -- [async agent commands] XGrpMemIntro continuation on receiving INV CFCreateConnGrpMemInv - | isCompatibleRange (fromJVersionRange $ peerChatVRange conn) groupNoDirectVRange -> sendWithoutDirectCReq + | maxVersion (peerChatVRange conn) >= groupDirectInvVersion -> sendWithoutDirectCReq | otherwise -> sendWithDirectCReq where sendWithoutDirectCReq = do @@ -3673,7 +3770,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv hostConnId (Just directConnReq) XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} -- [async agent commands] group link auto-accept continuation on receiving INV CFCreateConnGrpInv -> do - ct <- withStore $ \db -> getContactViaMember db user m + ct <- withStore $ \db -> getContactViaMember db vr user m withStore' $ \db -> setNewContactMemberConnRequest db user m cReq groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo sendGrpInvitation ct m groupLinkId @@ -3681,14 +3778,23 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> m () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do + currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let GroupMember {memberRole = userRole, memberId = userMemberId} = membership - groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile groupLinkId - (_msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv + groupInv = + GroupInvitation + { fromMember = MemberIdRole userMemberId userRole, + invitedMember = MemberIdRole memberId memRole, + connRequest = cReq, + groupProfile, + groupLinkId = groupLinkId, + groupSize = Just currentMemCount + } + (_msg, _) <- sendDirectContactMessage user ct $ XGrpInv groupInv -- we could link chat item with sent group invitation message (_msg) createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" - CONF confId _ connInfo -> do + CONF confId _pqSupport _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo conn' <- updatePeerChatVRange conn chatVRange case memberCategory m of @@ -3712,7 +3818,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = allowAgentConnectionAsync user conn' confId $ XGrpMemInfo membershipMemId membershipProfile | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" _ -> messageError "CONF from member must have x.grp.mem.info" - INFO connInfo -> do + INFO _pqSupport connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of @@ -3725,7 +3831,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XOk -> pure () _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () - CON -> do + CON _pqEnc -> do withStore' $ \db -> do updateGroupMemberStatus db userId m GSMemConnected unless (memberActive membership) $ @@ -3736,20 +3842,19 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case memberCategory m of GCHostMember -> do toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} + createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupE2EEInfo $ E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems gInfo m let GroupInfo {groupProfile = GroupProfile {description}} = gInfo memberConnectedChatItem gInfo m unless expectHistory $ forM_ description $ groupDescriptionChatItem gInfo m where - expectHistory = - groupFeatureAllowed SGFHistory gInfo - && isCompatibleRange (memberChatVRange' m) groupHistoryIncludeWelcomeVRange + expectHistory = groupFeatureAllowed SGFHistory gInfo && m `supportsVersion` groupHistoryIncludeWelcomeVersion GCInviteeMember -> do memberConnectedChatItem gInfo m toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} let Connection {viaUserContactLink} = conn when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem - members <- withStore' $ \db -> getGroupMembers db user gInfo + members <- withStore' $ \db -> getGroupMembers db vr user gInfo void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m sendIntroductions members when (groupFeatureAllowed SGFHistory gInfo) sendHistory @@ -3757,11 +3862,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpLinkMem = do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo profileToSend = profileToSendOnAccept user profileMode True - void $ sendDirectMessage conn (XGrpLinkMem profileToSend) (GroupId groupId) + void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId sendIntroductions members = do - intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m + intros <- withStore' $ \db -> createIntroductions db (maxVersion $ vr PQSupportOff) members m shuffledIntros <- liftIO $ shuffleIntros intros - if isCompatibleRange (memberChatVRange' m) batchSendVRange + if m `supportsVersion` batchSendVersion then do let events = map (memberIntro . reMember) shuffledIntros forM_ (L.nonEmpty events) $ \events' -> @@ -3783,10 +3888,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image processIntro intro@GroupMemberIntro {introId} = do - void $ sendDirectMessage conn (memberIntro $ reMember intro) (GroupId groupId) + void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId withStore' $ \db -> updateIntroStatus db introId GMIntroSent sendHistory = - when (isCompatibleRange (memberChatVRange' m) batchSendVRange) $ do + when (m `supportsVersion` batchSendVersion) $ do (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo 100) (errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items let errors = map ChatErrorStore errs <> errs' @@ -3796,7 +3901,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendGroupMemberMessages user conn events'' groupId descrEvent_ :: Maybe (ChatMsgEvent 'Json) descrEvent_ - | isCompatibleRange (memberChatVRange' m) groupHistoryIncludeWelcomeVRange = do + | m `supportsVersion` groupHistoryIncludeWelcomeVersion = do let GroupInfo {groupProfile = GroupProfile {description}} = gInfo fmap (\descr -> XMsgNew $ MCSimple $ extMsgContent (MCText descr) Nothing) description | otherwise = Nothing @@ -3863,7 +3968,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure msgForwardEvents _ -> do let memCategory = memberCategory m - withStore' (\db -> getViaGroupContact db user m) >>= \case + withStore' (\db -> getViaGroupContact db vr user m) >>= \case Nothing -> do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership @@ -3880,14 +3985,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemCon = \case GCPreMember -> forM_ (invitedByGroupMemberId membership) $ \hostId -> do - host <- withStore $ \db -> getGroupMember db user groupId hostId + host <- withStore $ \db -> getGroupMember db vr user groupId hostId forM_ (memberConn host) $ \hostConn -> - void $ sendDirectMessage hostConn (XGrpMemCon memberId) (GroupId groupId) + void $ sendDirectMemberMessage hostConn (XGrpMemCon memberId) groupId GCPostMember -> forM_ (invitedByGroupMemberId m) $ \invitingMemberId -> do - im <- withStore $ \db -> getGroupMember db user groupId invitingMemberId + im <- withStore $ \db -> getGroupMember db vr user groupId invitingMemberId forM_ (memberConn im) $ \imConn -> - void $ sendDirectMessage imConn (XGrpMemCon memberId) (GroupId groupId) + void $ sendDirectMemberMessage imConn (XGrpMemCon memberId) groupId _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta @@ -3958,10 +4063,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- members introduced to this invited member introducedMembers <- if memberCategory m == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedMembers db user m highlyAvailable + then withStore' $ \db -> getForwardIntroducedMembers db vr user m highlyAvailable else pure [] -- invited members to which this member was introduced - invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db user m highlyAvailable + invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db vr user m highlyAvailable let GroupMember {memberId} = m ms = forwardedToGroupMembers (introducedMembers <> invitedMembers) chatMsg' msg = XGrpMsgForward memberId chatMsg' brokerTs @@ -4059,7 +4164,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case agentMsg of -- SMP CONF for SndFileConnection happens for direct file protocol -- when recipient of the file "joins" connection created by the sender - CONF confId _ connInfo -> do + CONF confId _pqSupport _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of @@ -4071,7 +4176,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.file.acpt: fileName is different from expected" _ -> messageError "CONF from file connection must have x.file.acpt" - CON -> do + CON _ -> do ci <- withStore $ \db -> do liftIO $ updateSndFileStatus db ft FSConnected updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 @@ -4085,10 +4190,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case err of SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do ci <- withStore $ \db -> do - getChatRefByFileId db user fileId >>= \case - ChatRef CTDirect _ -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled + liftIO (lookupChatRefByFileId db user fileId) >>= \case + Just (ChatRef CTDirect _) -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled _ -> pure () - getChatItemByFileId db vr user fileId + lookupChatItemByFileId db vr user fileId toView $ CRSndFileRcvCancelled user ci ft _ -> throwChatError $ CEFileSend fileId err MSG meta _ _ -> withAckMessage' agentConnId conn meta $ pure () @@ -4110,17 +4215,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = fileInvConnReq@(CRInvitationUri _ _) -> case cmdFunction of -- [async agent commands] direct XFileAcptInv continuation on receiving INV CFCreateConnFileInvDirect -> do - ct <- withStore $ \db -> getContactByFileId db user fileId + ct <- withStore $ \db -> getContactByFileId db vr user fileId sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - void $ sendDirectContactMessage ct (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) + void $ sendDirectContactMessage user ct (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) -- [async agent commands] group XFileAcptInv continuation on receiving INV CFCreateConnFileInvGroup -> case grpMemberId of Just gMemberId -> do - GroupMember {groupId, activeConn} <- withStore $ \db -> getGroupMemberById db user gMemberId + GroupMember {groupId, activeConn} <- withStore $ \db -> getGroupMemberById db vr user gMemberId case activeConn of Just gMemberConn -> do sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - void $ sendDirectMessage gMemberConn (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) $ GroupId groupId + void $ sendDirectMemberMessage gMemberConn (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) groupId _ -> throwChatError $ CECommandError "no GroupMember activeConn" _ -> throwChatError $ CECommandError "no grpMemberId" _ -> throwChatError $ CECommandError "unexpected cmdFunction" @@ -4128,13 +4233,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- SMP CONF for RcvFileConnection happens for group file protocol -- when sender of the file "joins" connection created by the recipient -- (sender doesn't create connections for all group members) - CONF confId _ connInfo -> do + CONF confId _pqSupport _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of XOk -> allowAgentConnectionAsync user conn' confId XOk -- [async agent commands] no continuation needed, but command should be asynchronous for stability _ -> pure () - CON -> startReceivingFile user fileId + CON _ -> startReceivingFile user fileId MSG meta _ msgBody -> do parseFileChunk msgBody >>= receiveFileChunk ft (Just conn) meta OK -> @@ -4189,11 +4294,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processUserContactRequest :: ACommand 'Agent e -> ConnectionEntity -> Connection -> UserContact -> m () processUserContactRequest agentMsg connEntity conn UserContact {userContactLinkId} = case agentMsg of - REQ invId _ connInfo -> do + REQ invId pqSupport _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo case chatMsgEvent of - XContact p xContactId_ -> profileContactRequest invId chatVRange p xContactId_ - XInfo p -> profileContactRequest invId chatVRange p Nothing + XContact p xContactId_ -> profileContactRequest invId chatVRange p xContactId_ pqSupport + XInfo p -> profileContactRequest invId chatVRange p Nothing pqSupport -- TODO show/log error, other events in contact request _ -> pure () MERR _ err -> do @@ -4205,9 +4310,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO add debugging output _ -> pure () where - profileContactRequest :: InvitationId -> VersionRange -> Profile -> Maybe XContactId -> m () - profileContactRequest invId chatVRange p xContactId_ = do - withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_) >>= \case + profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> m () + profileContactRequest invId chatVRange p xContactId_ reqPQSup = do + withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId @@ -4217,18 +4322,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Nothing -> do -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - ct <- acceptContactRequestAsync user cReq incognitoProfile True + pqSup <- chatReadVar pqExperimentalEnabled + let pqSup' = pqSup `CR.pqSupportAnd` reqPQSup + ct <- acceptContactRequestAsync user cReq incognitoProfile True pqSup' toView $ CRAcceptingContactRequest user ct Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - if isCompatibleRange chatVRange groupLinkNoContactVRange + if maxVersion chatVRange >= groupFastLinkJoinVersion then do mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CRAcceptingGroupJoinRequestMember user gInfo mem else do - ct <- acceptContactRequestAsync user cReq profileMode False + -- TODO v5.7 remove old API (or v6.0?) + ct <- acceptContactRequestAsync user cReq profileMode False PQSupportOff toView $ CRAcceptingGroupJoinRequest user gInfo ct _ -> toView $ CRReceivedContactRequest user cReq @@ -4341,14 +4449,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendProbe probe cs <- if doProbeContacts - then map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) + then map COMContact <$> withStore' (\db -> getMatchingContacts db vr user ct) else pure [] - ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db user ct) + ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db vr user ct) sendProbeHashes (cs <> ms) probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () - sendProbe probe = void . sendDirectContactMessage ct $ XInfoProbe probe + sendProbe probe = void . sendDirectContactMessage user ct $ XInfoProbe probe probeMatchingMemberContact :: GroupMember -> IncognitoEnabled -> m () probeMatchingMemberContact GroupMember {activeConn = Nothing} _ = pure () @@ -4359,12 +4467,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId $ COMGroupMember m sendProbe probe - cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db user m) + cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db vr user m) sendProbeHashes cs probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () - sendProbe probe = void $ sendDirectMessage conn (XInfoProbe probe) (GroupId groupId) + sendProbe probe = void $ sendDirectMemberMessage conn (XInfoProbe probe) groupId sendProbeHashes :: [ContactOrMember] -> Probe -> Int64 -> m () sendProbeHashes cgms probe probeId = @@ -4373,12 +4481,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeHash = ProbeHash $ C.sha256Hash (unProbe probe) sendProbeHash :: ContactOrMember -> m () sendProbeHash cgm@(COMContact c) = do - void . sendDirectContactMessage c $ XInfoProbeCheck probeHash + void . sendDirectContactMessage user c $ XInfoProbeCheck probeHash withStore' $ \db -> createSentProbeHash db userId probeId cgm sendProbeHash (COMGroupMember GroupMember {activeConn = Nothing}) = pure () sendProbeHash cgm@(COMGroupMember m@GroupMember {groupId, activeConn = Just conn}) = when (memberCurrent m) $ do - void $ sendDirectMessage conn (XInfoProbeCheck probeHash) (GroupId groupId) + void $ sendDirectMemberMessage conn (XInfoProbeCheck probeHash) groupId withStore' $ \db -> createSentProbeHash db userId probeId cgm messageWarning :: Text -> m () @@ -4565,7 +4673,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles | otherwise = - withStore' (\db -> getCIModeration db user gInfo memberId sharedMsgId_) >>= \case + withStore' (\db -> getCIModeration db vr user gInfo memberId sharedMsgId_) >>= \case Just ciModeration -> do applyModeration ciModeration withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ @@ -4735,9 +4843,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- receiving via a separate connection Just fileConnReq -> do subMode <- chatReadVar subscriptionMode - dm <- directMessage XOk + dm <- encodeConnInfo XOk connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode - withStore' $ \db -> createSndDirectFTConnection db user fileId connIds subMode + withStore' $ \db -> createSndDirectFTConnection db vr user fileId connIds subMode -- receiving inline _ -> do event <- withStore $ \db -> do @@ -4747,7 +4855,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView event ifM (allowSendInline fileSize fileInline) - (sendDirectFileInline ct ft sharedMsgId) + (sendDirectFileInline user ct ft sharedMsgId) (messageError "x.file.acpt.inv: fileSize is bigger than allowed to send inline") else messageError "x.file.acpt.inv: fileName is different from expected" @@ -4832,9 +4940,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = subMode <- chatReadVar subscriptionMode -- receiving via a separate connection -- [async agent commands] no continuation needed, but command should be asynchronous for stability - dm <- directMessage XOk + dm <- encodeConnInfo XOk connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode - withStore' $ \db -> createSndGroupFileTransferConnection db user fileId connIds m subMode + withStore' $ \db -> createSndGroupFileTransferConnection db vr user fileId connIds m subMode (_, Just conn) -> do -- receiving inline event <- withStore $ \db -> do @@ -4857,7 +4965,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processGroupInvitation ct inv msg msgMeta = do let Contact {localDisplayName = c, activeConn} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv - forM_ activeConn $ \Connection {connId, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do + forM_ activeConn $ \Connection {connId, connChatVersion, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile @@ -4866,11 +4974,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if sameGroupLinkId groupLinkId groupLinkId' then do subMode <- chatReadVar subscriptionMode - dm <- directMessage $ XGrpAcpt membershipMemId + dm <- encodeConnInfo $ XGrpAcpt membershipMemId connIds <- joinAgentConnectionAsync user True connRequest dm subMode withStore' $ \db -> do setViaGroupLinkHash db groupId connId - createMemberConnectionAsync db user hostId connIds (fromJVersionRange peerChatVRange) subMode + createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) @@ -4901,7 +5009,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if directOrUsed c then do ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted - contactConns <- withStore' $ \db -> getContactConnections db userId ct' + contactConns <- withStore' $ \db -> getContactConnections db vr userId ct' deleteAgentConnectionsAsync user $ map aConnId contactConns forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} @@ -4910,9 +5018,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) toView $ CRContactDeletedByContact user ct'' else do - contactConns <- withStore' $ \db -> getContactConnections db userId c + contactConns <- withStore' $ \db -> getContactConnections db vr userId c deleteAgentConnectionsAsync user $ map aConnId contactConns - withStore' $ \db -> deleteContact db user c + withStore $ \db -> deleteContact db user c where brokerTs = metaBrokerTs msgMeta @@ -4981,7 +5089,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do - mCt <- withStore $ \db -> getContact db user mContactId + mCt <- withStore $ \db -> getContact db vr user mContactId if canUpdateProfile mCt then do (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' @@ -5022,7 +5130,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito when (contactMerge && not (contactOrMemberIncognito cgm2)) $ do - cgm1s <- withStore' $ \db -> matchReceivedProbe db user cgm2 probe + cgm1s <- withStore' $ \db -> matchReceivedProbe db vr user cgm2 probe let cgm1s' = filter (not . contactOrMemberIncognito) cgm1s probeMatches cgm1s' cgm2 where @@ -5038,7 +5146,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito when (contactMerge && not (contactOrMemberIncognito cgm1)) $ do - cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db user cgm1 probeHash + cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db vr user cgm1 probeHash forM_ cgm2Probe_ $ \(cgm2, probe) -> unless (contactOrMemberIncognito cgm2) . void $ probeMatch cgm1 cgm2 probe @@ -5050,12 +5158,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case cgm2 of COMContact c2@Contact {contactId = cId2, profile = p2} | cId1 /= cId2 && profilesMatch p1 p2 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe + void . sendDirectContactMessage user c1 $ XInfoProbeOk probe COMContact <$$> mergeContacts c1 c2 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" >> pure Nothing COMGroupMember m2@GroupMember {memberProfile = p2, memberContactId} | isNothing memberContactId && profilesMatch p1 p2 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe + void . sendDirectContactMessage user c1 $ XInfoProbeOk probe COMContact <$$> associateMemberAndContact c1 m2 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact" >> pure Nothing COMGroupMember GroupMember {activeConn = Nothing} -> pure Nothing @@ -5063,14 +5171,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case cgm2 of COMContact c2@Contact {profile = p2} | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> do - void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) + void $ sendDirectMemberMessage conn (XInfoProbeOk probe) groupId COMContact <$$> associateMemberAndContact c2 m1 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact or member not current" >> pure Nothing COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing xInfoProbeOk :: ContactOrMember -> Probe -> m () xInfoProbeOk cgm1 probe = do - cgm2 <- withStore' $ \db -> matchSentProbe db user cgm1 probe + cgm2 <- withStore' $ \db -> matchSentProbe db vr user cgm1 probe case cgm1 of COMContact c1@Contact {contactId = cId1} -> case cgm2 of @@ -5206,7 +5314,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure Nothing where merge c1' c2' = do - c2'' <- withStore $ \db -> mergeContactRecords db user c1' c2' + c2'' <- withStore $ \db -> mergeContactRecords db vr user c1' c2' toView $ CRContactsMerged user c1' c2' c2'' when (directOrUsed c2'') $ showSecurityCodeChanged c2'' pure $ Just c2'' @@ -5251,7 +5359,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = associateContactWithMember :: GroupMember -> Contact -> m Contact associateContactWithMember m1@GroupMember {groupId} c2 = do - c2' <- withStore $ \db -> associateContactWithMemberRecord db user m1 c2 + c2' <- withStore $ \db -> associateContactWithMemberRecord db vr user m1 c2 g <- withStore $ \db -> getGroupInfo db vr user groupId toView $ CRContactAndMemberAssociated user c2 g m1 c2' pure c2' @@ -5277,9 +5385,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msg brokerTs = do checkHostRole m memRole unless (sameMemberId memId $ membership gInfo) $ - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do - updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db user m unknownMember memInfo + updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db vr user m unknownMember memInfo toView $ CRUnknownMemberAnnounced user gInfo m unknownMember updatedMember memberAnnouncedToView updatedMember Right _ -> messageError "x.grp.mem.new error: member already exists" @@ -5297,7 +5405,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) memRestrictions = do case memberCategory m of GCHostMember -> - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right _ -> messageError "x.grp.mem.intro ignored: member already exists" Left _ -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) @@ -5306,27 +5414,29 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupConnIds <- createConn subMode directConnIds <- case memChatVRange of Nothing -> Just <$> createConn subMode - Just mcvr - | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> pure Nothing + Just (ChatVersionRange mcvr) + | maxVersion mcvr >= groupDirectInvVersion -> pure Nothing | otherwise -> Just <$> createConn subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo - void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo memRestrictions groupConnIds directConnIds customUserProfileId subMode + vr' = vr PQSupportOff + chatV = maybe (minVersion vr') (\peerVR -> vr' `peerConnChatVersion` fromChatVRange peerVR) memChatVRange + void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds directConnIds customUserProfileId subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" where createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> m () sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do - hostConn <- withStore $ \db -> getConnectionById db user hostConnId + hostConn <- withStore $ \db -> getConnectionById db vr user hostConnId let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} - void $ sendDirectMessage hostConn msg (GroupId groupId) + void $ sendDirectMemberMessage hostConn msg groupId withStore' $ \db -> updateGroupMemberStatusById db userId groupMemberId GSMemIntroInvited xGrpMemInv :: GroupInfo -> GroupMember -> MemberId -> IntroInvitation -> m () xGrpMemInv gInfo@GroupInfo {groupId} m memId introInv = do case memberCategory m of GCInviteeMember -> - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv @@ -5340,7 +5450,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let GroupMember {memberId = membershipMemId} = membership checkHostRole m memRole toMember <- - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent -- the situation when member does not exist is an error -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. @@ -5351,13 +5461,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito let membershipProfile = redactedMemberProfile $ fromLocalProfile $ memberProfile membership - dm <- directMessage $ XGrpMemInfo membershipMemId membershipProfile + dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo mcvr = maybe chatInitialVRange fromChatVRange memChatVRange - withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId subMode + chatV = vr PQSupportOff `peerConnChatVersion` mcvr + withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> m () xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg brokerTs @@ -5365,7 +5476,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership $ RGEUserRole memRole | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole Left _ -> messageError "x.grp.mem.role with unknown member ID" where @@ -5394,7 +5505,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- member shouldn't receive this message about themselves messageError "x.grp.mem.restrict: admin blocks you" | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right bm@GroupMember {groupMemberId = bmId, memberRole, memberProfile = bmp} | senderRole < GRAdmin || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" | otherwise -> do @@ -5413,12 +5524,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = setMemberBlocked bmId = withStore $ \db -> do liftIO $ updateGroupMemberBlocked db user groupId bmId restriction - getGroupMember db user groupId bmId + getGroupMember db vr user groupId bmId blocked = mrsBlocked restriction xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> m () xGrpMemCon gInfo sendingMember memId = do - refMember <- withStore $ \db -> getGroupMemberByMemberId db user gInfo memId + refMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId case (memberCategory sendingMember, memberCategory refMember) of (GCInviteeMember, GCInviteeMember) -> withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case @@ -5462,13 +5573,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then checkRole membership $ do deleteGroupLinkIfExists user gInfo -- member records are not deleted to keep history - members <- withStore' $ \db -> getGroupMembers db user gInfo + members <- withStore' $ \db -> getGroupMembers db vr user gInfo deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved deleteMemberItem RGEUserDeleted toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m else - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.del with unknown member ID" Right member@GroupMember {groupMemberId, memberProfile} -> checkRole member $ do @@ -5500,7 +5611,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do when (memberRole /= GROwner) $ throwChatError $ CEGroupUserRole gInfo GROwner ms <- withStore' $ \db -> do - members <- getGroupMembers db user gInfo + members <- getGroupMembers db vr user gInfo updateGroupMemberStatus db userId membership GSMemGroupDeleted pure members -- member records are not deleted to keep history @@ -5529,7 +5640,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case memberContactId of Nothing -> createNewContact subMode Just mContactId -> do - mCt <- withStore $ \db -> getContact db user mContactId + mCt <- withStore $ \db -> getContact db vr user mContactId let Contact {activeConn, contactGrpInvSent} = mCt forM_ activeConn $ \Connection {connId} -> if contactGrpInvSent @@ -5555,7 +5666,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = joinConn subMode = do -- [incognito] send membership incognito profile let p = userProfileToSend user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing False - dm <- directMessage $ XInfo p + dm <- encodeConnInfo $ XInfo p joinAgentConnectionAsync user True connReq dm subMode createItems mCt' m' = do createInternalChatItem user (CDGroupRcv g m') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing @@ -5572,7 +5683,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> ChatMessage 'Json -> UTCTime -> m () xGrpMsgForward gInfo@GroupInfo {groupId} m@GroupMember {memberRole, localDisplayName} memberId msg msgTs = do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole localDisplayName) - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memberId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case Right author -> processForwardedMsg author msg Left (SEGroupMemberNotFoundByMemberId _) -> do unknownAuthor <- createUnknownMember gInfo memberId @@ -5674,45 +5785,85 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () +createContactPQSndItem :: ChatMonad m => User -> Contact -> Connection -> PQEncryption -> m (Contact, Connection) +createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = + flip catchChatError (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of + (Just b, b') | b' /= b -> createPQItem $ CISndConnEvent (SCEPqEnabled pqSndEnabled') + (Nothing, PQEncOn) -> createPQItem $ CISndDirectE2EEInfo (E2EInfo pqSndEnabled') + _ -> pure (ct, conn) + where + createPQItem ciContent = do + let conn' = conn {pqSndEnabled = Just pqSndEnabled'} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + when (contactPQEnabled ct /= contactPQEnabled ct') $ do + createInternalChatItem user (CDDirectSnd ct') ciContent Nothing + toView $ CRContactPQEnabled user ct' pqSndEnabled' + pure (ct', conn') + +updateContactPQRcv :: ChatMonad m => User -> Contact -> Connection -> PQEncryption -> m (Contact, Connection) +updateContactPQRcv user ct conn@Connection {connId, pqRcvEnabled} pqRcvEnabled' = + flip catchChatError (const $ pure (ct, conn)) $ case (pqRcvEnabled, pqRcvEnabled') of + (Just b, b') | b' /= b -> updatePQ $ CIRcvConnEvent (RCEPqEnabled pqRcvEnabled') + (Nothing, PQEncOn) -> updatePQ $ CIRcvDirectE2EEInfo (E2EInfo pqRcvEnabled') + _ -> pure (ct, conn) + where + updatePQ ciContent = do + withStore' $ \db -> updateConnPQRcvEnabled db connId pqRcvEnabled' + let conn' = conn {pqRcvEnabled = Just pqRcvEnabled'} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + when (contactPQEnabled ct /= contactPQEnabled ct') $ do + createInternalChatItem user (CDDirectRcv ct') ciContent Nothing + toView $ CRContactPQEnabled user ct' pqRcvEnabled' + pure (ct', conn') + metaBrokerTs :: MsgMeta -> UTCTime metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs sameMemberId :: MemberId -> GroupMember -> Bool sameMemberId memId GroupMember {memberId} = memId == memberId -updatePeerChatVRange :: ChatMonad m => Connection -> VersionRange -> m Connection -updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange = do - let jMsgChatVRange = JVersionRange msgChatVRange - if jMsgChatVRange /= peerChatVRange +-- TODO v5.7 for contacts only version upgrade should trigger enabling PQ support/encryption +updatePeerChatVRange :: ChatMonad m => Connection -> VersionRangeChat -> m Connection +updatePeerChatVRange conn@Connection {connId, connChatVersion = v, peerChatVRange, pqSupport} msgVRange = do + v' <- upgradedConnVersion pqSupport v msgVRange + if msgVRange /= peerChatVRange || v' /= v then do - withStore' $ \db -> setPeerChatVRange db connId msgChatVRange - pure conn {peerChatVRange = jMsgChatVRange} + withStore' $ \db -> setPeerChatVRange db connId v' msgVRange + pure conn {connChatVersion = v', peerChatVRange = msgVRange} else pure conn -updateMemberChatVRange :: ChatMonad m => GroupMember -> Connection -> VersionRange -> m (GroupMember, Connection) -updateMemberChatVRange mem@GroupMember {groupMemberId} conn@Connection {connId, peerChatVRange} msgChatVRange = do - let jMsgChatVRange = JVersionRange msgChatVRange - if jMsgChatVRange /= peerChatVRange +updateMemberChatVRange :: ChatMonad m => GroupMember -> Connection -> VersionRangeChat -> m (GroupMember, Connection) +updateMemberChatVRange mem@GroupMember {groupMemberId} conn@Connection {connId, connChatVersion = v, peerChatVRange} msgVRange = do + v' <- upgradedConnVersion PQSupportOff v msgVRange + if msgVRange /= peerChatVRange || v' /= v then do withStore' $ \db -> do - setPeerChatVRange db connId msgChatVRange - setMemberChatVRange db groupMemberId msgChatVRange - let conn' = conn {peerChatVRange = jMsgChatVRange} - pure (mem {memberChatVRange = jMsgChatVRange, activeConn = Just conn'}, conn') + setPeerChatVRange db connId v' msgVRange + setMemberChatVRange db groupMemberId msgVRange + let conn' = conn {connChatVersion = v', peerChatVRange = msgVRange} + pure (mem {memberChatVRange = msgVRange, activeConn = Just conn'}, conn') else pure (mem, conn) +upgradedConnVersion :: ChatMonad' m => PQSupport -> VersionChat -> VersionRangeChat -> m VersionChat +upgradedConnVersion pqSup v peerVR = do + vr <- chatVersionRange + -- don't allow reducing agreed connection version + pure $ maybe v (\(Compatible v') -> max v v') $ vr pqSup `compatibleVersion` peerVR + parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) -sendDirectFileInline :: ChatMonad m => Contact -> FileTransferMeta -> SharedMsgId -> m () -sendDirectFileInline ct ft sharedMsgId = do - msgDeliveryId <- sendFileInline_ ft sharedMsgId $ sendDirectContactMessage ct +sendDirectFileInline :: ChatMonad m => User -> Contact -> FileTransferMeta -> SharedMsgId -> m () +sendDirectFileInline user ct ft sharedMsgId = do + msgDeliveryId <- sendFileInline_ ft sharedMsgId $ sendDirectContactMessage user ct withStore $ \db -> updateSndDirectFTDelivery db ct ft msgDeliveryId sendMemberFileInline :: ChatMonad m => GroupMember -> Connection -> FileTransferMeta -> SharedMsgId -> m () sendMemberFileInline m@GroupMember {groupId} conn ft sharedMsgId = do - msgDeliveryId <- sendFileInline_ ft sharedMsgId $ \msg -> sendDirectMessage conn msg $ GroupId groupId + msgDeliveryId <- sendFileInline_ ft sharedMsgId $ \msg -> do + (sndMsg, msgDeliveryId, _) <- sendDirectMemberMessage conn msg groupId + pure (sndMsg, msgDeliveryId) withStore' $ \db -> updateSndGroupFTDelivery db m conn ft msgDeliveryId sendFileInline_ :: ChatMonad m => FileTransferMeta -> SharedMsgId -> (ChatMsgEvent 'Binary -> m (SndMessage, Int64)) -> m Int64 @@ -5754,7 +5905,7 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentCo sendFileChunkNo :: ChatMonad m => SndFileTransfer -> Integer -> m () sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do chunkBytes <- readFileChunk ft chunkNo - msgId <- withAgent $ \a -> sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunk {chunkNo, chunkBytes} + (msgId, _) <- withAgent $ \a -> sendMessage a acId PQEncOff SMP.noMsgFlags $ smpEncode FileChunk {chunkNo, chunkBytes} withStore' $ \db -> updateSndFileChunkMsg db ft chunkNo msgId readFileChunk :: ChatMonad m => SndFileTransfer -> Integer -> m ByteString @@ -5849,6 +6000,7 @@ cancelSndFile user FileTransferMeta {fileId, xftpSndFile} fts sendCancel = do agentXFTPDeleteSndFileRemote user xsf fileId `catchChatError` (toView . CRChatError (Just user)) pure [] +-- TODO v6.0 remove cancelSndFileTransfer :: ChatMonad m => User -> SndFileTransfer -> Bool -> m (Maybe ConnId) cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = if fileStatus == FSCancelled || fileStatus == FSComplete @@ -5861,9 +6013,10 @@ cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, age deleteSndFileChunks db ft when sendCancel $ case fileInline of Just _ -> do - (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db user connId - void . sendDirectMessage conn (BFileChunk sharedMsgId FileChunkCancel) $ ConnectionId connId - _ -> withAgent $ \a -> void . sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunkCancel + vr <- chatVersionRange + (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db vr user connId + void $ sendDirectMessage_ conn PQSupportOff (BFileChunk sharedMsgId FileChunkCancel) (ConnectionId connId) + _ -> withAgent $ \a -> void . sendMessage a acId PQEncOff SMP.noMsgFlags $ smpEncode FileChunkCancel pure fileConnId fileConnId = if isNothing fileInline then Just acId else Nothing @@ -5874,17 +6027,23 @@ closeFileHandle fileId files = do liftIO $ mapM_ hClose h_ `catchAll_` pure () deleteMembersConnections :: ChatMonad m => User -> [GroupMember] -> m () -deleteMembersConnections user members = do +deleteMembersConnections user members = deleteMembersConnections' user members False + +deleteMembersConnections' :: ChatMonad m => User -> [GroupMember] -> Bool -> m () +deleteMembersConnections' user members waitDelivery = do let memberConns = filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $ mapMaybe (\GroupMember {activeConn} -> activeConn) members - deleteAgentConnectionsAsync user $ map aConnId memberConns - forM_ memberConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted + deleteAgentConnectionsAsync' user (map aConnId memberConns) waitDelivery + void . withStoreBatch' $ \db -> map (\conn -> updateConnectionStatus db conn ConnDeleted) memberConns deleteMemberConnection :: ChatMonad m => User -> GroupMember -> m () -deleteMemberConnection user GroupMember {activeConn} = do +deleteMemberConnection user mem = deleteMemberConnection' user mem False + +deleteMemberConnection' :: ChatMonad m => User -> GroupMember -> Bool -> m () +deleteMemberConnection' user GroupMember {activeConn} waitDelivery = do forM_ activeConn $ \conn -> do - deleteAgentConnectionAsync user $ aConnId conn + deleteAgentConnectionAsync' user (aConnId conn) waitDelivery withStore' $ \db -> updateConnectionStatus db conn ConnDeleted deleteOrUpdateMemberRecord :: ChatMonad m => User -> GroupMember -> m () @@ -5894,10 +6053,13 @@ deleteOrUpdateMemberRecord user@User {userId} member = Just _ -> updateGroupMemberStatus db userId member GSMemRemoved Nothing -> deleteGroupMember db user member -sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64) -sendDirectContactMessage ct chatMsgEvent = do - conn@Connection {connId} <- liftEither $ contactSendConn_ ct - sendDirectMessage conn chatMsgEvent (ConnectionId connId) +sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => User -> Contact -> ChatMsgEvent e -> m (SndMessage, Int64) +sendDirectContactMessage user ct chatMsgEvent = do + conn@Connection {connId, pqSupport} <- liftEither $ contactSendConn_ ct + r <- sendDirectMessage_ conn pqSupport chatMsgEvent (ConnectionId connId) + let (sndMessage, msgDeliveryId, pqEnc') = r + void $ createContactPQSndItem user ct conn pqEnc' + pure (sndMessage, msgDeliveryId) contactSendConn_ :: Contact -> Either ChatError Connection contactSendConn_ ct@Contact {activeConn} = case activeConn of @@ -5910,84 +6072,147 @@ contactSendConn_ ct@Contact {activeConn} = case activeConn of where err = Left . ChatError -sendDirectMessage :: (MsgEncodingI e, ChatMonad m) => Connection -> ChatMsgEvent e -> ConnOrGroupId -> m (SndMessage, Int64) -sendDirectMessage conn chatMsgEvent connOrGroupId = do +-- unlike sendGroupMemberMessage, this function will not store message as pending +-- TODO v5.8 we could remove pending messages once all clients support forwarding +sendDirectMemberMessage :: (MsgEncodingI e, ChatMonad m) => Connection -> ChatMsgEvent e -> GroupId -> m (SndMessage, Int64, PQEncryption) +sendDirectMemberMessage conn chatMsgEvent groupId = sendDirectMessage_ conn PQSupportOff chatMsgEvent (GroupId groupId) + +sendDirectMessage_ :: (MsgEncodingI e, ChatMonad m) => Connection -> PQSupport -> ChatMsgEvent e -> ConnOrGroupId -> m (SndMessage, Int64, PQEncryption) +sendDirectMessage_ conn pqSup chatMsgEvent connOrGroupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) - msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId - (msg,) <$> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId pqSup + -- TODO move compressed body to SndMessage and compress in createSndMessage + (msgDeliveryId, pqEnc') <- deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId + pure (msg, msgDeliveryId, pqEnc') -createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> m SndMessage -createSndMessage chatMsgEvent connOrGroupId = - liftEither . runIdentity =<< createSndMessages (Identity (connOrGroupId, chatMsgEvent)) +createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> PQSupport -> m SndMessage +createSndMessage chatMsgEvent connOrGroupId pqSup = + liftEither . runIdentity =<< createSndMessages (Identity (connOrGroupId, pqSup, chatMsgEvent)) -createSndMessages :: forall e m t. (MsgEncodingI e, ChatMonad' m, Traversable t) => t (ConnOrGroupId, ChatMsgEvent e) -> m (t (Either ChatError SndMessage)) +createSndMessages :: forall e m t. (MsgEncodingI e, ChatMonad' m, Traversable t) => t (ConnOrGroupId, PQSupport, ChatMsgEvent e) -> m (t (Either ChatError SndMessage)) createSndMessages idsEvents = do - gVar <- asks random + g <- asks random vr <- chatVersionRange - withStoreBatch $ \db -> fmap (uncurry (createMsg db gVar vr)) idsEvents + withStoreBatch $ \db -> fmap (createMsg db g vr) idsEvents where - createMsg db gVar chatVRange connOrGroupId evnt = runExceptT $ do - withExceptT ChatErrorStore $ createNewSndMessage db gVar connOrGroupId evnt (encodeMessage chatVRange evnt) - encodeMessage chatVRange evnt sharedMsgId = - encodeChatMessage ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent = evnt} + createMsg :: DB.Connection -> TVar ChaChaDRG -> (PQSupport -> VersionRangeChat) -> (ConnOrGroupId, PQSupport, ChatMsgEvent e) -> IO (Either ChatError SndMessage) + createMsg db g vr (connOrGroupId, pqSup, evnt) = runExceptT $ do + withExceptT ChatErrorStore $ createNewSndMessage db g connOrGroupId evnt encodeMessage + where + encodeMessage sharedMsgId = + encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr pqSup, msgId = Just sharedMsgId, chatMsgEvent = evnt} sendGroupMemberMessages :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> Connection -> NonEmpty (ChatMsgEvent e) -> GroupId -> m () -sendGroupMemberMessages user conn@Connection {connId} events groupId = do +sendGroupMemberMessages user conn events groupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) - let idsEvts = L.map (GroupId groupId,) events + let idsEvts = L.map (GroupId groupId,PQSupportOff,) events (errs, msgs) <- partitionEithers . L.toList <$> createSndMessages idsEvts unless (null errs) $ toView $ CRChatErrors (Just user) errs - unless (null msgs) $ do - let (errs', msgBatches) = partitionEithers $ batchMessages maxChatMsgSize msgs + forM_ (L.nonEmpty msgs) $ \msgs' -> do + -- TODO v5.7 based on version (?) + -- let shouldCompress = False + -- batched <- if shouldCompress then batchSndMessagesBinary msgs' else pure $ batchSndMessagesJSON msgs' + let batched = batchSndMessagesJSON msgs' + let (errs', msgBatches) = partitionEithers batched -- shouldn't happen, as large messages would have caused createNewSndMessage to throw SELargeMsg unless (null errs') $ toView $ CRChatErrors (Just user) errs' forM_ msgBatches $ \batch -> - processBatch batch `catchChatError` (toView . CRChatError (Just user)) - where - processBatch :: MsgBatch -> m () - processBatch (MsgBatch batchBody sndMsgs) = do - agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) MsgFlags {notification = True} batchBody - let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} - void . withStoreBatch' $ \db -> map (\SndMessage {msgId} -> createSndMsgDelivery db sndMsgDelivery msgId) sndMsgs + processSndMessageBatch conn batch `catchChatError` (toView . CRChatError (Just user)) -directMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString -directMessage chatMsgEvent = do - chatVRange <- chatVersionRange - let r = encodeChatMessage ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} - case r of - ECMEncoded encodedBody -> pure encodedBody - ECMLarge -> throwChatError $ CEException "large message" +processSndMessageBatch :: ChatMonad m => Connection -> MsgBatch -> m () +processSndMessageBatch conn@Connection {connId} (MsgBatch batchBody sndMsgs) = do + (agentMsgId, _pqEnc) <- withAgent $ \a -> sendMessage a (aConnId conn) PQEncOff MsgFlags {notification = True} batchBody + let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} + void . withStoreBatch' $ \db -> map (\SndMessage {msgId} -> createSndMsgDelivery db sndMsgDelivery msgId) sndMsgs -deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m Int64 +-- TODO v5.7 update batching for groups +batchSndMessagesJSON :: NonEmpty SndMessage -> [Either ChatError MsgBatch] +batchSndMessagesJSON = batchMessages maxEncodedMsgLength . L.toList + +-- batchSndMessagesBinary :: forall m. ChatMonad m => NonEmpty SndMessage -> m [Either ChatError MsgBatch] +-- batchSndMessagesBinary msgs = do +-- compressed <- liftIO $ withCompressCtx maxChatMsgSize $ \cctx -> mapM (compressForBatch cctx) msgs +-- pure . map toMsgBatch . SMP.batchTransmissions_ (maxEncodedMsgLength) $ L.zip compressed msgs +-- where +-- compressForBatch cctx SndMessage {msgBody} = bimap (const TELargeMsg) smpEncode <$> compress cctx msgBody +-- toMsgBatch :: SMP.TransportBatch SndMessage -> Either ChatError MsgBatch +-- toMsgBatch = \case +-- SMP.TBTransmissions combined _n sms -> Right $ MsgBatch (markCompressedBatch combined) sms +-- SMP.TBError tbe SndMessage {msgId} -> Left . ChatError $ CEInternalError (show tbe <> " " <> show msgId) +-- SMP.TBTransmission {} -> Left . ChatError $ CEInternalError "batchTransmissions_ didn't produce a batch" + +encodeConnInfo :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString +encodeConnInfo chatMsgEvent = do + vr <- chatVersionRange + encodeConnInfoPQ PQSupportOff (maxVersion $ vr PQSupportOff) chatMsgEvent + +encodeConnInfoPQ :: (MsgEncodingI e, ChatMonad m) => PQSupport -> VersionChat -> ChatMsgEvent e -> m ByteString +encodeConnInfoPQ pqSup v chatMsgEvent = do + vr <- chatVersionRange + let info = ChatMessage {chatVRange = vr pqSup, msgId = Nothing, chatMsgEvent} + case encodeChatMessage maxEncodedInfoLength info of + ECMEncoded connInfo -> case pqSup of + PQSupportOn | v >= pqEncryptionCompressionVersion && B.length connInfo > maxCompressedInfoLength -> do + connInfo' <- liftIO compressedBatchMsgBody + when (B.length connInfo' > maxCompressedInfoLength) $ throwChatError $ CEException "large compressed info" + pure connInfo' + _ -> pure connInfo + where + compressedBatchMsgBody = withCompressCtx (toEnum $ B.length connInfo) (`compressedBatchMsgBody_` connInfo) + ECMLarge -> throwChatError $ CEException "large info" + +deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m (Int64, PQEncryption) deliverMessage conn cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} deliverMessage' conn msgFlags msgBody msgId -deliverMessage' :: ChatMonad m => Connection -> MsgFlags -> MsgBody -> MessageId -> m Int64 +deliverMessage' :: ChatMonad m => Connection -> MsgFlags -> MsgBody -> MessageId -> m (Int64, PQEncryption) deliverMessage' conn msgFlags msgBody msgId = - deliverMessages [(conn, msgFlags, msgBody, msgId)] >>= \case - [r] -> liftEither r + deliverMessages ((conn, msgFlags, msgBody, msgId) :| []) >>= \case + r :| [] -> liftEither r rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs) type MsgReq = (Connection, MsgFlags, MsgBody, MessageId) -deliverMessages :: ChatMonad' m => [MsgReq] -> m [Either ChatError Int64] -deliverMessages = deliverMessagesB . map Right +deliverMessages :: ChatMonad' m => NonEmpty MsgReq -> m (NonEmpty (Either ChatError (Int64, PQEncryption))) +deliverMessages msgs = deliverMessagesB $ L.map Right msgs -deliverMessagesB :: ChatMonad' m => [Either ChatError MsgReq] -> m [Either ChatError Int64] +deliverMessagesB :: forall m. ChatMonad' m => NonEmpty (Either ChatError MsgReq) -> m (NonEmpty (Either ChatError (Int64, PQEncryption))) deliverMessagesB msgReqs = do - sent <- zipWith prepareBatch msgReqs <$> withAgent' (`sendMessagesB` map toAgent msgReqs) - withStoreBatch $ \db -> map (bindRight $ createDelivery db) sent + msgReqs' <- compressBodies + sent <- L.zipWith prepareBatch msgReqs' <$> withAgent' (`sendMessagesB` L.map toAgent msgReqs') + void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights . L.toList $ sent) + withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent where + compressBodies = liftIO $ withCompressCtx (toEnum maxEncodedMsgLength) $ \cxt -> + forME msgReqs $ \mr@(conn@Connection {pqSupport, connChatVersion = v}, msgFlags, msgBody, msgId) -> + runExceptT $ case pqSupport of + -- we only compress messages when: + -- 1) PQ support is enabled + -- 2) version is compatible with compression + -- 3) message is longer than max compressed size (as this function is not used for batched messages anyway) + PQSupportOn | v >= pqEncryptionCompressionVersion && B.length msgBody > maxCompressedMsgLength -> do + msgBody' <- liftIO $ compressedBatchMsgBody_ cxt msgBody + when (B.length msgBody' > maxCompressedMsgLength) $ throwError $ ChatError $ CEException "large compressed message" + pure (conn, msgFlags, msgBody', msgId) + _ -> pure mr toAgent = \case - Right (conn, msgFlags, msgBody, _msgId) -> Right (aConnId conn, msgFlags, msgBody) + Right (conn@Connection {pqEncryption}, msgFlags, msgBody, _msgId) -> Right (aConnId conn, pqEncryption, msgFlags, msgBody) Left _ce -> Left (AP.INTERNAL "ChatError, skip") -- as long as it is Left, the agent batchers should just step over it prepareBatch (Right req) (Right ar) = Right (req, ar) prepareBatch (Left ce) _ = Left ce -- restore original ChatError prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae Nothing - createDelivery :: DB.Connection -> (MsgReq, AgentMsgId) -> IO (Either ChatError Int64) - createDelivery db ((Connection {connId}, _, _, msgId), agentMsgId) = - Right <$> createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId}) msgId + createDelivery :: DB.Connection -> (MsgReq, (AgentMsgId, PQEncryption)) -> IO (Either ChatError (Int64, PQEncryption)) + createDelivery db ((Connection {connId}, _, _, msgId), (agentMsgId, pqEnc')) = + Right . (,pqEnc') <$> createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId}) msgId + updatePQSndEnabled :: DB.Connection -> (MsgReq, (AgentMsgId, PQEncryption)) -> IO () + updatePQSndEnabled db ((Connection {connId, pqSndEnabled}, _, _, _), (_, pqSndEnabled')) = + case (pqSndEnabled, pqSndEnabled') of + (Just b, b') | b' /= b -> updatePQ + (Nothing, PQEncOn) -> updatePQ + _ -> pure () + where + updatePQ = updateConnPQSndEnabled db connId pqSndEnabled' sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) sendGroupMessage user gInfo members chatMsgEvent = do @@ -6005,7 +6230,7 @@ sendGroupMessage user gInfo members chatMsgEvent = do (Nothing, Just _) -> True _ -> False sendProfileUpdate = do - let members' = filter (\m -> isCompatibleRange (memberChatVRange' m) memberProfileUpdateVRange) members + let members' = filter (`supportsVersion` memberProfileUpdateVersion) members profileUpdateEvent = XInfo $ redactedMemberProfile $ fromLocalProfile p void $ sendGroupMessage' user gInfo members' profileUpdateEvent currentTs <- liftIO getCurrentTime @@ -6013,12 +6238,13 @@ sendGroupMessage user gInfo members chatMsgEvent = do sendGroupMessage' :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) sendGroupMessage' user GroupInfo {groupId} members chatMsgEvent = do - msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId) + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId) PQSupportOff recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) let msgFlags = MsgFlags {notification = hasNotification $ toCMEventTag chatMsgEvent} (toSend, pending) = foldr addMember ([], []) recipientMembers + -- TODO PQ either somehow ensure that group members connections cannot have pqSupport/pqEncryption or pass Off's here msgReqs = map (\(_, conn) -> (conn, msgFlags, msgBody, msgId)) toSend - delivered <- deliverMessages msgReqs + delivered <- maybe (pure []) (fmap L.toList . deliverMessages) $ L.nonEmpty msgReqs let errors = lefts delivered unless (null errors) $ toView $ CRChatErrors (Just user) errors stored <- withStoreBatch' $ \db -> map (\m -> createPendingGroupMessage db (groupMemberId' m) msgId Nothing) pending @@ -6053,16 +6279,12 @@ memberSendAction chatMsgEvent members m@GroupMember {invitedByGroupMemberId} = c | isXGrpMsgForward chatMsgEvent = Nothing | otherwise = Just MSAPending where - forwardSupported = - let mcvr = memberChatVRange' m - in isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward + forwardSupported = m `supportsVersion` groupForwardVersion && invitingMemberSupportsForward invitingMemberSupportsForward = case invitedByGroupMemberId of Just invMemberId -> -- can be optimized for large groups by replacing [GroupMember] with Map GroupMemberId GroupMember case find (\m' -> groupMemberId' m' == invMemberId) members of - Just invitingMember -> do - let mcvr = memberChatVRange' invitingMember - isCompatibleRange mcvr groupForwardVRange + Just invitingMember -> invitingMember `supportsVersion` groupForwardVersion Nothing -> False Nothing -> False isXGrpMsgForward ev = case ev of @@ -6071,7 +6293,7 @@ memberSendAction chatMsgEvent members m@GroupMember {invitedByGroupMemberId} = c sendGroupMemberMessage :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> GroupMember -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m () sendGroupMemberMessage user m@GroupMember {groupMemberId} chatMsgEvent groupId introId_ postDeliver = do - msg <- createSndMessage chatMsgEvent (GroupId groupId) + msg <- createSndMessage chatMsgEvent (GroupId groupId) PQSupportOff messageMember msg `catchChatError` (\e -> toView (CRChatError (Just user) e)) where messageMember :: SndMessage -> m () @@ -6119,9 +6341,10 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) `catchChatError` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do - fm <- withStore $ \db -> getGroupMember db user groupId forwardedByGroupMemberId + vr <- chatVersionRange + fm <- withStore $ \db -> getGroupMember db vr user groupId forwardedByGroupMemberId forM_ (memberConn fm) $ \fmConn -> - void $ sendDirectMessage fmConn (XGrpMemCon amMemId) (GroupId groupId) + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId throwError e _ -> throwError e pure (am', conn', msg) @@ -6134,10 +6357,11 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me withStore (\db -> createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) `catchChatError` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ (Just authorGroupMemberId) Nothing) -> do - am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db user groupId authorGroupMemberId + vr <- chatVersionRange + am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGroupMemberId if sameMemberId refMemberId am then forM_ (memberConn forwardingMember) $ \fmConn -> - void $ sendDirectMessage fmConn (XGrpMemCon amMemberId) (GroupId groupId) + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId else toView $ CRMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" throwError e _ -> throwError e @@ -6194,18 +6418,19 @@ deleteGroupCI user gInfo ci@ChatItem {file} byUser timed byGroupMember_ deletedT gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo) deleteLocalCI :: (ChatMonad m, MsgDirectionI d) => User -> NoteFolder -> ChatItem 'CTLocal d -> Bool -> Bool -> m ChatResponse -deleteLocalCI user nf ci@ChatItem {file} byUser timed = do - forM_ file $ \CIFile {fileSource} -> do - forM_ (CF.filePath <$> fileSource) $ \fPath -> - deleteFileLocally fPath `catchChatError` (toView . CRChatError (Just user)) +deleteLocalCI user nf ci@ChatItem {file = file_} byUser timed = do + forM_ file_ $ \file -> do + let filesInfo = [mkCIFileInfo file] + deleteFilesLocally filesInfo withStore' $ \db -> deleteLocalChatItem db user nf ci pure $ CRChatItemDeleted user (AChatItem SCTLocal msgDirection (LocalChat nf) ci) Nothing byUser timed deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () deleteCIFile user file_ = forM_ file_ $ \file -> do - fileAgentConnIds <- deleteFile' user (mkCIFileInfo file) True - deleteAgentConnectionsAsync user fileAgentConnIds + let filesInfo = [mkCIFileInfo file] + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo markDirectCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> MessageId -> Bool -> UTCTime -> m ChatResponse markDirectCIDeleted user ct ci@ChatItem {file} msgId byUser deletedTs = do @@ -6226,56 +6451,92 @@ markGroupCIDeleted user gInfo ci@ChatItem {file} msgId byUser byGroupMember_ del cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () cancelCIFile user file_ = forM_ file_ $ \file -> do - fileAgentConnIds <- cancelFile' user (mkCIFileInfo file) True - deleteAgentConnectionsAsync user fileAgentConnIds + let filesInfo = [mkCIFileInfo file] + cancelFilesInProgress user filesInfo createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction - connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode subMode + connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode IKPQOff subMode pure (cmdId, connId) joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> m (CommandId, ConnId) joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn - connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo subMode + connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo PQSupportOff subMode pure (cmdId, connId) allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m () -allowAgentConnectionAsync user conn@Connection {connId} confId msg = do +allowAgentConnectionAsync user conn@Connection {connId, pqSupport, connChatVersion} confId msg = do cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn - dm <- directMessage msg + dm <- encodeConnInfoPQ pqSupport connChatVersion msg withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted -agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> m (CommandId, ConnId) -agentAcceptContactAsync user enableNtfs invId msg subMode = do +agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> PQSupport -> VersionChat -> m (CommandId, ConnId) +agentAcceptContactAsync user enableNtfs invId msg subMode pqSup chatV = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact - dm <- directMessage msg - connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm subMode + dm <- encodeConnInfoPQ pqSup chatV msg + connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm pqSup subMode pure (cmdId, connId) deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m () -deleteAgentConnectionAsync user acId = - withAgent (`deleteConnectionAsync` acId) `catchChatError` (toView . CRChatError (Just user)) +deleteAgentConnectionAsync user acId = deleteAgentConnectionAsync' user acId False + +deleteAgentConnectionAsync' :: ChatMonad m => User -> ConnId -> Bool -> m () +deleteAgentConnectionAsync' user acId waitDelivery = do + withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` (toView . CRChatError (Just user)) deleteAgentConnectionsAsync :: ChatMonad m => User -> [ConnId] -> m () -deleteAgentConnectionsAsync _ [] = pure () -deleteAgentConnectionsAsync user acIds = - withAgent (`deleteConnectionsAsync` acIds) `catchChatError` (toView . CRChatError (Just user)) +deleteAgentConnectionsAsync user acIds = deleteAgentConnectionsAsync' user acIds False + +deleteAgentConnectionsAsync' :: ChatMonad m => User -> [ConnId] -> Bool -> m () +deleteAgentConnectionsAsync' _ [] _ = pure () +deleteAgentConnectionsAsync' user acIds waitDelivery = do + withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` (toView . CRChatError (Just user)) agentXFTPDeleteRcvFile :: ChatMonad m => RcvFileId -> FileTransferId -> m () agentXFTPDeleteRcvFile aFileId fileId = do withAgent (`xftpDeleteRcvFile` aFileId) withStore' $ \db -> setRcvFTAgentDeleted db fileId +agentXFTPDeleteRcvFiles :: ChatMonad m => [(XFTPRcvFile, FileTransferId)] -> m () +agentXFTPDeleteRcvFiles rcvFiles = do + let rcvFiles' = filter (not . agentRcvFileDeleted . fst) rcvFiles + rfIds = mapMaybe fileIds rcvFiles' + withAgent $ \a -> xftpDeleteRcvFiles a (map fst rfIds) + void . withStoreBatch' $ \db -> map (setRcvFTAgentDeleted db . snd) rfIds + where + fileIds :: (XFTPRcvFile, FileTransferId) -> Maybe (RcvFileId, FileTransferId) + fileIds (XFTPRcvFile {agentRcvFileId = Just (AgentRcvFileId aFileId)}, fileId) = Just (aFileId, fileId) + fileIds _ = Nothing + agentXFTPDeleteSndFileRemote :: ChatMonad m => User -> XFTPSndFile -> FileTransferId -> m () -agentXFTPDeleteSndFileRemote user XFTPSndFile {agentSndFileId = AgentSndFileId aFileId, privateSndFileDescr, agentSndFileDeleted} fileId = - unless agentSndFileDeleted $ - forM_ privateSndFileDescr $ \sfdText -> do - sd <- parseFileDescription sfdText - withAgent $ \a -> xftpDeleteSndFileRemote a (aUserId user) aFileId sd - withStore' $ \db -> setSndFTAgentDeleted db user fileId +agentXFTPDeleteSndFileRemote user xsf fileId = + agentXFTPDeleteSndFilesRemote user [(xsf, fileId)] + +agentXFTPDeleteSndFilesRemote :: forall m. ChatMonad m => User -> [(XFTPSndFile, FileTransferId)] -> m () +agentXFTPDeleteSndFilesRemote user sndFiles = do + (_errs, redirects) <- partitionEithers <$> withStoreBatch' (\db -> map (lookupFileTransferRedirectMeta db user . snd) sndFiles) + let redirects' = mapMaybe mapRedirectMeta $ concat redirects + sndFilesAll = redirects' <> sndFiles + sndFilesAll' = filter (not . agentSndFileDeleted . fst) sndFilesAll + sndFilesAll'' <- catMaybes <$> mapM sndFileDescr sndFilesAll' + let sfs = map (\(XFTPSndFile {agentSndFileId = AgentSndFileId aFileId}, sfd, _) -> (aFileId, sfd)) sndFilesAll'' + withAgent $ \a -> xftpDeleteSndFilesRemote a (aUserId user) sfs + void . withStoreBatch' $ \db -> map (setSndFTAgentDeleted db user . (\(_, _, fId) -> fId)) sndFilesAll'' + where + mapRedirectMeta :: FileTransferMeta -> Maybe (XFTPSndFile, FileTransferId) + mapRedirectMeta FileTransferMeta {fileId = fileId, xftpSndFile = Just sndFileRedirect} = Just (sndFileRedirect, fileId) + mapRedirectMeta _ = Nothing + sndFileDescr :: (XFTPSndFile, FileTransferId) -> m (Maybe (XFTPSndFile, ValidFileDescription 'FSender, FileTransferId)) + sndFileDescr (xsf@XFTPSndFile {privateSndFileDescr}, fileId) = + join <$> forM privateSndFileDescr parseSndDescr + where + parseSndDescr sfdText = + tryChatError (parseFileDescription sfdText) >>= \case + Left _ -> pure Nothing + Right sd -> pure $ Just (xsf, sd, fileId) userProfileToSend :: User -> Maybe Profile -> Maybe Contact -> Bool -> Profile userProfileToSend user@User {profile = p} incognitoProfile ct inGroup = do @@ -6450,10 +6711,11 @@ waitChatStartedAndActivated = do activated <- readTVar chatActivated unless (isJust started && activated) retry -chatVersionRange :: ChatMonad' m => m VersionRange +chatVersionRange :: ChatMonad' m => m (PQSupport -> VersionRangeChat) chatVersionRange = do ChatConfig {chatVRange} <- asks config pure chatVRange +{-# INLINE chatVersionRange #-} chatCommandP :: Parser ChatCommand chatCommandP = @@ -6495,10 +6757,11 @@ chatCommandP = "/_temp_folder " *> (SetTempFolder <$> filePath), ("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath), "/remote_hosts_folder " *> (SetRemoteHostsFolder <$> filePath), - "/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))), - "/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))), "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), + "/_pq @" *> (APISetContactPQ <$> A.decimal <* A.space <*> (PQEncryption <$> onOffP)), + "/pq @" *> (SetContactPQ <$> displayName <* A.space <*> (PQEncryption <$> onOffP)), + "/pq " *> (APISetPQEncryption . PQSupport <$> onOffP), "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, "/_db import " *> (APIImportArchive <$> jsonP), @@ -6507,6 +6770,9 @@ chatCommandP = "/db encrypt " *> (APIStorageEncryption . dbEncryptionConfig "" <$> dbKeyP), "/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), "/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> dbKeyP), + "/db test key " *> (TestStorageEncryption <$> dbKeyP), + "/_save app settings" *> (APISaveAppSettings <$> jsonP), + "/_get app settings" *> (APIGetAppSettings <$> optional (A.space *> jsonP)), "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), "/sql slow" $> SlowSQLQueries, @@ -6564,6 +6830,7 @@ chatCommandP = "/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP), "/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP), "/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP), + "/ntf test " *> (TestProtoServer . AProtoServerWithAuth SPNTF <$> strP), "/_servers " *> (APISetUserProtoServers <$> A.decimal <* A.space <*> srvCfgP), "/smp " *> (SetUserProtoServers . APSC SPSMP . ProtoServersConfig . map toServerCfg <$> protocolServersP), "/smp default" $> SetUserProtoServers (APSC SPSMP $ ProtoServersConfig []), @@ -6744,6 +7011,9 @@ chatCommandP = "/list remote ctrls" $> ListRemoteCtrls, "/stop remote ctrl" $> StopRemoteCtrl, "/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal), + "/_upload " *> (APIUploadStandaloneFile <$> A.decimal <* A.space <*> cryptoFileP), + "/_download info " *> (APIStandaloneFileInfo <$> strP), + "/_download " *> (APIDownloadStandaloneFile <$> A.decimal <* A.space <*> strP_ <*> cryptoFileP), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, "/debug locks" $> DebugLocks, @@ -6868,14 +7138,6 @@ chatCommandP = logErrors <- " log=" *> onOffP <|> pure False let tcpTimeout = 1000000 * fromMaybe (maybe 5 (const 10) socksProxy) t_ pure $ fullNetworkConfig socksProxy tcpTimeout logErrors - xftpCfgP = XFTPFileConfig <$> (" size=" *> fileSizeP <|> pure 0) - fileSizeP = - A.choice - [ gb <$> A.decimal <* "gb", - mb <$> A.decimal <* "mb", - kb <$> A.decimal <* "kb", - A.decimal - ] dbKeyP = nonEmptyKey <$?> strP nonEmptyKey k@(DBEncryptionKey s) = if BA.null s then Left "empty key" else Right k dbEncryptionConfig currentKey newKey = DBEncryptionConfig {currentKey, newKey, keepKey = Just False} @@ -6931,3 +7193,29 @@ mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 | isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c) | otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c validFirstChar = isLetter c || isNumber c || isSymbol c + +xftpSndFileTransfer_ :: ChatMonad m => User -> CryptoFile -> Integer -> Int -> Maybe ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) +xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup_ = do + let fileName = takeFileName filePath + fInv = xftpFileInvitation fileName fileSize dummyFileDescr + 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) Nothing chSize + let fileSource = Just $ CryptoFile filePath cfArgs + ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP} + pure (fInv, ciFile, ft) + +xftpSndFileRedirect :: ChatMonad m => User -> FileTransferId -> ValidFileDescription 'FRecipient -> m FileTransferMeta +xftpSndFileRedirect user ftId vfd = do + let fileName = "redirect.yaml" + file = CryptoFile fileName Nothing + fInv = xftpFileInvitation fileName (fromIntegral $ B.length $ strEncode vfd) dummyFileDescr + aFileId <- withAgent $ \a -> xftpSendDescription a (aUserId user) vfd (roundedFDCount 1) + chSize <- asks $ fileChunkSize . config + withStore' $ \db -> createSndFileTransferXFTP db user Nothing file fInv (AgentSndFileId aFileId) (Just ftId) chSize + +dummyFileDescr :: FileDescr +dummyFileDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs new file mode 100644 index 0000000000..572ce0c67b --- /dev/null +++ b/src/Simplex/Chat/AppSettings.hs @@ -0,0 +1,190 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.AppSettings where + +import Control.Applicative ((<|>)) +import Data.Aeson (FromJSON (..), (.:?)) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Simplex.Messaging.Client (NetworkConfig, defaultNetworkConfig) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) +import Simplex.Messaging.Util (catchAll_) + +data AppPlatform = APIOS | APAndroid | APDesktop deriving (Show) + +data NotificationMode = NMOff | NMPeriodic | NMInstant deriving (Show) + +data NotificationPreviewMode = NPMHidden | NPMContact | NPMMessage deriving (Show) + +data LockScreenCalls = LSCDisable | LSCShow | LSCAccept deriving (Show) + +data AppSettings = AppSettings + { appPlatform :: Maybe AppPlatform, + networkConfig :: Maybe NetworkConfig, + privacyEncryptLocalFiles :: Maybe Bool, + privacyAcceptImages :: Maybe Bool, + privacyLinkPreviews :: Maybe Bool, + privacyShowChatPreviews :: Maybe Bool, + privacySaveLastDraft :: Maybe Bool, + privacyProtectScreen :: Maybe Bool, + notificationMode :: Maybe NotificationMode, + notificationPreviewMode :: Maybe NotificationPreviewMode, + webrtcPolicyRelay :: Maybe Bool, + webrtcICEServers :: Maybe [Text], + confirmRemoteSessions :: Maybe Bool, + connectRemoteViaMulticast :: Maybe Bool, + connectRemoteViaMulticastAuto :: Maybe Bool, + developerTools :: Maybe Bool, + confirmDBUpgrades :: Maybe Bool, + androidCallOnLockScreen :: Maybe LockScreenCalls, + iosCallKitEnabled :: Maybe Bool, + iosCallKitCallsInRecents :: Maybe Bool + } + deriving (Show) + +defaultAppSettings :: AppSettings +defaultAppSettings = + AppSettings + { appPlatform = Nothing, + networkConfig = Just defaultNetworkConfig, + privacyEncryptLocalFiles = Just True, + privacyAcceptImages = Just True, + privacyLinkPreviews = Just True, + privacyShowChatPreviews = Just True, + privacySaveLastDraft = Just True, + privacyProtectScreen = Just False, + notificationMode = Just NMInstant, + notificationPreviewMode = Just NPMMessage, + webrtcPolicyRelay = Just True, + webrtcICEServers = Just [], + confirmRemoteSessions = Just False, + connectRemoteViaMulticast = Just True, + connectRemoteViaMulticastAuto = Just True, + developerTools = Just False, + confirmDBUpgrades = Just False, + androidCallOnLockScreen = Just LSCShow, + iosCallKitEnabled = Just True, + iosCallKitCallsInRecents = Just False + } + +defaultParseAppSettings :: AppSettings +defaultParseAppSettings = + AppSettings + { appPlatform = Nothing, + networkConfig = Nothing, + privacyEncryptLocalFiles = Nothing, + privacyAcceptImages = Nothing, + privacyLinkPreviews = Nothing, + privacyShowChatPreviews = Nothing, + privacySaveLastDraft = Nothing, + privacyProtectScreen = Nothing, + notificationMode = Nothing, + notificationPreviewMode = Nothing, + webrtcPolicyRelay = Nothing, + webrtcICEServers = Nothing, + confirmRemoteSessions = Nothing, + connectRemoteViaMulticast = Nothing, + connectRemoteViaMulticastAuto = Nothing, + developerTools = Nothing, + confirmDBUpgrades = Nothing, + androidCallOnLockScreen = Nothing, + iosCallKitEnabled = Nothing, + iosCallKitCallsInRecents = Nothing + } + +combineAppSettings :: AppSettings -> AppSettings -> AppSettings +combineAppSettings platformDefaults storedSettings = + AppSettings + { appPlatform = p appPlatform, + networkConfig = p networkConfig, + privacyEncryptLocalFiles = p privacyEncryptLocalFiles, + privacyAcceptImages = p privacyAcceptImages, + privacyLinkPreviews = p privacyLinkPreviews, + privacyShowChatPreviews = p privacyShowChatPreviews, + privacySaveLastDraft = p privacySaveLastDraft, + privacyProtectScreen = p privacyProtectScreen, + notificationMode = p notificationMode, + notificationPreviewMode = p notificationPreviewMode, + webrtcPolicyRelay = p webrtcPolicyRelay, + webrtcICEServers = p webrtcICEServers, + confirmRemoteSessions = p confirmRemoteSessions, + connectRemoteViaMulticast = p connectRemoteViaMulticast, + connectRemoteViaMulticastAuto = p connectRemoteViaMulticastAuto, + developerTools = p developerTools, + confirmDBUpgrades = p confirmDBUpgrades, + iosCallKitEnabled = p iosCallKitEnabled, + iosCallKitCallsInRecents = p iosCallKitCallsInRecents, + androidCallOnLockScreen = p androidCallOnLockScreen + } + where + p :: (AppSettings -> Maybe a) -> Maybe a + p sel = sel storedSettings <|> sel platformDefaults <|> sel defaultAppSettings + +$(JQ.deriveJSON (enumJSON $ dropPrefix "AP") ''AppPlatform) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "NM") ''NotificationMode) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "NPM") ''NotificationPreviewMode) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "LSC") ''LockScreenCalls) + +$(JQ.deriveToJSON defaultJSON ''AppSettings) + +instance FromJSON AppSettings where + parseJSON (J.Object v) = do + appPlatform <- p "appPlatform" + networkConfig <- p "networkConfig" + privacyEncryptLocalFiles <- p "privacyEncryptLocalFiles" + privacyAcceptImages <- p "privacyAcceptImages" + privacyLinkPreviews <- p "privacyLinkPreviews" + privacyShowChatPreviews <- p "privacyShowChatPreviews" + privacySaveLastDraft <- p "privacySaveLastDraft" + privacyProtectScreen <- p "privacyProtectScreen" + notificationMode <- p "notificationMode" + notificationPreviewMode <- p "notificationPreviewMode" + webrtcPolicyRelay <- p "webrtcPolicyRelay" + webrtcICEServers <- p "webrtcICEServers" + confirmRemoteSessions <- p "confirmRemoteSessions" + connectRemoteViaMulticast <- p "connectRemoteViaMulticast" + connectRemoteViaMulticastAuto <- p "connectRemoteViaMulticastAuto" + developerTools <- p "developerTools" + confirmDBUpgrades <- p "confirmDBUpgrades" + iosCallKitEnabled <- p "iosCallKitEnabled" + iosCallKitCallsInRecents <- p "iosCallKitCallsInRecents" + androidCallOnLockScreen <- p "androidCallOnLockScreen" + pure + AppSettings + { appPlatform, + networkConfig, + privacyEncryptLocalFiles, + privacyAcceptImages, + privacyLinkPreviews, + privacyShowChatPreviews, + privacySaveLastDraft, + privacyProtectScreen, + notificationMode, + notificationPreviewMode, + webrtcPolicyRelay, + webrtcICEServers, + confirmRemoteSessions, + connectRemoteViaMulticast, + connectRemoteViaMulticastAuto, + developerTools, + confirmDBUpgrades, + iosCallKitEnabled, + iosCallKitCallsInRecents, + androidCallOnLockScreen + } + where + p key = v .:? key <|> pure Nothing + parseJSON _ = pure defaultParseAppSettings + +readAppSettings :: FilePath -> Maybe AppSettings -> IO AppSettings +readAppSettings f platformDefaults = + combineAppSettings (fromMaybe defaultAppSettings platformDefaults) . fromMaybe defaultParseAppSettings + <$> (J.decodeFileStrict f `catchAll_` pure Nothing) diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index d386b48d40..4644299598 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Archive importArchive, deleteStorage, sqlCipherExport, + sqlCipherTestKey, archiveFilesFolder, ) where @@ -20,6 +21,7 @@ import Control.Monad.Reader import qualified Data.ByteArray as BA import Data.Functor (($>)) import Data.Maybe (fromMaybe) +import Data.Text (Text) import qualified Data.Text as T import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller @@ -147,19 +149,8 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D atomically $ writeTVar dbKey $ storeKey key' (fromMaybe False keepKey) export f = do withDB f (`SQL.exec` exportSQL) DBErrorExport - withDB (exported f) (`SQL.exec` testSQL) DBErrorOpen + withDB (exported f) (`SQL.exec` testSQL key') DBErrorOpen where - withDB f' a err = - liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing) - `catch` checkSQLError - `catch` (\(e :: SomeException) -> sqliteError' e) - >>= mapM_ (throwDBError . err) - where - checkSQLError e = case SQL.sqlError e of - SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase - _ -> sqliteError' e - sqliteError' :: Show e => e -> m (Maybe SQLiteError) - sqliteError' = pure . Just . SQLiteError . show exportSQL = T.unlines $ keySQL key @@ -167,14 +158,38 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D "SELECT sqlcipher_export('exported');", "DETACH DATABASE exported;" ] - testSQL = - T.unlines $ - keySQL key' - <> [ "PRAGMA foreign_keys = ON;", - "PRAGMA secure_delete = ON;", - "SELECT count(*) FROM sqlite_master;" - ] - keySQL k = ["PRAGMA key = " <> keyString k <> ";" | not (BA.null k)] + +withDB :: forall a m. ChatMonad m => FilePath -> (SQL.Database -> IO a) -> (SQLiteError -> DatabaseError) -> m () +withDB f' a err = + liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing) + `catch` checkSQLError + `catch` (\(e :: SomeException) -> sqliteError' e) + >>= mapM_ (throwDBError . err) + where + checkSQLError e = case SQL.sqlError e of + SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase + _ -> sqliteError' e + sqliteError' :: Show e => e -> m (Maybe SQLiteError) + sqliteError' = pure . Just . SQLiteError . show + +testSQL :: BA.ScrubbedBytes -> Text +testSQL k = + T.unlines $ + keySQL k + <> [ "PRAGMA foreign_keys = ON;", + "PRAGMA secure_delete = ON;", + "SELECT count(*) FROM sqlite_master;" + ] + +keySQL :: BA.ScrubbedBytes -> [Text] +keySQL k = ["PRAGMA key = " <> keyString k <> ";" | not (BA.null k)] + +sqlCipherTestKey :: forall m. ChatMonad m => DBEncryptionKey -> m () +sqlCipherTestKey (DBEncryptionKey key) = do + fs <- storageFiles + testKey `withDBs` fs + where + testKey f = withDB f (`SQL.exec` testSQL key) DBErrorOpen withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d3c8698f94..b2d82a0243 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -49,6 +49,7 @@ import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC +import Simplex.Chat.AppSettings import Simplex.Chat.Call import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages @@ -59,6 +60,7 @@ import Simplex.Chat.Remote.Types import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) import Simplex.Messaging.Agent.Client (AgentLocks, AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) @@ -70,6 +72,7 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Crypto.Ratchet (PQEncryption, PQSupport (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) @@ -78,7 +81,6 @@ import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) -import Simplex.Messaging.Version import Simplex.RemoteControl.Client import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation) import Simplex.RemoteControl.Types @@ -120,7 +122,7 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, - chatVRange :: VersionRange, + chatVRange :: PQSupport -> VersionRangeChat, confirmMigrations :: MigrationConfirmation, defaultServers :: DefaultAgentServers, tbqSize :: Natural, @@ -128,8 +130,6 @@ data ChatConfig = ChatConfig xftpDescrPartSize :: Int, inlineFiles :: InlineFilesConfig, autoAcceptFileSize :: Integer, - xftpFileConfig :: Maybe XFTPFileConfig, -- Nothing - XFTP is disabled - tempDir :: Maybe FilePath, showReactions :: Bool, showReceipts :: Bool, subscriptionEvents :: Bool, @@ -204,10 +204,10 @@ data ChatController = ChatController timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))), showLiveItems :: TVar Bool, encryptLocalFiles :: TVar Bool, - userXFTPFileConfig :: TVar (Maybe XFTPFileConfig), tempDirectory :: TVar (Maybe FilePath), logFilePath :: Maybe FilePath, - contactMergeEnabled :: TVar Bool + contactMergeEnabled :: TVar Bool, + pqExperimentalEnabled :: TVar PQSupport -- TODO v5.7 remove } data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase @@ -242,14 +242,19 @@ data ChatCommand | SetTempFolder FilePath | SetFilesFolder FilePath | SetRemoteHostsFolder FilePath - | APISetXFTPConfig (Maybe XFTPFileConfig) | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool + | APISetPQEncryption PQSupport + | APISetContactPQ ContactId PQEncryption + | SetContactPQ ContactName PQEncryption | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig + | APISaveAppSettings AppSettings + | APIGetAppSettings (Maybe AppSettings) | APIDeleteStorage | APIStorageEncryption DBEncryptionConfig + | TestStorageEncryption DBEncryptionKey | ExecChatStoreSQL Text | ExecAgentStoreSQL Text | SlowSQLQueries @@ -452,6 +457,9 @@ data ChatCommand | ListRemoteCtrls | StopRemoteCtrl -- Stop listening for announcements or terminate an active session | DeleteRemoteCtrl RemoteCtrlId -- Remove all local data associated with a remote controller session + | APIUploadStandaloneFile UserId CryptoFile + | APIDownloadStandaloneFile UserId FileDescriptionURI CryptoFile + | APIStandaloneFileInfo FileDescriptionURI | QuitChat | ShowVersion | DebugLocks @@ -473,7 +481,6 @@ allowRemoteCommand = \case SetTempFolder _ -> False SetFilesFolder _ -> False SetRemoteHostsFolder _ -> False - APISetXFTPConfig _ -> False APISetEncryptLocalFiles _ -> False APIExportArchive _ -> False APIImportArchive _ -> False @@ -592,21 +599,27 @@ data ChatResponse | CRRcvFileAccepted {user :: User, chatItem :: AChatItem} | CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} | CRRcvFileDescrNotReady {user :: User, chatItem :: AChatItem} - | CRRcvFileStart {user :: User, chatItem :: AChatItem} - | CRRcvFileProgressXFTP {user :: User, chatItem :: AChatItem, receivedSize :: Int64, totalSize :: Int64} + | CRStandaloneFileInfo {fileMeta :: Maybe J.Value} + | CRRcvStandaloneFileCreated {user :: User, rcvFileTransfer :: RcvFileTransfer} -- returned by _download + | CRRcvFileStart {user :: User, chatItem :: AChatItem} -- sent by chats + | CRRcvFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, receivedSize :: Int64, totalSize :: Int64, rcvFileTransfer :: RcvFileTransfer} | CRRcvFileComplete {user :: User, chatItem :: AChatItem} - | CRRcvFileCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer} + | CRRcvStandaloneFileComplete {user :: User, targetPath :: FilePath, rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, rcvFileTransfer :: RcvFileTransfer} | CRRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileError {user :: User, chatItem :: AChatItem, agentError :: AgentErrorType} + | CRRcvFileError {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer} | CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} | CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileRcvCancelled {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileCancelled {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} - | CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} - | CRSndFileProgressXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64} + | CRSndFileRcvCancelled {user :: User, chatItem_ :: Maybe AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} + | CRSndStandaloneFileCreated {user :: User, fileTransferMeta :: FileTransferMeta} -- returned by _upload + | CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} -- not used + | CRSndFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64} + | CRSndFileRedirectStartXFTP {user :: User, fileTransferMeta :: FileTransferMeta, redirectMeta :: FileTransferMeta} | CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} - | CRSndFileCancelledXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} - | CRSndFileError {user :: User, chatItem :: AChatItem} + | CRSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]} + | CRSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta} + | CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta} | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary} | CRUserProfileImage {user :: User, profile :: Profile} | CRContactAliasUpdated {user :: User, toContact :: Contact} @@ -672,7 +685,7 @@ data ChatResponse | CRUserContactLinkSubscribed -- TODO delete | CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete | CRNtfTokenStatus {status :: NtfTknStatus} - | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} + | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer} | CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgInfo} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} @@ -690,6 +703,8 @@ data ChatResponse | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} + | CRContactPQAllowed {user :: User, contact :: Contact, pqEncryption :: PQEncryption} + | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -707,6 +722,7 @@ data ChatResponse | CRChatError {user_ :: Maybe User, chatError :: ChatError} | CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} | CRArchiveImported {archiveErrors :: [ArchiveError]} + | CRAppSettings {appSettings :: AppSettings} | CRTimedAction {action :: String, durationMilliseconds :: Int64} deriving (Show) @@ -934,22 +950,14 @@ instance FromJSON ComposedMessage where parseJSON invalid = JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) -data XFTPFileConfig = XFTPFileConfig - { minFileSize :: Integer - } - deriving (Show) - -defaultXFTPFileConfig :: XFTPFileConfig -defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0} - data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime} deriving (Show) ntfMsgInfo :: SMPMsgMeta -> NtfMsgInfo ntfMsgInfo SMPMsgMeta {msgId, msgTs} = NtfMsgInfo {msgId = decodeLatin1 $ strEncode msgId, msgTs = systemToUTCTime msgTs} -crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse -crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode} +crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode, NtfServer) -> ChatResponse +crNtfToken (token, status, ntfMode, ntfServer) = CRNtfToken {token, status, ntfMode, ntfServer} data SwitchProgress = SwitchProgress { queueDirection :: QueueDirection, @@ -1001,11 +1009,6 @@ data CoreVersionInfo = CoreVersionInfo } deriving (Show) -data SendFileMode - = SendFileSMP (Maybe InlineFileMode) - | SendFileXFTP - deriving (Show) - data SlowSQLQuery = SlowSQLQuery { query :: Text, queryStats :: SlowQueryStats @@ -1257,6 +1260,14 @@ mkChatError :: SomeException -> ChatError mkChatError = ChatError . CEException . show {-# INLINE mkChatError #-} +catchStoreError :: ExceptT StoreError IO a -> (StoreError -> ExceptT StoreError IO a) -> ExceptT StoreError IO a +catchStoreError = catchAllErrors mkStoreError +{-# INLINE catchStoreError #-} + +mkStoreError :: SomeException -> StoreError +mkStoreError = SEInternalError . show +{-# INLINE mkStoreError #-} + chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError user = CRChatCmdError user . ChatError . CECommandError @@ -1409,6 +1420,4 @@ $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) $(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig) -$(JQ.deriveJSON defaultJSON ''XFTPFileConfig) - $(JQ.deriveToJSON defaultJSON ''ComposedMessage) diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index ac93e05533..adb77b9557 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -185,6 +185,8 @@ contactsHelpInfo = indent <> highlight "/verify @ " <> " - clear security code verification", indent <> highlight "/info @ " <> " - info about contact connection", indent <> highlight "/switch @ " <> " - switch receiving messages to another SMP relay", + indent <> highlight "/pq @ on/off " <> " - [BETA] toggle quantum resistant / standard e2e encryption for a contact", + indent <> " " <> " (both have to enable for quantum resistance)", "", green "Contact chat preferences:", indent <> highlight "/set voice @ yes/no/always " <> " - allow/prohibit voice messages with the contact", @@ -320,6 +322,7 @@ settingsInfo = map styleMarkdown [ green "Chat settings:", + indent <> highlight "/pq on/off " <> " - [BETA] toggle quantum resistant / standard e2e encryption for the new contacts", indent <> highlight "/network " <> " - show / set network access options", indent <> highlight "/smp " <> " - show / set configured SMP servers", indent <> highlight "/xftp " <> " - show / set configured XFTP servers", diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 6ee4898e3d..2eabb48166 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -30,10 +30,11 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Types import Simplex.Chat.Types.Util -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) +import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..)) +import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types import qualified Text.Email.Validate as Email @@ -231,10 +232,10 @@ markdownP = mconcat <$> A.many' fragmentP simplexUriFormat :: AConnectionRequestUri -> Format simplexUriFormat = \case ACR _ (CRContactUri crData) -> - let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = CRSSimplex} + let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = SSSimplex} in SimplexLink (linkType' crData) uri $ uriHosts crData ACR _ (CRInvitationUri crData e2e) -> - let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = CRSSimplex} e2e + let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = SSSimplex} e2e in SimplexLink XLInvitation uri $ uriHosts crData where uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 50b098bb71..4312cfa858 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -360,6 +360,24 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted item _ -> False in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByMember, createdAt, updatedAt} +dummyMeta :: ChatItemId -> UTCTime -> Text -> CIMeta c 'MDSnd +dummyMeta itemId ts itemText = + CIMeta + { itemId, + itemTs = ts, + itemText, + itemStatus = CISSndNew, + itemSharedMsgId = Nothing, + itemDeleted = Nothing, + itemEdited = False, + itemTimed = Nothing, + itemLive = Nothing, + editable = False, + forwardedByMember = Nothing, + createdAt = ts, + updatedAt = ts + } + data CITimed = CITimed { ttl :: Int, -- seconds deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index f0ce2d6274..0e95570b85 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -6,6 +6,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TemplateHaskell #-} @@ -29,6 +30,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (MsgErrorType (..), RatchetSyncState (..), SwitchPhase (..)) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOn, pattern PQEncOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, tshow, (<$?>)) @@ -139,13 +141,21 @@ data CIContent (d :: MsgDirection) where CISndModerated :: CIContent 'MDSnd CIRcvModerated :: CIContent 'MDRcv CIRcvBlocked :: CIContent 'MDRcv - CIInvalidJSON :: Text -> CIContent d + CISndDirectE2EEInfo :: E2EInfo -> CIContent 'MDSnd + CIRcvDirectE2EEInfo :: E2EInfo -> CIContent 'MDRcv + CISndGroupE2EEInfo :: E2EInfo -> CIContent 'MDSnd -- when new group is created + CIRcvGroupE2EEInfo :: E2EInfo -> CIContent 'MDRcv -- when enabled with some member + CIInvalidJSON :: Text -> CIContent d -- this is also used for logical database errors, e.g. SEBadChatItem + -- ^ This type is used both in API and in DB, so we use different JSON encodings for the database and for the API -- ! ^ Nested sum types also have to use different encodings for database and API -- ! ^ to avoid breaking cross-platform compatibility, see RcvGroupEvent and SndGroupEvent deriving instance Show (CIContent d) +data E2EInfo = E2EInfo {pqEnabled :: PQEncryption} + deriving (Eq, Show) + ciMsgContent :: CIContent d -> Maybe MsgContent ciMsgContent = \case CISndMsgContent mc -> Just mc @@ -172,7 +182,7 @@ ciRequiresAttention content = case msgDirection @d of CIRcvGroupInvitation {} -> True CIRcvDirectEvent rde -> case rde of RDEContactDeleted -> False - RDEProfileUpdated {} -> True + RDEProfileUpdated {} -> False CIRcvGroupEvent rge -> case rge of RGEMemberAdded {} -> False RGEMemberConnected -> False @@ -195,6 +205,8 @@ ciRequiresAttention content = case msgDirection @d of CIRcvGroupFeatureRejected _ -> True CIRcvModerated -> True CIRcvBlocked -> False + CIRcvDirectE2EEInfo _ -> False + CIRcvGroupE2EEInfo _ -> False CIInvalidJSON _ -> False newtype DBMsgErrorType = DBME MsgErrorType @@ -250,8 +262,28 @@ ciContentToText = \case CISndModerated -> ciModeratedText CIRcvModerated -> ciModeratedText CIRcvBlocked -> "blocked" + CISndDirectE2EEInfo e2eeInfo -> directE2EInfoToText e2eeInfo + CIRcvDirectE2EEInfo e2eeInfo -> directE2EInfoToText e2eeInfo + CISndGroupE2EEInfo e2eeInfo -> groupE2EInfoToText e2eeInfo + CIRcvGroupE2EEInfo e2eeInfo -> groupE2EInfoToText e2eeInfo CIInvalidJSON _ -> "invalid content JSON" +directE2EInfoToText :: E2EInfo -> Text +directE2EInfoToText E2EInfo {pqEnabled} = case pqEnabled of + PQEncOn -> e2eInfoPQText + PQEncOff -> e2eInfoNoPQText + +groupE2EInfoToText :: E2EInfo -> Text +groupE2EInfoToText _e2eeInfo = e2eInfoNoPQText + +e2eInfoNoPQText :: Text +e2eInfoNoPQText = + "This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery." + +e2eInfoPQText :: Text +e2eInfoPQText = + "This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery." + ciGroupInvitationToText :: CIGroupInvitation -> GroupMemberRole -> Text ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayName, fullName}} role = "invitation to join group " <> displayName <> optionalFullName displayName fullName <> " as " <> (decodeLatin1 . strEncode $ role) @@ -295,6 +327,9 @@ rcvConnEventToText = \case SPCompleted -> "changed address for you" RCERatchetSync syncStatus -> ratchetSyncStatusToText syncStatus RCEVerificationCodeReset -> "security code changed" + RCEPqEnabled pqEnc -> case pqEnc of + PQEncOn -> "quantum resistant e2e encryption" + PQEncOff -> "standard end-to-end encryption" ratchetSyncStatusToText :: RatchetSyncState -> Text ratchetSyncStatusToText = \case @@ -312,6 +347,9 @@ sndConnEventToText = \case SPSecured -> "secured new address" <> forMember m <> "..." SPCompleted -> "you changed address" <> forMember m SCERatchetSync syncStatus m -> ratchetSyncStatusToText syncStatus <> forMember m + SCEPqEnabled pqEnc -> case pqEnc of + PQEncOn -> "quantum resistant e2e encryption" + PQEncOff -> "standard end-to-end encryption" where forMember member_ = maybe "" (\GroupMemberRef {profile = Profile {displayName}} -> " for " <> displayName) member_ @@ -382,6 +420,10 @@ data JSONCIContent | JCISndModerated | JCIRcvModerated | JCIRcvBlocked + | JCISndDirectE2EEInfo {e2eeInfo :: E2EInfo} + | JCIRcvDirectE2EEInfo {e2eeInfo :: E2EInfo} + | JCISndGroupE2EEInfo {e2eeInfo :: E2EInfo} + | JCIRcvGroupE2EEInfo {e2eeInfo :: E2EInfo} | JCIInvalidJSON {direction :: MsgDirection, json :: Text} jsonCIContent :: forall d. MsgDirectionI d => CIContent d -> JSONCIContent @@ -412,6 +454,10 @@ jsonCIContent = \case CISndModerated -> JCISndModerated CIRcvModerated -> JCIRcvModerated CIRcvBlocked -> JCIRcvBlocked + CISndDirectE2EEInfo e2eeInfo -> JCISndDirectE2EEInfo e2eeInfo + CIRcvDirectE2EEInfo e2eeInfo -> JCIRcvDirectE2EEInfo e2eeInfo + CISndGroupE2EEInfo e2eeInfo -> JCISndGroupE2EEInfo e2eeInfo + CIRcvGroupE2EEInfo e2eeInfo -> JCIRcvGroupE2EEInfo e2eeInfo CIInvalidJSON json -> JCIInvalidJSON (toMsgDirection $ msgDirection @d) json aciContentJSON :: JSONCIContent -> ACIContent @@ -442,6 +488,10 @@ aciContentJSON = \case JCISndModerated -> ACIContent SMDSnd CISndModerated JCIRcvModerated -> ACIContent SMDRcv CIRcvModerated JCIRcvBlocked -> ACIContent SMDRcv CIRcvBlocked + JCISndDirectE2EEInfo {e2eeInfo} -> ACIContent SMDSnd $ CISndDirectE2EEInfo e2eeInfo + JCIRcvDirectE2EEInfo {e2eeInfo} -> ACIContent SMDRcv $ CIRcvDirectE2EEInfo e2eeInfo + JCISndGroupE2EEInfo {e2eeInfo} -> ACIContent SMDSnd $ CISndGroupE2EEInfo e2eeInfo + JCIRcvGroupE2EEInfo {e2eeInfo} -> ACIContent SMDRcv $ CIRcvGroupE2EEInfo e2eeInfo JCIInvalidJSON dir json -> case fromMsgDirection dir of AMsgDirection d -> ACIContent d $ CIInvalidJSON json @@ -473,6 +523,10 @@ data DBJSONCIContent | DBJCISndModerated | DBJCIRcvModerated | DBJCIRcvBlocked + | DBJCISndDirectE2EEInfo {e2eeInfo :: E2EInfo} + | DBJCIRcvDirectE2EEInfo {e2eeInfo :: E2EInfo} + | DBJCISndGroupE2EEInfo {e2eeInfo :: E2EInfo} + | DBJCIRcvGroupE2EEInfo {e2eeInfo :: E2EInfo} | DBJCIInvalidJSON {direction :: MsgDirection, json :: Text} dbJsonCIContent :: forall d. MsgDirectionI d => CIContent d -> DBJSONCIContent @@ -503,6 +557,10 @@ dbJsonCIContent = \case CISndModerated -> DBJCISndModerated CIRcvModerated -> DBJCIRcvModerated CIRcvBlocked -> DBJCIRcvBlocked + CISndDirectE2EEInfo e2eeInfo -> DBJCISndDirectE2EEInfo e2eeInfo + CIRcvDirectE2EEInfo e2eeInfo -> DBJCIRcvDirectE2EEInfo e2eeInfo + CISndGroupE2EEInfo e2eeInfo -> DBJCISndGroupE2EEInfo e2eeInfo + CIRcvGroupE2EEInfo e2eeInfo -> DBJCIRcvGroupE2EEInfo e2eeInfo CIInvalidJSON json -> DBJCIInvalidJSON (toMsgDirection $ msgDirection @d) json aciContentDBJSON :: DBJSONCIContent -> ACIContent @@ -533,6 +591,10 @@ aciContentDBJSON = \case DBJCISndModerated -> ACIContent SMDSnd CISndModerated DBJCIRcvModerated -> ACIContent SMDRcv CIRcvModerated DBJCIRcvBlocked -> ACIContent SMDRcv CIRcvBlocked + DBJCISndDirectE2EEInfo e2eeInfo -> ACIContent SMDSnd $ CISndDirectE2EEInfo e2eeInfo + DBJCIRcvDirectE2EEInfo e2eeInfo -> ACIContent SMDRcv $ CIRcvDirectE2EEInfo e2eeInfo + DBJCISndGroupE2EEInfo e2eeInfo -> ACIContent SMDSnd $ CISndGroupE2EEInfo e2eeInfo + DBJCIRcvGroupE2EEInfo e2eeInfo -> ACIContent SMDRcv $ CIRcvGroupE2EEInfo e2eeInfo DBJCIInvalidJSON dir json -> case fromMsgDirection dir of AMsgDirection d -> ACIContent d $ CIInvalidJSON json @@ -558,6 +620,8 @@ ciCallInfoText status duration = case status of CISCallEnded -> "ended " <> durationText duration CISCallError -> "error" +$(JQ.deriveJSON defaultJSON ''E2EInfo) + $(JQ.deriveJSON (enumJSON $ dropPrefix "MDE") ''MsgDecryptError) $(JQ.deriveJSON (enumJSON $ dropPrefix "CIGIS") ''CIGroupInvitationStatus) @@ -626,4 +690,8 @@ toCIContentTag ciContent = case ciContent of CISndModerated -> "sndModerated" CIRcvModerated -> "rcvModerated" CIRcvBlocked -> "rcvBlocked" + CISndDirectE2EEInfo _ -> "sndDirectE2EEInfo" + CIRcvDirectE2EEInfo _ -> "rcvDirectE2EEInfo" + CISndGroupE2EEInfo _ -> "sndGroupE2EEInfo" + CIRcvGroupE2EEInfo _ -> "rcvGroupE2EEInfo" CIInvalidJSON _ -> "invalidJSON" diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 05417a2e14..7ce5f73cde 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -9,6 +9,7 @@ import qualified Data.Aeson.TH as J import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (RatchetSyncState (..), SwitchPhase (..)) import Simplex.Messaging.Parsers (dropPrefix, singleFieldJSON, sumTypeJSON) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption) data RcvGroupEvent = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting @@ -42,11 +43,13 @@ data RcvConnEvent = RCESwitchQueue {phase :: SwitchPhase} | RCERatchetSync {syncStatus :: RatchetSyncState} | RCEVerificationCodeReset + | RCEPqEnabled {enabled :: PQEncryption} deriving (Show) data SndConnEvent = SCESwitchQueue {phase :: SwitchPhase, member :: Maybe GroupMemberRef} | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} + | SCEPqEnabled {enabled :: PQEncryption} deriving (Show) data RcvDirectEvent diff --git a/src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs b/src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs new file mode 100644 index 0000000000..da8f4d413b --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240214_redirect_file_id where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240214_redirect_file_id :: Query +m20240214_redirect_file_id = + [sql| +ALTER TABLE files ADD COLUMN redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE; + +CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id); +|] + +down_m20240214_redirect_file_id :: Query +down_m20240214_redirect_file_id = + [sql| +DROP INDEX idx_files_redirect_file_id; + +ALTER TABLE files DROP COLUMN redirect_file_id; +|] diff --git a/src/Simplex/Chat/Migrations/M20240222_app_settings.hs b/src/Simplex/Chat/Migrations/M20240222_app_settings.hs new file mode 100644 index 0000000000..e7fda06a2e --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240222_app_settings.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240222_app_settings where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240222_app_settings :: Query +m20240222_app_settings = + [sql| +CREATE TABLE app_settings ( + app_settings TEXT NOT NULL +); +|] + +down_m20240222_app_settings :: Query +down_m20240222_app_settings = + [sql| +DROP TABLE app_settings; +|] diff --git a/src/Simplex/Chat/Migrations/M20240226_users_restrict.hs b/src/Simplex/Chat/Migrations/M20240226_users_restrict.hs new file mode 100644 index 0000000000..a68923142c --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240226_users_restrict.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240226_users_restrict where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240226_users_restrict :: Query +m20240226_users_restrict = + [sql| +PRAGMA writable_schema=1; + +UPDATE sqlite_master +SET sql = replace(sql, 'ON DELETE CASCADE', 'ON DELETE RESTRICT') +WHERE name = 'users' AND type = 'table'; + +PRAGMA writable_schema=0; +|] + +down_m20240226_users_restrict :: Query +down_m20240226_users_restrict = + [sql| +PRAGMA writable_schema=1; + +UPDATE sqlite_master +SET sql = replace(sql, 'ON DELETE RESTRICT', 'ON DELETE CASCADE') +WHERE name = 'users' AND type = 'table'; + +PRAGMA writable_schema=0; +|] diff --git a/src/Simplex/Chat/Migrations/M20240228_pq.hs b/src/Simplex/Chat/Migrations/M20240228_pq.hs new file mode 100644 index 0000000000..c496d33b4b --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240228_pq.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240228_pq where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240228_pq :: Query +m20240228_pq = + [sql| +ALTER TABLE connections ADD COLUMN conn_chat_version INTEGER; +ALTER TABLE connections ADD COLUMN pq_support INTEGER NOT NULL DEFAULT 0; +ALTER TABLE connections ADD COLUMN pq_encryption INTEGER NOT NULL DEFAULT 0; +ALTER TABLE connections ADD COLUMN pq_snd_enabled INTEGER; +ALTER TABLE connections ADD COLUMN pq_rcv_enabled INTEGER; + +ALTER TABLE contact_requests ADD COLUMN pq_support INTEGER NOT NULL DEFAULT 0; +|] + +down_m20240228_pq :: Query +down_m20240228_pq = + [sql| +ALTER TABLE contact_requests DROP COLUMN pq_support; + +ALTER TABLE connections DROP COLUMN conn_chat_version; +ALTER TABLE connections DROP COLUMN pq_support; +ALTER TABLE connections DROP COLUMN pq_encryption; +ALTER TABLE connections DROP COLUMN pq_snd_enabled; +ALTER TABLE connections DROP COLUMN pq_rcv_enabled; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index efed6d168a..19c6ba24d0 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -22,7 +22,7 @@ CREATE TABLE contact_profiles( ); CREATE TABLE users( user_id INTEGER PRIMARY KEY, - contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE + contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED, local_display_name TEXT NOT NULL UNIQUE, active_user INTEGER NOT NULL DEFAULT 0, @@ -37,7 +37,7 @@ CREATE TABLE users( user_member_profile_updated_at TEXT, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) - ON DELETE CASCADE + ON DELETE RESTRICT ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED ); @@ -193,7 +193,8 @@ CREATE TABLE files( protocol TEXT NOT NULL DEFAULT 'smp', file_crypto_key BLOB, file_crypto_nonce BLOB, - note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE + note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE, + redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE ); CREATE TABLE snd_files( file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, @@ -276,6 +277,11 @@ CREATE TABLE connections( peer_chat_max_version INTEGER NOT NULL DEFAULT 1, to_subscribe INTEGER DEFAULT 0 NOT NULL, contact_conn_initiated INTEGER NOT NULL DEFAULT 0, + conn_chat_version INTEGER, + pq_support INTEGER NOT NULL DEFAULT 0, + pq_encryption INTEGER NOT NULL DEFAULT 0, + pq_snd_enabled INTEGER, + pq_rcv_enabled INTEGER, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -311,6 +317,7 @@ CREATE TABLE contact_requests( xcontact_id BLOB, peer_chat_min_version INTEGER NOT NULL DEFAULT 1, peer_chat_max_version INTEGER NOT NULL DEFAULT 1, + pq_support INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON UPDATE CASCADE @@ -561,6 +568,7 @@ CREATE TABLE note_folders( favorite INTEGER NOT NULL DEFAULT 0, unread_chat INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE app_settings(app_settings TEXT NOT NULL); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -854,3 +862,4 @@ CREATE INDEX idx_chat_items_notes_item_status on chat_items( note_folder_id, item_status ); +CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id); diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index c4423bfe6a..85ef027335 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -7,6 +7,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} @@ -30,6 +31,7 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.ByteString.Internal (c2w, w2c) import qualified Data.ByteString.Lazy.Char8 as LB +import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.String import Data.Text (Text) @@ -44,49 +46,71 @@ import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Util +import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) +import Simplex.Messaging.Compression (CompressCtx, compress, decompressBatch) +import Simplex.Messaging.Crypto.Ratchet (PQSupport (..), pattern PQSupportOn, pattern PQSupportOff) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) +-- Chat version history: +-- 1 - support chat versions in connections (9/1/2023) +-- 2 - create contacts for group members only via x.grp.direct.inv (9/16/2023) +-- 3 - faster joining via group links without creating contact (10/30/2023) +-- 4 - group message forwarding (11/18/2023) +-- 5 - batch sending messages (12/23/2023) +-- 6 - send group welcome message after history (12/29/2023) +-- 7 - update member profiles (1/15/2024) + -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. -currentChatVersion :: Version -currentChatVersion = 7 +currentChatVersion :: VersionChat +currentChatVersion = VersionChat 7 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) -supportedChatVRange :: VersionRange -supportedChatVRange = mkVersionRange 1 currentChatVersion +-- TODO remove parameterization in 5.7 +supportedChatVRange :: PQSupport -> VersionRangeChat +supportedChatVRange pq = mkVersionRange initialChatVersion $ case pq of + PQSupportOn -> pqEncryptionCompressionVersion + PQSupportOff -> currentChatVersion +{-# INLINE supportedChatVRange #-} --- version range that supports skipping establishing direct connections in a group -groupNoDirectVRange :: VersionRange -groupNoDirectVRange = mkVersionRange 2 currentChatVersion - --- version range that supports establishing direct connection via x.grp.direct.inv with a group member -xGrpDirectInvVRange :: VersionRange -xGrpDirectInvVRange = mkVersionRange 2 currentChatVersion +-- version range that supports skipping establishing direct connections in a group and establishing direct connection via x.grp.direct.inv +groupDirectInvVersion :: VersionChat +groupDirectInvVersion = VersionChat 2 -- version range that supports joining group via group link without creating direct contact -groupLinkNoContactVRange :: VersionRange -groupLinkNoContactVRange = mkVersionRange 3 currentChatVersion +groupFastLinkJoinVersion :: VersionChat +groupFastLinkJoinVersion = VersionChat 3 -- version range that supports group forwarding -groupForwardVRange :: VersionRange -groupForwardVRange = mkVersionRange 4 currentChatVersion +groupForwardVersion :: VersionChat +groupForwardVersion = VersionChat 4 -- version range that supports batch sending in groups -batchSendVRange :: VersionRange -batchSendVRange = mkVersionRange 5 currentChatVersion +batchSendVersion :: VersionChat +batchSendVersion = VersionChat 5 -- version range that supports sending group welcome message in group history -groupHistoryIncludeWelcomeVRange :: VersionRange -groupHistoryIncludeWelcomeVRange = mkVersionRange 6 currentChatVersion +groupHistoryIncludeWelcomeVersion :: VersionChat +groupHistoryIncludeWelcomeVersion = VersionChat 6 -- version range that supports sending member profile updates to groups -memberProfileUpdateVRange :: VersionRange -memberProfileUpdateVRange = mkVersionRange 7 currentChatVersion +memberProfileUpdateVersion :: VersionChat +memberProfileUpdateVersion = VersionChat 7 + +-- version range that supports compressing messages and PQ e2e encryption +pqEncryptionCompressionVersion :: VersionChat +pqEncryptionCompressionVersion = VersionChat 8 + +agentToChatVersion :: VersionSMPA -> VersionChat +agentToChatVersion v + | v < pqdrSMPAgentVersion = initialChatVersion + | otherwise = pqEncryptionCompressionVersion data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} @@ -217,7 +241,7 @@ instance ToJSON LinkContent where $(JQ.deriveJSON defaultJSON ''LinkPreview) data ChatMessage e = ChatMessage - { chatVRange :: VersionRange, + { chatVRange :: VersionRangeChat, msgId :: Maybe SharedMsgId, chatMsgEvent :: ChatMsgEvent e } @@ -507,17 +531,29 @@ $(JQ.deriveJSON defaultJSON ''QuotedMsg) -- this limit reserves space for metadata in forwarded messages -- 15780 (limit used for fileChunkSize) - 161 (x.grp.msg.forward overhead) = 15619, round to 15610 -maxChatMsgSize :: Int -maxChatMsgSize = 15610 +maxEncodedMsgLength :: Int +maxEncodedMsgLength = 15610 + +-- maxEncodedMsgLength - 2222, see e2eEncUserMsgLength in agent +maxCompressedMsgLength :: Int +maxCompressedMsgLength = 13388 + +-- maxEncodedMsgLength - delta between MSG and INFO + 100 (returned for forward overhead) +-- delta between MSG and INFO = e2eEncUserMsgLength (no PQ) - e2eEncConnInfoLength (no PQ) = 1008 +maxEncodedInfoLength :: Int +maxEncodedInfoLength = 14702 + +maxCompressedInfoLength :: Int +maxCompressedInfoLength = 10976 -- maxEncodedInfoLength - 3726, see e2eEncConnInfoLength in agent data EncodedChatMessage = ECMEncoded ByteString | ECMLarge -encodeChatMessage :: MsgEncodingI e => ChatMessage e -> EncodedChatMessage -encodeChatMessage msg = do +encodeChatMessage :: MsgEncodingI e => Int -> ChatMessage e -> EncodedChatMessage +encodeChatMessage maxSize msg = do case chatToAppMessage msg of AMJson m -> do let body = LB.toStrict $ J.encode m - if B.length body > maxChatMsgSize + if B.length body > maxSize then ECMLarge else ECMEncoded body AMBinary m -> ECMEncoded $ strEncode m @@ -529,10 +565,23 @@ parseChatMessages s = case B.head s of '[' -> case J.eitherDecodeStrict' s of Right v -> map parseItem v Left e -> [Left e] + 'X' -> decodeCompressed (B.drop 1 s) _ -> [ACMsg SBinary <$> (appBinaryToCM =<< strDecode s)] where parseItem :: J.Value -> Either String AChatMessage parseItem v = ACMsg SJson <$> JT.parseEither parseJSON v + decodeCompressed :: ByteString -> [Either String AChatMessage] + decodeCompressed s' = case smpDecode s' of + Left e -> [Left e] + -- TODO v5.7 don't reserve multiple large buffers when decoding batches + Right compressed -> concatMap (either (pure . Left) parseChatMessages) . L.toList $ decompressBatch maxEncodedMsgLength compressed + +compressedBatchMsgBody_ :: CompressCtx -> MsgBody -> IO ByteString +compressedBatchMsgBody_ ctx msgBody = markCompressedBatch . smpEncode . (L.:| []) <$> compress ctx msgBody + +markCompressedBatch :: ByteString -> ByteString +markCompressedBatch = B.cons 'X' +{-# INLINE markCompressedBatch #-} parseMsgContainer :: J.Object -> JT.Parser MsgContainer parseMsgContainer v = diff --git a/src/Simplex/Chat/Store/AppSettings.hs b/src/Simplex/Chat/Store/AppSettings.hs new file mode 100644 index 0000000000..ee0dd30183 --- /dev/null +++ b/src/Simplex/Chat/Store/AppSettings.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Store.AppSettings where + +import Control.Monad (join) +import Control.Monad.IO.Class (liftIO) +import qualified Data.Aeson as J +import Data.Maybe (fromMaybe) +import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.AppSettings (AppSettings (..), combineAppSettings, defaultAppSettings, defaultParseAppSettings) +import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB + +saveAppSettings :: DB.Connection -> AppSettings -> IO () +saveAppSettings db appSettings = do + DB.execute_ db "DELETE FROM app_settings" + DB.execute db "INSERT INTO app_settings (app_settings) VALUES (?)" (Only $ J.encode appSettings) + +getAppSettings :: DB.Connection -> Maybe AppSettings -> IO AppSettings +getAppSettings db platformDefaults = do + stored_ <- join <$> liftIO (maybeFirstRow (J.decodeStrict . fromOnly) $ DB.query_ db "SELECT app_settings FROM app_settings") + pure $ combineAppSettings (fromMaybe defaultAppSettings platformDefaults) (fromMaybe defaultParseAppSettings stored_) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index fdc3703219..f8e9fa3401 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -34,10 +34,10 @@ import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Util (eitherToMaybe) -import Simplex.Messaging.Version (VersionRange) -getConnectionEntity :: DB.Connection -> VersionRange -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity +getConnectionEntity :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do c@Connection {connType, entityId} <- getConnection_ case entityId of @@ -55,13 +55,14 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do where getConnection_ :: ExceptT StoreError IO Connection getConnection_ = ExceptT $ do - firstRow toConnection (SEConnectionNotFound agentConnId) $ + firstRow (toConnection vr) (SEConnectionNotFound agentConnId) $ DB.query db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, - peer_chat_min_version, peer_chat_max_version + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, + conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? |] @@ -157,7 +158,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound -getConnectionEntityByConnReq :: DB.Connection -> VersionRange -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) +getConnectionEntityByConnReq :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do connId_ <- maybeFirstRow fromOnly $ @@ -168,7 +169,7 @@ getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) -- multiple connections can have same via_contact_uri_hash if request was repeated; -- this function searches for latest connection with contact so that "known contact" plan would be chosen; -- deleted connections are filtered out to allow re-connecting via same contact address -getContactConnEntityByConnReqHash :: DB.Connection -> VersionRange -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) +getContactConnEntityByConnReqHash :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2) = do connId_ <- maybeFirstRow fromOnly $ @@ -188,7 +189,7 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2 (userId, cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ -getConnectionsToSubscribe :: DB.Connection -> VersionRange -> IO ([ConnId], [ConnectionEntity]) +getConnectionsToSubscribe :: DB.Connection -> (PQSupport -> VersionRangeChat) -> IO ([ConnId], [ConnectionEntity]) getConnectionsToSubscribe db vr = do aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" entities <- forM aConnIds $ \acId -> do diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 43d58d3ffa..47174a59a6 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -89,6 +89,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Version @@ -124,14 +125,14 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createAddressContactConnection :: DB.Connection -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> ExceptT StoreError IO Contact -createAddressContactConnection db user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode = do - PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode +createAddressContactConnection :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> ExceptT StoreError IO Contact +createAddressContactConnection db vr user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode chatV pqSup = do + PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode chatV pqSup liftIO $ DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, pccConnId) - getContact db user contactId + getContact db vr user contactId -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> IO PendingContactConnection -createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode chatV pqSup = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let pccConnStatus = ConnJoined @@ -140,16 +141,20 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, - via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, + created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate)) + ( (userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) + :. (customUserProfileId, isJust groupLinkId, groupLinkId) + :. (createdAt, createdAt, subMode == SMOnlyCreate, chatV, pqSup, pqSup) + ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} -getConnReqContactXContactId :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) -getConnReqContactXContactId db user@User {userId} cReqHash = do - getContactByConnReqHash db user cReqHash >>= \case +getConnReqContactXContactId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) +getConnReqContactXContactId db vr user@User {userId} cReqHash = do + getContactByConnReqHash db vr user cReqHash >>= \case c@(Just _) -> pure (c, Nothing) Nothing -> (Nothing,) <$> getXContactId where @@ -161,9 +166,9 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) -getContactByConnReqHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact) -getContactByConnReqHash db user@User {userId} cReqHash = - maybeFirstRow (toContact user) $ +getContactByConnReqHash :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ConnReqUriHash -> IO (Maybe Contact) +getContactByConnReqHash db vr user@User {userId} cReqHash = + maybeFirstRow (toContact vr user) $ DB.query db [sql| @@ -173,8 +178,8 @@ getContactByConnReqHash db user@User {userId} cReqHash = cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id @@ -184,8 +189,8 @@ getContactByConnReqHash db user@User {userId} cReqHash = |] (userId, cReqHash, CSActive) -createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> IO PendingContactConnection -createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode = do +createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection +createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode chatV pqSup = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let contactConnInitiated = pccConnStatus == ConnNew @@ -193,9 +198,13 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile db [sql| INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?,?) + (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, + created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate) + ( (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId) + :. (createdAt, createdAt, subMode == SMOnlyCreate, chatV, pqSup, pqSup) + ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -229,45 +238,53 @@ deleteContactConnectionsAndFiles db userId Contact {contactId} = do (userId, contactId) DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId) -deleteContact :: DB.Connection -> User -> Contact -> IO () -deleteContact db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do - DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) - if isNothing ctMember - then do - deleteContactProfile_ db userId contactId - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) - else do - currentTs <- getCurrentTime - DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) - DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) - forM_ activeConn $ \Connection {customUserProfileId} -> - forM_ customUserProfileId $ \profileId -> - deleteUnusedIncognitoProfileById_ db user profileId +deleteContact :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () +deleteContact db user@User {userId} ct@Contact {contactId, localDisplayName, activeConn} = do + assertNotUser db user ct + liftIO $ do + DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) + ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) + if isNothing ctMember + then do + deleteContactProfile_ db userId contactId + -- user's local display name already checked in assertNotUser + DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + else do + currentTs <- getCurrentTime + DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) + DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) + forM_ activeConn $ \Connection {customUserProfileId} -> + forM_ customUserProfileId $ \profileId -> + deleteUnusedIncognitoProfileById_ db user profileId -- should only be used if contact is not member of any groups -deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> IO () -deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do - DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - deleteContactProfile_ db userId contactId - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) - DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) - forM_ activeConn $ \Connection {customUserProfileId} -> - forM_ customUserProfileId $ \profileId -> - deleteUnusedIncognitoProfileById_ db user profileId +deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () +deleteContactWithoutGroups db user@User {userId} ct@Contact {contactId, localDisplayName, activeConn} = do + assertNotUser db user ct + liftIO $ do + DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) + deleteContactProfile_ db userId contactId + -- user's local display name already checked in assertNotUser + DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) + forM_ activeConn $ \Connection {customUserProfileId} -> + forM_ customUserProfileId $ \profileId -> + deleteUnusedIncognitoProfileById_ db user profileId -setContactDeleted :: DB.Connection -> User -> Contact -> IO () -setContactDeleted db User {userId} Contact {contactId} = do - currentTs <- getCurrentTime - DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) +setContactDeleted :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () +setContactDeleted db user@User {userId} ct@Contact {contactId} = do + assertNotUser db user ct + liftIO $ do + currentTs <- getCurrentTime + DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) -getDeletedContacts :: DB.Connection -> User -> IO [Contact] -getDeletedContacts db user@User {userId} = do +getDeletedContacts :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> IO [Contact] +getDeletedContacts db vr user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1" (Only userId) - rights <$> mapM (runExceptT . getDeletedContact db user) contactIds + rights <$> mapM (runExceptT . getDeletedContact db vr user) contactIds -getDeletedContact :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact -getDeletedContact db user contactId = getContact_ db user contactId True +getDeletedContact :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO Contact +getDeletedContact db vr user contactId = getContact_ db vr user contactId True deleteContactProfile_ :: DB.Connection -> UserId -> ContactId -> IO () deleteContactProfile_ db userId contactId = @@ -320,7 +337,7 @@ updateContactProfile db user@User {userId} c p' ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime updateContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db userId contactId localDisplayName ldn currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} where Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c @@ -491,8 +508,8 @@ updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, |] (displayName, fullName, image, updatedAt, userId, profileId) -updateContactLDN_ :: DB.Connection -> UserId -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () -updateContactLDN_ db userId contactId displayName newName updatedAt = do +updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () +updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do DB.execute db "UPDATE contacts SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" @@ -501,21 +518,21 @@ updateContactLDN_ db userId contactId displayName newName updatedAt = do db "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (newName, updatedAt, userId, contactId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId) + safeDeleteLDN db user displayName -getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact -getContactByName db user localDisplayName = do +getContactByName :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactName -> ExceptT StoreError IO Contact +getContactByName db vr user localDisplayName = do cId <- getContactIdByName db user localDisplayName - getContact db user cId + getContact db vr user cId -getUserContacts :: DB.Connection -> User -> IO [Contact] -getUserContacts db user@User {userId} = do +getUserContacts :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> IO [Contact] +getUserContacts db vr user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId) - contacts <- rights <$> mapM (runExceptT . getContact db user) contactIds + contacts <- rights <$> mapM (runExceptT . getContact db vr user) contactIds pure $ filter (\Contact {activeConn} -> isJust activeConn) contacts -createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> VersionRange -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest -createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ = +createOrUpdateContactRequest :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> ExceptT StoreError IO ContactOrRequest +createOrUpdateContactRequest db vr user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ pqSup = liftIO (maybeM getContact' xContactId_) >>= \case Just contact -> pure $ CORContact contact Nothing -> CORRequest <$> createOrUpdate_ @@ -544,14 +561,17 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers db [sql| INSERT INTO contact_requests - (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id) - VALUES (?,?,?,?,?,?,?,?,?,?) + (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, + created_at, updated_at, xcontact_id, pq_support) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (userContactLinkId, invId, minV, maxV, profileId, ldn, userId, currentTs, currentTs, xContactId_) + ( (userContactLinkId, invId, minV, maxV, profileId, ldn, userId) + :. (currentTs, currentTs, xContactId_, pqSup) + ) insertedRowId db getContact' :: XContactId -> IO (Maybe Contact) getContact' xContactId = - maybeFirstRow (toContact user) $ + maybeFirstRow (toContact vr user) $ DB.query db [sql| @@ -561,8 +581,8 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id @@ -579,7 +599,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr JOIN connections c USING (user_contact_link_id) @@ -600,21 +620,21 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers db [sql| UPDATE contact_requests - SET agent_invitation_id = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? + SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (invId, minV, maxV, currentTs, userId, cReqId) + (invId, pqSup, minV, maxV, currentTs, userId, cReqId) else withLocalDisplayName db userId displayName $ \ldn -> Right <$> do DB.execute db [sql| UPDATE contact_requests - SET agent_invitation_id = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? + SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (invId, minV, maxV, ldn, currentTs, userId, cReqId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (oldLdn, userId) + (invId, pqSup, minV, maxV, ldn, currentTs, userId, cReqId) + safeDeleteLDN db user oldLdn where updateProfile currentTs = DB.execute @@ -648,7 +668,7 @@ getContactRequest db User {userId} contactRequestId = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr JOIN connections c USING (user_contact_link_id) @@ -684,12 +704,13 @@ deleteContactRequest db User {userId} contactRequestId = do SELECT local_display_name FROM contact_requests WHERE user_id = ? AND contact_request_id = ? ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) |] - (userId, userId, contactRequestId) + (userId, userId, contactRequestId, userId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> Bool -> IO Contact -createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode contactUsed = do +createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO Contact +createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId connChatVersion cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed = do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) createdAt <- getCurrentTime customUserProfileId <- forM incognitoProfile $ \case @@ -701,7 +722,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id, contact_used) VALUES (?,?,?,?,?,?,?,?,?,?)" (userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId, contactUsed) contactId <- insertedRowId db - conn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode + conn <- createConnection_ db userId ConnContact (Just contactId) agentConnId connChatVersion cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode pqSup let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn = Just conn, viaGroup = Nothing, contactUsed, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} @@ -710,12 +731,12 @@ getContactIdByName db User {userId} cName = ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $ DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ? AND deleted = 0" (userId, cName) -getContact :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact -getContact db user contactId = getContact_ db user contactId False +getContact :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO Contact +getContact db vr user contactId = getContact_ db vr user contactId False -getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact -getContact_ db user@User {userId} contactId deleted = - ExceptT . firstRow (toContact user) (SEContactNotFound contactId) $ +getContact_ :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact +getContact_ db vr user@User {userId} contactId deleted = + ExceptT . firstRow (toContact vr user) (SEContactNotFound contactId) $ DB.query db [sql| @@ -725,8 +746,8 @@ getContact_ db user@User {userId} contactId deleted = cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id @@ -769,8 +790,8 @@ getPendingContactConnections db User {userId} = do |] [":user_id" := userId, ":conn_type" := ConnContact] -getContactConnections :: DB.Connection -> UserId -> Contact -> IO [Connection] -getContactConnections db userId Contact {contactId} = +getContactConnections :: DB.Connection -> (PQSupport -> VersionRangeChat) -> UserId -> Contact -> IO [Connection] +getContactConnections db vr userId Contact {contactId} = connections =<< liftIO getConnections_ where getConnections_ = @@ -778,25 +799,27 @@ getContactConnections db userId Contact {contactId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? |] (userId, userId, contactId) connections [] = pure [] - connections rows = pure $ map toConnection rows + connections rows = pure $ map (toConnection vr) rows -getConnectionById :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Connection -getConnectionById db User {userId} connId = ExceptT $ do - firstRow toConnection (SEConnectionNotFoundById connId) $ +getConnectionById :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO Connection +getConnectionById db vr User {userId} connId = ExceptT $ do + firstRow (toConnection vr) (SEConnectionNotFoundById connId) $ DB.query db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, - peer_chat_min_version, peer_chat_max_version + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, + conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? |] diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index bc5cec3332..e77681bb9b 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -14,7 +14,6 @@ module Simplex.Chat.Store.Files ( getLiveSndFileTransfers, getLiveRcvFileTransfers, getPendingSndChunks, - createSndDirectFileTransfer, createSndDirectFTConnection, createSndGroupFileTransfer, createSndGroupFileTransferConnection, @@ -39,6 +38,7 @@ module Simplex.Chat.Store.Files getGroupFileIdBySharedMsgId, getDirectFileIdBySharedMsgId, getChatRefByFileId, + lookupChatRefByFileId, updateSndFileStatus, createSndFileChunk, updateSndFileChunkMsg, @@ -46,6 +46,7 @@ module Simplex.Chat.Store.Files deleteSndFileChunks, createRcvFileTransfer, createRcvGroupFileTransfer, + createRcvStandaloneFileTransfer, appendRcvFD, getRcvFileDescrByRcvFileId, getRcvFileDescrBySndFileId, @@ -70,6 +71,7 @@ module Simplex.Chat.Store.Files getFileTransfer, getFileTransferProgress, getFileTransferMeta, + lookupFileTransferRedirectMeta, getSndFileTransfer, getSndFileTransfers, getContactFileInfo, @@ -86,12 +88,14 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Data.Either (rights) +import Data.Functor ((<&>)) import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text (Text) import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) import Data.Type.Equality +import Data.Word (Word32) import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Database.SQLite.Simple.ToField (ToField) @@ -110,8 +114,9 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (SubscriptionMode (..)) -import Simplex.Messaging.Version (VersionRange) +import Simplex.Messaging.Version import System.FilePath (takeFileName) getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] @@ -169,27 +174,10 @@ getPendingSndChunks db fileId connId = |] (fileId, connId) -createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> SubscriptionMode -> IO FileTransferMeta -createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize subMode = do +createSndDirectFTConnection :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO () +createSndDirectFTConnection db vr user@User {userId} fileId (cmdId, acId) subMode = do currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)" - ((userId, contactId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs)) - fileId <- insertedRowId db - forM_ acId_ $ \acId -> do - Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode - let fileStatus = FSNew - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, file_inline, connection_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (fileId, fileStatus, fileInline, connId, currentTs, currentTs) - pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} - -createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO () -createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) subMode = do - currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode + Connection {connId} <- createSndFileConnection_ db vr userId fileId acId subMode setCommandConnId db user cmdId connId DB.execute db @@ -204,12 +192,12 @@ createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)" ((userId, groupId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs)) fileId <- insertedRowId db - pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} + pure FileTransferMeta {fileId, xftpSndFile = Nothing, xftpRedirectFor = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} -createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO () -createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do +createSndGroupFileTransferConnection :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO () +createSndGroupFileTransferConnection db vr user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode + Connection {connId} <- createSndFileConnection_ db vr userId fileId acId subMode setCommandConnId db user cmdId connId DB.execute db @@ -277,16 +265,16 @@ 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 -> CryptoFile -> FileInvitation -> AgentSndFileId -> Integer -> IO FileTransferMeta -createSndFileTransferXFTP db User {userId} contactOrGroup (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId chunkSize = do +createSndFileTransferXFTP :: DB.Connection -> User -> Maybe ContactOrGroup -> CryptoFile -> FileInvitation -> AgentSndFileId -> Maybe FileTransferId -> Integer -> IO FileTransferMeta +createSndFileTransferXFTP db User {userId} contactOrGroup_ (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId xftpRedirectFor chunkSize = do currentTs <- getCurrentTime 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_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)) + "INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, redirect_file_id, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + (maybe (Nothing, Nothing) contactAndGroupIds contactOrGroup_ :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize) :. (xftpRedirectFor, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs)) fileId <- insertedRowId db - pure FileTransferMeta {fileId, xftpSndFile, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False} + pure FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False} createSndFTDescrXFTP :: DB.Connection -> User -> Maybe GroupMember -> Connection -> FileTransferMeta -> FileDescr -> IO () createSndFTDescrXFTP db User {userId} m Connection {connId} FileTransferMeta {fileId} FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do @@ -421,11 +409,14 @@ getDirectFileIdBySharedMsgId db User {userId} Contact {contactId} sharedMsgId = (userId, contactId, sharedMsgId) getChatRefByFileId :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO ChatRef -getChatRefByFileId db User {userId} fileId = - liftIO getChatRef >>= \case - [(Just contactId, Nothing)] -> pure $ ChatRef CTDirect contactId - [(Nothing, Just groupId)] -> pure $ ChatRef CTGroup groupId - _ -> throwError $ SEInternalError "could not retrieve chat ref by file id" +getChatRefByFileId db user fileId = liftIO (lookupChatRefByFileId db user fileId) >>= maybe (throwError $ SEInternalError "could not retrieve chat ref by file id") pure + +lookupChatRefByFileId :: DB.Connection -> User -> Int64 -> IO (Maybe ChatRef) +lookupChatRefByFileId db User {userId} fileId = + getChatRef <&> \case + [(Just contactId, Nothing)] -> Just $ ChatRef CTDirect contactId + [(Nothing, Just groupId)] -> Just $ ChatRef CTGroup groupId + _ -> Nothing where getChatRef = DB.query @@ -438,10 +429,11 @@ getChatRefByFileId db User {userId} fileId = |] (userId, fileId) -createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection -createSndFileConnection_ db userId fileId agentConnId subMode = do +-- TODO v6.0 remove +createSndFileConnection_ :: DB.Connection -> (PQSupport -> VersionRangeChat) -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection +createSndFileConnection_ db vr userId fileId agentConnId subMode = do currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode + createConnection_ db userId ConnSndFile (Just fileId) agentConnId (minVersion $ vr PQSupportOff) chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do @@ -536,6 +528,23 @@ createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localD (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, groupMemberId, rfdId, currentTs, currentTs) pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId, cryptoArgs = Nothing} +createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64 +createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do + currentTs <- liftIO getCurrentTime + fileId <- liftIO $ do + DB.execute + db + "INSERT INTO files (user_id, file_name, file_path, file_size, chunk_size, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, takeFileName filePath, filePath, fileSize, chunkSize, CIFSRcvInvitation, FPXFTP, currentTs, currentTs) + insertedRowId db + liftIO . forM_ cfArgs_ $ \cfArgs -> setFileCryptoArgs_ db fileId cfArgs currentTs + liftIO $ + DB.execute + db + "INSERT INTO rcv_files (file_id, file_status, created_at, updated_at) VALUES (?,?,?,?)" + (fileId, FSNew, currentTs, currentTs) + pure fileId + createRcvFD_ :: DB.Connection -> UserId -> UTCTime -> FileDescr -> ExceptT StoreError IO RcvFileDescr createRcvFD_ db userId currentTs FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do when (fileDescrPartNo /= 0) $ throwError SERcvFileInvalidDescrPart @@ -662,9 +671,9 @@ getRcvFileTransfer_ db userId fileId = do (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_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted) :. (connId_, agentConnId_)) = - case contactName_ <|> memberName_ of + case contactName_ <|> memberName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId - Just name -> do + Just name -> case fileStatus' of FSNew -> pure $ ft name RFSNew FSAccepted -> ft name . RFSAccepted <$> rfi @@ -672,6 +681,9 @@ getRcvFileTransfer_ db userId fileId = do FSComplete -> ft name . RFSComplete <$> rfi FSCancelled -> ft name . RFSCancelled <$> rfi_ where + standaloneName_ = case (connId_, agentRcvFileId, filePath_) of + (Nothing, Just _, Just _) -> Just "" -- filePath marks files that are accepted from contact or, in this case, set by createRcvDirectFileTransfer + _ -> Nothing ft senderDisplayName fileStatus = let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} cryptoArgs = CFArgs <$> fileKey <*> fileNonce @@ -683,7 +695,7 @@ getRcvFileTransfer_ db userId fileId = do _ -> pure Nothing cancelled = fromMaybe False cancelled_ -acceptRcvFileTransfer :: DB.Connection -> VersionRange -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem +acceptRcvFileTransfer :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus filePath subMode = ExceptT $ do currentTs <- getCurrentTime acceptRcvFT_ db user fileId filePath Nothing currentTs @@ -695,16 +707,16 @@ acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus f setCommandConnId db user cmdId connId runExceptT $ getChatItemByFileId db vr user fileId -getContactByFileId :: DB.Connection -> User -> FileTransferId -> ExceptT StoreError IO Contact -getContactByFileId db user@User {userId} fileId = do +getContactByFileId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> FileTransferId -> ExceptT StoreError IO Contact +getContactByFileId db vr user@User {userId} fileId = do cId <- getContactIdByFileId - getContact db user cId + getContact db vr user cId where getContactIdByFileId = ExceptT . firstRow fromOnly (SEContactNotFoundByFileId fileId) $ DB.query db "SELECT contact_id FROM files WHERE user_id = ? AND file_id = ?" (userId, fileId) -acceptRcvInlineFT :: DB.Connection -> VersionRange -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem +acceptRcvInlineFT :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem acceptRcvInlineFT db vr user fileId filePath = do liftIO $ acceptRcvFT_ db user fileId filePath (Just IFMOffer) =<< getCurrentTime getChatItemByFileId db vr user fileId @@ -713,7 +725,7 @@ startRcvInlineFT :: DB.Connection -> User -> RcvFileTransfer -> FilePath -> Mayb startRcvInlineFT db user RcvFileTransfer {fileId} filePath rcvFileInline = acceptRcvFT_ db user fileId filePath rcvFileInline =<< getCurrentTime -xftpAcceptRcvFT :: DB.Connection -> VersionRange -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem +xftpAcceptRcvFT :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem xftpAcceptRcvFT db vr user fileId filePath = do liftIO $ acceptRcvFT_ db user fileId filePath Nothing =<< getCurrentTime getChatItemByFileId db vr user fileId @@ -906,17 +918,22 @@ getFileTransferMeta_ db userId fileId = DB.query db [sql| - 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 + 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, redirect_file_id FROM files WHERE user_id = ? AND file_id = ? |] (userId, fileId) where - 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_) = + fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool, Maybe FileTransferId) -> FileTransferMeta + fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_, xftpRedirectFor) = 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_} + in FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_} + +lookupFileTransferRedirectMeta :: DB.Connection -> User -> Int64 -> IO [FileTransferMeta] +lookupFileTransferRedirectMeta db User {userId} fileId = do + redirects <- DB.query db "SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ?" (userId, fileId) + rights <$> mapM (runExceptT . getFileTransferMeta_ db userId . fromOnly) redirects createLocalFile :: ToField (CIFileStatus d) => CIFileStatus d -> DB.Connection -> User -> NoteFolder -> ChatItemId -> UTCTime -> CryptoFile -> Integer -> Integer -> IO Int64 createLocalFile fileStatus db User {userId} NoteFolder {noteFolderId} chatItemId itemTs CryptoFile {filePath, cryptoArgs} fileSize fileChunkSize = do @@ -983,7 +1000,7 @@ getLocalCryptoFile db userId fileId sent = pure $ CryptoFile filePath fileCryptoArgs _ -> throwError $ SEFileNotFound fileId -updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> VersionRange -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem +updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem updateDirectCIFileStatus db vr user fileId fileStatus = do aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db vr user fileId case (cType, testEquality d $ msgDirection @d) of diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 189f95fdf0..254b8dab59 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -4,6 +4,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -132,7 +133,7 @@ import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages -import Simplex.Chat.Protocol (groupForwardVRange) +import Simplex.Chat.Protocol (groupForwardVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -141,6 +142,7 @@ import Simplex.Messaging.Agent.Protocol (ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.Ratchet (PQSupport, pattern PQEncOff, pattern PQSupportOff) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version @@ -148,13 +150,13 @@ import UnliftIO.STM type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. GroupMemberRow -type GroupMemberRow = ((Int64, Int64, MemberId, Version, Version, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) +type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) -type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe Version, Maybe Version, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) +type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) -toGroupInfo :: VersionRange -> Int64 -> GroupInfoRow -> GroupInfo +toGroupInfo :: (PQSupport -> VersionRangeChat) -> Int64 -> GroupInfoRow -> GroupInfo toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. userMemberRow) = - let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = JVersionRange vr} + let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr PQSupportOff} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} @@ -167,7 +169,7 @@ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing - memberChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in GroupMember {..} toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember @@ -184,17 +186,18 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff -getGroupLinkConnection :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO Connection -getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = - ExceptT . firstRow toConnection (SEGroupLinkNotFound groupInfo) $ +getGroupLinkConnection :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> ExceptT StoreError IO Connection +getGroupLinkConnection db vr User {userId} groupInfo@GroupInfo {groupId} = + ExceptT . firstRow (toConnection vr) (SEGroupLinkNotFound groupInfo) $ DB.query db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.group_id = ? @@ -225,8 +228,9 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do JOIN user_contact_links uc USING (user_contact_link_id) WHERE uc.user_id = ? AND uc.group_id = ? ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) |] - (userId, userId, groupId) + (userId, userId, groupId, userId) DB.execute db [sql| @@ -257,7 +261,7 @@ setGroupLinkMemberRole :: DB.Connection -> User -> Int64 -> GroupMemberRole -> I setGroupLinkMemberRole db User {userId} userContactLinkId memberRole = DB.execute db "UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ?" (memberRole, userId, userContactLinkId) -getGroupAndMember :: DB.Connection -> User -> Int64 -> VersionRange -> ExceptT StoreError IO (GroupInfo, GroupMember) +getGroupAndMember :: DB.Connection -> User -> Int64 -> (PQSupport -> VersionRangeChat) -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember db User {userId, userContactId} groupMemberId vr = ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ DB.query @@ -277,8 +281,9 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -298,10 +303,10 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = toGroupAndMember (groupInfoRow :. memberRow :. connRow) = let groupInfo = toGroupInfo vr userContactId groupInfoRow member = toGroupMember userContactId memberRow - in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) + in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow}) -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> VersionRange -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo +createNewGroup :: DB.Connection -> (PQSupport -> VersionRangeChat) -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences @@ -343,7 +348,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc } -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> VersionRange -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) +createGroupInvitation :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId, peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case @@ -388,7 +393,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ |] (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) insertedRowId db - let JVersionRange hostVRange = peerChatVRange + let hostVRange = const $ adjustedMemberVRange vr peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs vr let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} @@ -409,13 +414,18 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ groupMemberId ) +adjustedMemberVRange :: (PQSupport -> VersionRangeChat) -> VersionRangeChat -> VersionRangeChat +adjustedMemberVRange getVR vr@(VersionRange minV maxV) = + let maxV' = min maxV (maxVersion $ getVR PQSupportOff) + in fromMaybe vr $ safeVersionRange minV (max minV maxV') + getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId getHostMemberId_ db User {userId} groupId = ExceptT . firstRow fromOnly (SEHostMemberIdNotFound groupId) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ?" (userId, groupId, GCHostMember) -createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRange -> ExceptT StoreError IO GroupMember -createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt memberChatVRange@(VersionRange minV maxV) = do +createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> (PQSupport -> VersionRangeChat) -> ExceptT StoreError IO GroupMember +createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt vr = do incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId (localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of (Just profile@LocalProfile {displayName}, Just profileId) -> @@ -439,9 +449,10 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe memberContactId = Just $ contactId' userOrContact, memberContactProfileId = localProfileId (profile' userOrContact), activeConn = Nothing, - memberChatVRange = JVersionRange memberChatVRange + memberChatVRange } where + memberChatVRange@(VersionRange minV maxV) = vr PQSupportOff insertMember_ :: IO ContactName insertMember_ = do let localDisplayName = localDisplayName' userOrContact @@ -477,7 +488,7 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe ) pure $ Right incognitoLdn -createGroupInvitedViaLink :: DB.Connection -> VersionRange -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupInvitedViaLink :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) createGroupInvitedViaLink db vr @@ -491,7 +502,7 @@ createGroupInvitedViaLink -- using IBUnknown since host is created without contact void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs vr liftIO $ setViaGroupLinkHash db groupId connId - (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db user hostMemberId + (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId where insertGroup_ currentTs = ExceptT $ do let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile @@ -548,10 +559,10 @@ setGroupInvitationChatItemId db User {userId} groupId chatItemId = do -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getGroup :: DB.Connection -> VersionRange -> User -> GroupId -> ExceptT StoreError IO Group +getGroup :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupId -> ExceptT StoreError IO Group getGroup db vr user groupId = do gInfo <- getGroupInfo db vr user groupId - members <- liftIO $ getGroupMembers db user gInfo + members <- liftIO $ getGroupMembers db vr user gInfo pure $ Group gInfo members deleteGroupConnectionsAndFiles :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () @@ -586,7 +597,7 @@ deleteGroup :: DB.Connection -> User -> GroupInfo -> IO () deleteGroup db user@User {userId} g@GroupInfo {groupId, localDisplayName} = do deleteGroupProfile_ db userId groupId DB.execute db "DELETE FROM groups WHERE user_id = ? AND group_id = ?" (userId, groupId) - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + safeDeleteLDN db user localDisplayName forM_ (incognitoMembershipProfile g) $ deleteUnusedIncognitoProfileById_ db user . localProfileId deleteGroupProfile_ :: DB.Connection -> UserId -> GroupId -> IO () @@ -603,12 +614,12 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) -getUserGroups :: DB.Connection -> VersionRange -> User -> IO [Group] +getUserGroups :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> IO [Group] getUserGroups db vr user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) rights <$> mapM (runExceptT . getGroup db vr user) groupIds -getUserGroupDetails :: DB.Connection -> VersionRange -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] +getUserGroupDetails :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = map (toGroupInfo vr userContactId) <$> DB.query @@ -631,7 +642,7 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = where search = fromMaybe "" search_ -getUserGroupsWithSummary :: DB.Connection -> VersionRange -> User -> Maybe ContactId -> Maybe String -> IO [(GroupInfo, GroupSummary)] +getUserGroupsWithSummary :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Maybe ContactId -> Maybe String -> IO [(GroupInfo, GroupSummary)] getUserGroupsWithSummary db vr user _contactId_ search_ = getUserGroupDetails db vr user _contactId_ search_ >>= mapM (\g@GroupInfo {groupId} -> (g,) <$> getGroupSummary db user groupId) @@ -672,7 +683,7 @@ checkContactHasGroups :: DB.Connection -> User -> Contact -> IO (Maybe GroupId) checkContactHasGroups db User {userId} Contact {contactId} = maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) -getGroupInfoByName :: DB.Connection -> VersionRange -> User -> GroupName -> ExceptT StoreError IO GroupInfo +getGroupInfoByName :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupName -> ExceptT StoreError IO GroupInfo getGroupInfoByName db vr user gName = do gId <- getGroupIdByName db user gName getGroupInfo db vr user gId @@ -684,8 +695,9 @@ groupMemberQuery = m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.connection_id = ( @@ -695,41 +707,41 @@ groupMemberQuery = ) |] -getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMember db user@User {userId} groupId groupMemberId = - ExceptT . firstRow (toContactMember user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMember :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember +getGroupMember db vr user@User {userId} groupId groupMemberId = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (userId, groupId, groupMemberId, userId) -getGroupMemberById :: DB.Connection -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMemberById db user@User {userId} groupMemberId = - ExceptT . firstRow (toContactMember user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMemberById :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember +getGroupMemberById db vr user@User {userId} groupMemberId = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (userId, groupMemberId, userId) -getGroupMemberByMemberId :: DB.Connection -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember -getGroupMemberByMemberId db user@User {userId} GroupInfo {groupId} memberId = - ExceptT . firstRow (toContactMember user) (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberByMemberId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember +getGroupMemberByMemberId db vr user@User {userId} GroupInfo {groupId} memberId = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByMemberId memberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") (userId, groupId, memberId) -getGroupMembers :: DB.Connection -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember user) +getGroupMembers :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> IO [GroupMember] +getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") (userId, groupId, userId, userContactId) -getGroupMembersForExpiration :: DB.Connection -> User -> GroupInfo -> IO [GroupMember] -getGroupMembersForExpiration db user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember user) +getGroupMembersForExpiration :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> IO [GroupMember] +getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr user) <$> DB.query db ( groupMemberQuery @@ -743,9 +755,9 @@ getGroupMembersForExpiration db user@User {userId, userContactId} GroupInfo {gro ) (userId, groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) -toContactMember :: User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember User {userContactId} (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection connRow} +toContactMember :: (PQSupport -> VersionRangeChat) -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember +toContactMember vr User {userContactId} (memberRow :. connRow) = + (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection vr connRow} getGroupCurrentMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do @@ -761,14 +773,14 @@ getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do (groupId, userId) pure $ length $ filter memberCurrent' statuses -getGroupInvitation :: DB.Connection -> VersionRange -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation +getGroupInvitation :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation getGroupInvitation db vr user groupId = getConnRec_ user >>= \case Just connRequest -> do groupInfo@GroupInfo {membership} <- getGroupInfo db vr user groupId when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined hostId <- getHostMemberId_ db user groupId - fromMember <- getGroupMember db user groupId hostId + fromMember <- getGroupMember db vr user groupId hostId pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo} _ -> throwError SEGroupInvitationNotFound where @@ -779,14 +791,14 @@ getGroupInvitation db vr user groupId = createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember createNewContactMember _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ _ = throwError $ SEContactNotReady localDisplayName -createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile, activeConn = Just Connection {peerChatVRange}} memberRole agentConnId connRequest subMode = +createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile, activeConn = Just Connection {connChatVersion, peerChatVRange}} memberRole agentConnId connRequest subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt - void $ createMemberConnection_ db userId groupMemberId agentConnId (fromJVersionRange peerChatVRange) Nothing 0 createdAt subMode + void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode pure member where - JVersionRange (VersionRange minV maxV) = peerChatVRange + VersionRange minV maxV = peerChatVRange invitedByGroupMemberId = groupMemberId' membership createMember_ memberId createdAt = do insertMember_ @@ -826,13 +838,13 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, :. (minV, maxV) ) -createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> ExceptT StoreError IO () -createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange subMode = +createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () +createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) chatV peerChatVRange subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt groupMemberId <- liftIO $ insertedRowId db - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 createdAt subMode setCommandConnId db user cmdId connId where VersionRange minV maxV = peerChatVRange @@ -867,7 +879,7 @@ createAcceptedMember groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) where - JVersionRange (VersionRange minV maxV) = cReqChatVRange + VersionRange minV maxV = cReqChatVRange insertMember_ memberId createdAt = DB.execute db @@ -883,20 +895,21 @@ createAcceptedMember :. (minV, maxV) ) -createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () +createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> VersionChat -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () createAcceptedMemberConnection db user@User {userId} (cmdId, agentConnId) + chatV UserContactRequest {cReqChatVRange, userContactLinkId} groupMemberId subMode = do createdAt <- liftIO getCurrentTime - Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId (fromJVersionRange cReqChatVRange) Nothing (Just userContactLinkId) Nothing 0 createdAt subMode + Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId chatV cReqChatVRange Nothing (Just userContactLinkId) Nothing 0 createdAt subMode PQSupportOff setCommandConnId db user cmdId connId -getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact -getContactViaMember db user@User {userId} GroupMember {groupMemberId} = do +getContactViaMember :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> ExceptT StoreError IO Contact +getContactViaMember db vr user@User {userId} GroupMember {groupMemberId} = do contactId <- ExceptT $ firstRow fromOnly (SEContactNotFoundByMemberId groupMemberId) $ @@ -910,7 +923,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} = do LIMIT 1 |] (userId, groupMemberId) - getContact db user contactId + getContact db vr user contactId setNewContactMemberConnRequest :: DB.Connection -> User -> GroupMember -> ConnReqInvitation -> IO () setNewContactMemberConnRequest db User {userId} GroupMember {groupMemberId} connRequest = do @@ -922,15 +935,15 @@ getMemberInvitation db User {userId} groupMemberId = fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) -createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRange -> SubscriptionMode -> IO () -createMemberConnection db userId GroupMember {groupMemberId} agentConnId peerChatVRange subMode = do +createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionChat -> VersionRangeChat -> SubscriptionMode -> IO () +createMemberConnection db userId GroupMember {groupMemberId} agentConnId chatV peerChatVRange subMode = do currentTs <- getCurrentTime - void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode + void $ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 currentTs subMode -createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> IO () -createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) peerChatVRange subMode = do +createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> IO () +createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) chatV peerChatVRange subMode = do currentTs <- getCurrentTime - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 currentTs subMode setCommandConnId db user cmdId connId updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () @@ -996,7 +1009,7 @@ createNewMember_ createdAt = do let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing - mcvr@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange + memberChatVRange@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange DB.execute db [sql| @@ -1028,7 +1041,7 @@ createNewMember_ memberContactId, memberContactProfileId, activeConn, - memberChatVRange = JVersionRange mcvr + memberChatVRange } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) @@ -1044,14 +1057,14 @@ deleteGroupMember db user@User {userId} m@GroupMember {groupMemberId, groupId, m when (memberIncognito m) $ deleteUnusedIncognitoProfileById_ db user $ localProfileId memberProfile cleanupMemberProfileAndName_ :: DB.Connection -> User -> GroupMember -> IO () -cleanupMemberProfileAndName_ db User {userId} GroupMember {groupMemberId, memberContactId, memberContactProfileId, localDisplayName} = +cleanupMemberProfileAndName_ db user@User {userId} GroupMember {groupMemberId, memberContactId, memberContactProfileId, localDisplayName} = -- check record has no memberContactId (contact_id) - it means contact has been deleted and doesn't use profile & ldn when (isNothing memberContactId) $ do -- check other group member records don't use profile & ldn sameProfileMember :: (Maybe GroupMemberId) <- maybeFirstRow fromOnly $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1" (userId, memberContactProfileId, groupMemberId) when (isNothing sameProfileMember) $ do DB.execute db "DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ?" (userId, memberContactProfileId) - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + safeDeleteLDN db user localDisplayName deleteGroupMemberConnection :: DB.Connection -> User -> GroupMember -> IO () deleteGroupMemberConnection db User {userId} GroupMember {groupMemberId} = @@ -1061,7 +1074,7 @@ updateGroupMemberRole :: DB.Connection -> User -> GroupMember -> GroupMemberRole updateGroupMemberRole db User {userId} GroupMember {groupMemberId} memRole = DB.execute db "UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ?" (memRole, userId, groupMemberId) -createIntroductions :: DB.Connection -> Version -> [GroupMember] -> GroupMember -> IO [GroupMemberIntro] +createIntroductions :: DB.Connection -> VersionChat -> [GroupMember] -> GroupMember -> IO [GroupMemberIntro] createIntroductions db chatV members toMember = do let reMembers = filter (\m -> memberCurrent m && groupMemberId' m /= groupMemberId' toMember) members if null reMembers @@ -1156,10 +1169,10 @@ getIntroduction db reMember toMember = ExceptT $ do in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound -getForwardIntroducedMembers :: DB.Connection -> User -> GroupMember -> Bool -> IO [GroupMember] -getForwardIntroducedMembers db user invitee highlyAvailable = do +getForwardIntroducedMembers :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> Bool -> IO [GroupMember] +getForwardIntroducedMembers db vr user invitee highlyAvailable = do memberIds <- map fromOnly <$> query - filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds where mId = groupMemberId' invitee query @@ -1168,7 +1181,7 @@ getForwardIntroducedMembers db user invitee highlyAvailable = do DB.query db (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) + (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion) q = [sql| SELECT re_group_member_id @@ -1176,10 +1189,10 @@ getForwardIntroducedMembers db user invitee highlyAvailable = do WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) |] -getForwardInvitedMembers :: DB.Connection -> User -> GroupMember -> Bool -> IO [GroupMember] -getForwardInvitedMembers db user forwardMember highlyAvailable = do +getForwardInvitedMembers :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> Bool -> IO [GroupMember] +getForwardInvitedMembers db vr user forwardMember highlyAvailable = do memberIds <- map fromOnly <$> query - filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds where mId = groupMemberId' forwardMember query @@ -1188,7 +1201,7 @@ getForwardInvitedMembers db user forwardMember highlyAvailable = do DB.query db (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) + (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion) q = [sql| SELECT to_group_member_id @@ -1196,12 +1209,13 @@ getForwardInvitedMembers db user forwardMember highlyAvailable = do WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) |] -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} + chatV memInfo@(MemberInfo _ _ memChatVRange memberProfile) memRestrictions_ (groupCmdId, groupAgentConnId) @@ -1214,7 +1228,7 @@ createIntroReMember currentTs <- liftIO getCurrentTime newMember <- case directConnIds of Just (directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode + Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId chatV mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff liftIO $ setCommandConnId db user directCmdId directConnId (localDisplayName, contactId, memProfileId) <- createContact_ db userId memberProfile "" (Just groupId) currentTs False liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, directConnId) @@ -1224,18 +1238,18 @@ createIntroReMember pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do member <- createNewMember_ db user gInfo newMember currentTs - conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId mcvr memberContactId cLevel currentTs subMode + conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode liftIO $ setCommandConnId db user groupCmdId groupConnId pure (member :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionRange -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () -createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do +createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () +createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do let cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn currentTs <- getCurrentTime - Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId mcvr viaContactId cLevel currentTs subMode + Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId chatV mcvr viaContactId cLevel currentTs subMode setCommandConnId db user groupCmdId groupConnId forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode + Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId chatV mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff setCommandConnId db user directCmdId directConnId contactId <- createMemberContact_ directConnId currentTs updateMember_ contactId currentTs @@ -1265,10 +1279,11 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = |] [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] -createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRange -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection -createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId peerChatVRange viaContact Nothing Nothing +createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionChat -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection +createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange viaContact connLevel currentTs subMode = + createConnection_ db userId ConnMember (Just groupMemberId) agentConnId chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff -getViaGroupMember :: DB.Connection -> VersionRange -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) +getViaGroupMember :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = maybeFirstRow toGroupAndMember $ DB.query @@ -1288,8 +1303,9 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -1310,10 +1326,10 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = toGroupAndMember (groupInfoRow :. memberRow :. connRow) = let groupInfo = toGroupInfo vr userContactId groupInfoRow member = toGroupMember userContactId memberRow - in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) + in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow}) -getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact) -getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do +getViaGroupContact :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> IO (Maybe Contact) +getViaGroupContact db vr user@User {userId} GroupMember {groupMemberId} = do contactId_ <- maybeFirstRow fromOnly $ DB.query @@ -1327,10 +1343,10 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do LIMIT 1 |] (userId, groupMemberId) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) contactId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) contactId_ updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo -updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} +updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} | displayName == newName = liftIO $ do currentTs <- getCurrentTime updateGroupProfile_ currentTs @@ -1361,9 +1377,9 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou db "UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (ldn, currentTs, userId, groupId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + safeDeleteLDN db user localDisplayName -getGroupInfo :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO GroupInfo +getGroupInfo :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db vr User {userId, userContactId} groupId = ExceptT . firstRow (toGroupInfo vr userContactId) (SEGroupNotFound groupId) $ DB.query @@ -1386,7 +1402,7 @@ getGroupInfo db vr User {userId, userContactId} groupId = |] (groupId, userId, userContactId) -getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRange -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) +getGroupInfoByUserContactLinkConnReq :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do groupId_ <- maybeFirstRow fromOnly $ @@ -1400,7 +1416,7 @@ getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReq (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_ -getGroupInfoByGroupLinkHash :: DB.Connection -> VersionRange -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) +getGroupInfoByGroupLinkHash :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do groupId_ <- maybeFirstRow fromOnly $ @@ -1427,7 +1443,7 @@ getGroupMemberIdByName db User {userId} groupId groupMemberName = ExceptT . firstRow fromOnly (SEGroupMemberNameNotFound groupId groupMemberName) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ?" (userId, groupId, groupMemberName) -getActiveMembersByName :: DB.Connection -> VersionRange -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)] +getActiveMembersByName :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)] getActiveMembersByName db vr user@User {userId} groupMemberName = do groupMemberIds :: [(GroupId, GroupMemberId)] <- liftIO $ @@ -1442,19 +1458,19 @@ getActiveMembersByName db vr user@User {userId} groupMemberName = do (userId, groupMemberName, GSMemConnected, GSMemComplete, GCUserMember) possibleMembers <- forM groupMemberIds $ \(groupId, groupMemberId) -> do groupInfo <- getGroupInfo db vr user groupId - groupMember <- getGroupMember db user groupId groupMemberId + groupMember <- getGroupMember db vr user groupId groupMemberId pure (groupInfo, groupMember) pure $ sortOn (Down . ts . fst) possibleMembers where ts GroupInfo {chatTs, updatedAt} = fromMaybe updatedAt chatTs -getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact] -getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do +getMatchingContacts :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> IO [Contact] +getMatchingContacts db vr user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly <$> case image of Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, CSActive, displayName, fullName, img) Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, CSActive, displayName, fullName) - rights <$> mapM (runExceptT . getContact db user) contactIds + rights <$> mapM (runExceptT . getContact db vr user) contactIds where -- this query is different from one in getMatchingMemberContacts -- it checks that it's not the same contact @@ -1464,17 +1480,17 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? AND ct.contact_id != ? - AND ct.contact_status = ? AND ct.deleted = 0 + AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 AND p.display_name = ? AND p.full_name = ? |] -getMatchingMembers :: DB.Connection -> User -> Contact -> IO [GroupMember] -getMatchingMembers db user@User {userId} Contact {profile = LocalProfile {displayName, fullName, image}} = do +getMatchingMembers :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> IO [GroupMember] +getMatchingMembers db vr user@User {userId} Contact {profile = LocalProfile {displayName, fullName, image}} = do memberIds <- map fromOnly <$> case image of Just img -> DB.query db (q <> " AND p.image = ?") (userId, GCUserMember, displayName, fullName, img) Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, GCUserMember, displayName, fullName) - filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds where -- only match with members without associated contact q = @@ -1487,14 +1503,14 @@ getMatchingMembers db user@User {userId} Contact {profile = LocalProfile {displa AND p.display_name = ? AND p.full_name = ? |] -getMatchingMemberContacts :: DB.Connection -> User -> GroupMember -> IO [Contact] -getMatchingMemberContacts _ _ GroupMember {memberContactId = Just _} = pure [] -getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, image}} = do +getMatchingMemberContacts :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> IO [Contact] +getMatchingMemberContacts _ _ _ GroupMember {memberContactId = Just _} = pure [] +getMatchingMemberContacts db vr user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly <$> case image of Just img -> DB.query db (q <> " AND p.image = ?") (userId, CSActive, displayName, fullName, img) Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, CSActive, displayName, fullName) - rights <$> mapM (runExceptT . getContact db user) contactIds + rights <$> mapM (runExceptT . getContact db vr user) contactIds where q = [sql| @@ -1502,7 +1518,7 @@ getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = Loc FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? - AND ct.contact_status = ? AND ct.deleted = 0 + AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 AND p.display_name = ? AND p.full_name = ? |] @@ -1526,8 +1542,8 @@ createSentProbeHash db userId probeId to = do "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, group_member_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (probeId, ctId, gmId, userId, currentTs, currentTs) -matchReceivedProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] -matchReceivedProbe db user@User {userId} from (Probe probe) = do +matchReceivedProbe :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] +matchReceivedProbe db vr user@User {userId} from (Probe probe) = do let probeHash = C.sha256Hash probe cgmIds <- DB.query @@ -1548,7 +1564,7 @@ matchReceivedProbe db user@User {userId} from (Probe probe) = do "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (ctId, gmId, probe, probeHash, userId, currentTs, currentTs) let cgmIds' = filterFirstContactId cgmIds - catMaybes <$> mapM (getContactOrMember_ db user) cgmIds' + catMaybes <$> mapM (getContactOrMember_ db vr user) cgmIds' where filterFirstContactId :: [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] -> [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] filterFirstContactId cgmIds = do @@ -1558,8 +1574,8 @@ matchReceivedProbe db user@User {userId} from (Probe probe) = do (x : _) -> [x] ctIds' <> memIds -matchReceivedProbeHash :: DB.Connection -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) -matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do +matchReceivedProbeHash :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) +matchReceivedProbeHash db vr user@User {userId} from (ProbeHash probeHash) = do probeIds <- maybeFirstRow id $ DB.query @@ -1579,11 +1595,11 @@ matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do db "INSERT INTO received_probes (contact_id, group_member_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (ctId, gmId, probeHash, userId, currentTs, currentTs) - pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db user cgmIds + pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db vr user cgmIds -matchSentProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) -matchSentProbe db user@User {userId} _from (Probe probe) = do - cgmIds $>>= getContactOrMember_ db user +matchSentProbe :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) +matchSentProbe db vr user@User {userId} _from (Probe probe) = do + cgmIds $>>= getContactOrMember_ db vr user where (ctId, gmId) = contactOrMemberIds _from cgmIds = @@ -1602,19 +1618,21 @@ matchSentProbe db user@User {userId} _from (Probe probe) = do |] (userId, probe, ctId, gmId) -getContactOrMember_ :: DB.Connection -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) -getContactOrMember_ db user ids = +getContactOrMember_ :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) +getContactOrMember_ db vr user ids = fmap eitherToMaybe . runExceptT $ case ids of - (Just ctId, _, _) -> COMContact <$> getContact db user ctId - (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db user gId gmId + (Just ctId, _, _) -> COMContact <$> getContact db vr user ctId + (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db vr user gId gmId _ -> throwError $ SEInternalError "" -- if requested merge direction is overruled (toFromContacts), keepLDN is kept -mergeContactRecords :: DB.Connection -> User -> Contact -> Contact -> ExceptT StoreError IO Contact -mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN} from = do +mergeContactRecords :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> Contact -> ExceptT StoreError IO Contact +mergeContactRecords db vr user@User {userId} to@Contact {localDisplayName = keepLDN} from = do let (toCt, fromCt) = toFromContacts to from Contact {contactId = toContactId, localDisplayName = toLDN} = toCt Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt + assertNotUser db user toCt + assertNotUser db user fromCt liftIO $ do currentTs <- getCurrentTime -- next query fixes incorrect unused contacts deletion @@ -1667,7 +1685,7 @@ mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN WHERE user_id = ? AND local_display_name = ? |] (keepLDN, currentTs, userId, toLDN) - getContact db user toContactId + getContact db vr user toContactId where toFromContacts :: Contact -> Contact -> (Contact, Contact) toFromContacts c1 c2 @@ -1698,9 +1716,10 @@ associateMemberWithContactRecord when (memProfileId /= profileId) $ deleteUnusedProfile_ db userId memProfileId when (memLDN /= localDisplayName) $ deleteUnusedDisplayName_ db userId memLDN -associateContactWithMemberRecord :: DB.Connection -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact +associateContactWithMemberRecord :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact associateContactWithMemberRecord db + vr user@User {userId} GroupMember {groupId, groupMemberId, localDisplayName = memLDN, memberProfile = LocalProfile {profileId = memProfileId}} Contact {contactId, localDisplayName, profile = LocalProfile {profileId}} = do @@ -1724,7 +1743,7 @@ associateContactWithMemberRecord (memLDN, memProfileId, currentTs, userId, contactId) when (profileId /= memProfileId) $ deleteUnusedProfile_ db userId profileId when (localDisplayName /= memLDN) $ deleteUnusedDisplayName_ db userId localDisplayName - getContact db user contactId + getContact db vr user contactId deleteUnusedDisplayName_ :: DB.Connection -> UserId -> ContactName -> IO () deleteUnusedDisplayName_ db userId localDisplayName = @@ -1872,7 +1891,7 @@ createMemberContact cReq gInfo GroupMember {groupMemberId, localDisplayName, memberProfile, memberContactProfileId} - Connection {connLevel, peerChatVRange = peerChatVRange@(JVersionRange (VersionRange minV maxV))} + Connection {connLevel, connChatVersion, peerChatVRange = peerChatVRange@(VersionRange minV maxV)} subMode = do currentTs <- getCurrentTime let incognitoProfile = incognitoMembershipProfile gInfo @@ -1899,25 +1918,49 @@ createMemberContact [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_conn_initiated, contact_id, custom_user_profile_id, - peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, True, contactId, customUserProfileId) - :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) + :. (connChatVersion, minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db - let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = True, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + let ctConn = + Connection + { connId, + agentConnId = AgentConnId acId, + peerChatVRange, + connChatVersion, + connType = ConnContact, + contactConnInitiated = True, + entityId = Just contactId, + viaContact = Nothing, + viaUserContactLink = Nothing, + viaGroupLink = False, + groupLinkId = Nothing, + customUserProfileId, + connLevel, + connStatus = ConnNew, + localAlias = "", + createdAt = currentTs, + connectionCode = Nothing, + pqSupport = PQSupportOff, + pqEncryption = PQEncOff, + pqSndEnabled = Nothing, + pqRcvEnabled = Nothing, + authErrCounter = 0 + } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} -getMemberContact :: DB.Connection -> VersionRange -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) +getMemberContact :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do - ct <- getContact db user contactId + ct <- getContact db vr user contactId let Contact {contactGroupMemberId, activeConn} = ct case (activeConn, contactGroupMemberId) of (Just Connection {connId}, Just groupMemberId) -> do cReq <- getConnReqInv db connId - m@GroupMember {groupId} <- getGroupMemberById db user groupMemberId + m@GroupMember {groupId} <- getGroupMemberById db vr user groupMemberId g <- getGroupInfo db vr user groupId pure (g, m, ct, cReq) _ -> @@ -1997,7 +2040,7 @@ createMemberContactConn_ user@User {userId} (cmdId, acId) gInfo - _memberConn@Connection {connLevel, peerChatVRange = peerChatVRange@(JVersionRange (VersionRange minV maxV))} + _memberConn@Connection {connLevel, connChatVersion, peerChatVRange = peerChatVRange@(VersionRange minV maxV)} contactId subMode = do currentTs <- liftIO getCurrentTime @@ -2007,18 +2050,42 @@ createMemberContactConn_ [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_level, conn_status, conn_type, contact_id, custom_user_profile_id, - peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, ConnJoined, ConnContact, contactId, customUserProfileId) - :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) + :. (connChatVersion, minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db setCommandConnId db user cmdId connId - pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = False, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnJoined, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure + Connection + { connId, + agentConnId = AgentConnId acId, + connChatVersion, + peerChatVRange, + connType = ConnContact, + contactConnInitiated = False, + entityId = Just contactId, + viaContact = Nothing, + viaUserContactLink = Nothing, + viaGroupLink = False, + groupLinkId = Nothing, + customUserProfileId, + connLevel, + connStatus = ConnJoined, + localAlias = "", + createdAt = currentTs, + connectionCode = Nothing, + pqSupport = PQSupportOff, + pqEncryption = PQEncOff, + pqSndEnabled = Nothing, + pqRcvEnabled = Nothing, + authErrCounter = 0 + } updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember -updateMemberProfile db User {userId} m p' +updateMemberProfile db user@User {userId} m p' | displayName == newName = do liftIO $ updateMemberContactProfileReset_ db userId profileId p' pure m {memberProfile = profile} @@ -2030,7 +2097,7 @@ updateMemberProfile db User {userId} m p' db "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" (ldn, currentTs, userId, groupMemberId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + safeDeleteLDN db user localDisplayName pure $ Right m {localDisplayName = ldn, memberProfile = profile} where GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m @@ -2038,7 +2105,7 @@ updateMemberProfile db User {userId} m p' profile = toLocalProfile profileId p' localAlias updateContactMemberProfile :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) -updateContactMemberProfile db User {userId} m ct@Contact {contactId} p' +updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p' | displayName == newName = do liftIO $ updateMemberContactProfile_ db userId profileId p' pure (m {memberProfile = profile}, ct {profile} :: Contact) @@ -2046,7 +2113,7 @@ updateContactMemberProfile db User {userId} m ct@Contact {contactId} p' ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime updateMemberContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db userId contactId localDisplayName ldn currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) where GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m @@ -2066,7 +2133,7 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" (xGrpLinkMemReceived, currentTs, mId) -createNewUnknownGroupMember :: DB.Connection -> VersionRange -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember +createNewUnknownGroupMember :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName @@ -2086,12 +2153,12 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g :. (minV, maxV) ) insertedRowId db - getGroupMemberById db user groupMemberId + getGroupMemberById db vr user groupMemberId where - VersionRange minV maxV = vr + VersionRange minV maxV = vr PQSupportOff -updateUnknownMemberAnnounced :: DB.Connection -> User -> GroupMember -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember -updateUnknownMemberAnnounced db user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do +updateUnknownMemberAnnounced :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember +updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do _ <- updateMemberProfile db user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ @@ -2111,9 +2178,9 @@ updateUnknownMemberAnnounced db user@User {userId} invitingMember unknownMember@ ( (memberRole, GCPostMember, GSMemAnnounced, groupMemberId' invitingMember) :. (minV, maxV, currentTs, userId, groupMemberId) ) - getGroupMemberById db user groupMemberId + getGroupMemberById db vr user groupMemberId where - VersionRange minV maxV = maybe (fromJVersionRange memberChatVRange) fromChatVRange v + VersionRange minV maxV = maybe memberChatVRange fromChatVRange v updateUserMemberProfileSentAt :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO () updateUserMemberProfileSentAt db User {userId} GroupInfo {groupId} sentTs = diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 891e9887e6..05b1a153b2 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -39,7 +39,7 @@ module Simplex.Chat.Store.Messages getDirectChat, getGroupChat, getLocalChat, - getDirectChatItemsLast, + getDirectChatItemLast, getAllChatItems, getAChatItem, updateDirectChatItem, @@ -92,6 +92,7 @@ module Simplex.Chat.Store.Messages getLocalChatItemIdByText, getLocalChatItemIdByText', getChatItemByFileId, + lookupChatItemByFileId, getChatItemByGroupId, updateDirectChatItemStatus, getTimedItems, @@ -125,6 +126,7 @@ import Data.List (sortBy) import Data.Maybe (fromMaybe, isJust, mapMaybe) import Data.Ord (Down (..), comparing) import Data.Text (Text) +import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..)) @@ -143,9 +145,9 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserI import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Util (eitherToMaybe) -import Simplex.Messaging.Version (VersionRange) import UnliftIO.STM deleteContactCIs :: DB.Connection -> User -> Contact -> IO () @@ -480,7 +482,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow -getChatPreviews :: DB.Connection -> VersionRange -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] +getChatPreviews :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] getChatPreviews db vr user withPCC pagination query = do directChats <- findDirectChatPreviews_ db user pagination query groupChats <- findGroupChatPreviews_ db user pagination query @@ -503,7 +505,7 @@ getChatPreviews db vr user withPCC pagination query = do PTBefore _ count -> take count . sortBy (comparing $ Down . ts) getChatPreview :: AChatPreviewData -> ExceptT StoreError IO AChat getChatPreview (ACPD cType cpd) = case cType of - SCTDirect -> getDirectChatPreview_ db user cpd + SCTDirect -> getDirectChatPreview_ db vr user cpd SCTGroup -> getGroupChatPreview_ db vr user cpd SCTLocal -> getLocalChatPreview_ db user cpd SCTContactRequest -> let (ContactRequestPD _ chat) = cpd in pure chat @@ -617,9 +619,9 @@ findDirectChatPreviews_ db User {userId} pagination clq = ) ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) -getDirectChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat -getDirectChatPreview_ db user (DirectChatPD _ contactId lastItemId_ stats) = do - contact <- getContact db user contactId +getDirectChatPreview_ :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat +getDirectChatPreview_ db vr user (DirectChatPD _ contactId lastItemId_ stats) = do + contact <- getContact db vr user contactId lastItem <- case lastItemId_ of Just lastItemId -> (: []) <$> getDirectChatItem db user contactId lastItemId Nothing -> pure [] @@ -713,7 +715,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = ) ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) -getGroupChatPreview_ :: DB.Connection -> VersionRange -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat +getGroupChatPreview_ :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do groupInfo <- getGroupInfo db vr user groupId lastItem <- case lastItemId_ of @@ -828,7 +830,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal cItem d chatDir ciStatus content file = CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} - badItem = Left $ SEBadChatItem itemId + badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d ciMeta content status = let itemDeleted' = case itemDeleted of @@ -855,7 +857,7 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of ( [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, cr.created_at, cr.updated_at as ts, cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr @@ -918,119 +920,141 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats in ACPD SCTContactConnection $ ContactConnectionPD updatedAt aChat -getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChat db user contactId pagination search_ = do +getDirectChat :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChat db vr user contactId pagination search_ = do let search = fromMaybe "" search_ - ct <- getContact db user contactId - liftIO . getDirectChatReactions_ db ct =<< case pagination of + ct <- getContact db vr user contactId + liftIO $ case pagination of CPLast count -> getDirectChatLast_ db user ct count search CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search -getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChatLast_ db user ct@Contact {contactId} count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - chatItems <- getDirectChatItemsLast db user contactId count search - pure $ Chat (DirectChat ct) (reverse chatItems) stats - -- the last items in reverse order (the last item in the conversation is the first in the returned list) -getDirectChatItemsLast :: DB.Connection -> User -> ContactId -> Int -> String -> ExceptT StoreError IO [CChatItem 'CTDirect] -getDirectChatItemsLast db User {userId} contactId count search = ExceptT $ do - currentTs <- getCurrentTime - mapM (toDirectChatItem currentTs) - <$> DB.query - db - [sql| - SELECT - -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, - -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, - -- DirectQuote - ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent - FROM chat_items i - LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id - WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%' - ORDER BY i.created_at DESC, i.chat_item_id DESC - LIMIT ? - |] - (userId, contactId, search, count) - -getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId count search = do +getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect) +getDirectChatLast_ db user@User {userId} ct@Contact {contactId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - chatItems <- ExceptT getDirectChatItemsAfter_ - pure $ Chat (DirectChat ct) chatItems stats + chatItemIds <- getDirectChatItemIdsLast_ + currentTs <- getCurrentTime + chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds + pure $ Chat (DirectChat ct) (reverse chatItems) stats where - getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) - getDirectChatItemsAfter_ = do - currentTs <- getCurrentTime - mapM (toDirectChatItem currentTs) + getDirectChatItemIdsLast_ :: IO [ChatItemId] + getDirectChatItemIdsLast_ = + map fromOnly <$> DB.query db [sql| - SELECT - -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, - -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, - -- DirectQuote - ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent - FROM chat_items i - LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id - WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%' - AND i.chat_item_id > ? - ORDER BY i.created_at ASC, i.chat_item_id ASC + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + |] + (userId, contactId, search, count) + +safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect) +safeGetDirectItem db user ct currentTs itemId = + runExceptT (getDirectCIWithReactions db user ct itemId) + >>= pure <$> safeToDirectItem currentTs itemId + +safeToDirectItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTDirect) -> CChatItem 'CTDirect +safeToDirectItem currentTs itemId = \case + Right ci -> ci + Left e@(SEBadChatItem _ (Just itemTs)) -> badDirectItem itemTs e + Left e -> badDirectItem currentTs e + where + badDirectItem :: UTCTime -> StoreError -> CChatItem 'CTDirect + badDirectItem ts e = + let errorText = T.pack $ show e + in CChatItem + SMDSnd + ChatItem + { chatDir = CIDirectSnd, + meta = dummyMeta itemId ts errorText, + content = CIInvalidJSON errorText, + formattedText = Nothing, + quotedItem = Nothing, + reactions = [], + file = Nothing + } + +getDirectChatItemLast :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (CChatItem 'CTDirect) +getDirectChatItemLast db user@User {userId} contactId = do + chatItemId <- + ExceptT . firstRow fromOnly (SEChatItemNotFoundByContactId contactId) $ + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? + ORDER BY created_at DESC, chat_item_id DESC + LIMIT 1 + |] + (userId, contactId) + getDirectChatItem db user contactId chatItemId + +getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect) +getDirectChatAfter_ db user@User {userId} ct@Contact {contactId} afterChatItemId count search = do + let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} + chatItemIds <- getDirectChatItemIdsAfter_ + currentTs <- getCurrentTime + chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds + pure $ Chat (DirectChat ct) chatItems stats + where + getDirectChatItemIdsAfter_ :: IO [ChatItemId] + getDirectChatItemIdsAfter_ = + map fromOnly + <$> DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' + AND chat_item_id > ? + ORDER BY created_at ASC, chat_item_id ASC LIMIT ? |] (userId, contactId, search, afterChatItemId, count) -getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do +getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> IO (Chat 'CTDirect) +getDirectChatBefore_ db user@User {userId} ct@Contact {contactId} beforeChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - chatItems <- ExceptT getDirectChatItemsBefore_ + chatItemIds <- getDirectChatItemsIdsBefore_ + currentTs <- getCurrentTime + chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds pure $ Chat (DirectChat ct) (reverse chatItems) stats where - getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) - getDirectChatItemsBefore_ = do - currentTs <- getCurrentTime - mapM (toDirectChatItem currentTs) + getDirectChatItemsIdsBefore_ :: IO [ChatItemId] + getDirectChatItemsIdsBefore_ = + map fromOnly <$> DB.query db [sql| - SELECT - -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, - -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, - -- DirectQuote - ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent - FROM chat_items i - LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id - WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%' - AND i.chat_item_id < ? - ORDER BY i.created_at DESC, i.chat_item_id DESC + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' + AND chat_item_id < ? + ORDER BY created_at DESC, chat_item_id DESC LIMIT ? |] (userId, contactId, search, beforeChatItemId, count) -getGroupChat :: DB.Connection -> VersionRange -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChat :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChat db vr user groupId pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId case pagination of - CPLast count -> getGroupChatLast_ db user g count search + CPLast count -> liftIO $ getGroupChatLast_ db user g count search CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search -getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup) getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - chatItemIds <- liftIO getGroupChatItemIdsLast_ - chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds + chatItemIds <- getGroupChatItemIdsLast_ + currentTs <- getCurrentTime + chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsLast_ :: IO [ChatItemId] @@ -1047,6 +1071,32 @@ getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do |] (userId, groupId, search, count) +safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup) +safeGetGroupItem db user g currentTs itemId = + runExceptT (getGroupCIWithReactions db user g itemId) + >>= pure <$> safeToGroupItem currentTs itemId + +safeToGroupItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTGroup) -> CChatItem 'CTGroup +safeToGroupItem currentTs itemId = \case + Right ci -> ci + Left e@(SEBadChatItem _ (Just itemTs)) -> badGroupItem itemTs e + Left e -> badGroupItem currentTs e + where + badGroupItem :: UTCTime -> StoreError -> CChatItem 'CTGroup + badGroupItem ts e = + let errorText = T.pack $ show e + in CChatItem + SMDSnd + ChatItem + { chatDir = CIGroupSnd, + meta = dummyMeta itemId ts errorText, + content = CIInvalidJSON errorText, + formattedText = Nothing, + quotedItem = Nothing, + reactions = [], + file = Nothing + } + getGroupMemberChatItemLast :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do chatItemId <- @@ -1068,7 +1118,8 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} afterChatItem <- getGroupChatItem db user groupId afterChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) - chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds + currentTs <- liftIO getCurrentTime + chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds pure $ Chat (GroupChat g) chatItems stats where getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] @@ -1091,7 +1142,8 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) - chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds + currentTs <- liftIO getCurrentTime + chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] @@ -1113,16 +1165,17 @@ getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String getLocalChat db user folderId pagination search_ = do let search = fromMaybe "" search_ nf <- getNoteFolder db user folderId - case pagination of + liftIO $ case pagination of CPLast count -> getLocalChatLast_ db user nf count search CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search -getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) +getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal) getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - chatItemIds <- liftIO getLocalChatItemIdsLast_ - chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds + chatItemIds <- getLocalChatItemIdsLast_ + currentTs <- getCurrentTime + chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds pure $ Chat (LocalChat nf) (reverse chatItems) stats where getLocalChatItemIdsLast_ :: IO [ChatItemId] @@ -1139,11 +1192,38 @@ getLocalChatLast_ db user@User {userId} nf@NoteFolder {noteFolderId} count searc |] (userId, noteFolderId, search, count) -getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) +safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal) +safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId = + runExceptT (getLocalChatItem db user noteFolderId itemId) + >>= pure <$> safeToLocalItem currentTs itemId + +safeToLocalItem :: UTCTime -> ChatItemId -> Either StoreError (CChatItem 'CTLocal) -> CChatItem 'CTLocal +safeToLocalItem currentTs itemId = \case + Right ci -> ci + Left e@(SEBadChatItem _ (Just itemTs)) -> badLocalItem itemTs e + Left e -> badLocalItem currentTs e + where + badLocalItem :: UTCTime -> StoreError -> CChatItem 'CTLocal + badLocalItem ts e = + let errorText = T.pack $ show e + in CChatItem + SMDSnd + ChatItem + { chatDir = CILocalSnd, + meta = dummyMeta itemId ts errorText, + content = CIInvalidJSON errorText, + formattedText = Nothing, + quotedItem = Nothing, + reactions = [], + file = Nothing + } + +getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal) getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - chatItemIds <- liftIO getLocalChatItemIdsAfter_ - chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds + chatItemIds <- getLocalChatItemIdsAfter_ + currentTs <- getCurrentTime + chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds pure $ Chat (LocalChat nf) chatItems stats where getLocalChatItemIdsAfter_ :: IO [ChatItemId] @@ -1161,11 +1241,12 @@ getLocalChatAfter_ db user@User {userId} nf@NoteFolder {noteFolderId} afterChatI |] (userId, noteFolderId, search, afterChatItemId, count) -getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) +getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> IO (Chat 'CTLocal) getLocalChatBefore_ db user@User {userId} nf@NoteFolder {noteFolderId} beforeChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - chatItemIds <- liftIO getLocalChatItemIdsBefore_ - chatItems <- mapM (getLocalChatItem db user noteFolderId) chatItemIds + chatItemIds <- getLocalChatItemIdsBefore_ + currentTs <- getCurrentTime + chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds pure $ Chat (LocalChat nf) (reverse chatItems) stats where getLocalChatItemIdsBefore_ :: IO [ChatItemId] @@ -1188,7 +1269,7 @@ toChatItemRef = \case (itemId, Just contactId, Nothing, Nothing) -> Right (ChatRef CTDirect contactId, itemId) (itemId, Nothing, Just groupId, Nothing) -> Right (ChatRef CTGroup groupId, itemId) (itemId, Nothing, Nothing, Just folderId) -> Right (ChatRef CTLocal folderId, itemId) - (itemId, _, _, _) -> Left $ SEBadChatItem itemId + (itemId, _, _, _) -> Left $ SEBadChatItem itemId Nothing updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> Maybe (ChatItemId, ChatItemId) -> IO () updateDirectChatItemsRead db User {userId} contactId itemsRange_ = do @@ -1361,7 +1442,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} - badItem = Left $ SEBadChatItem itemId + badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = let itemDeleted' = case itemDeleted of @@ -1412,7 +1493,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup cItem d chatDir ciStatus content file = CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} - badItem = Left $ SEBadChatItem itemId + badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d ciMeta content status = let itemDeleted' = case itemDeleted of @@ -1425,7 +1506,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -getAllChatItems :: DB.Connection -> VersionRange -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] +getAllChatItems :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] getAllChatItems db vr user@User {userId} pagination search_ = do itemRefs <- rights . map toChatItemRef <$> case pagination of @@ -2069,7 +2150,7 @@ deleteLocalChatItem db User {userId} NoteFolder {noteFolderId} ci = do |] (userId, noteFolderId, itemId) -getChatItemByFileId :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO AChatItem +getChatItemByFileId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO AChatItem getChatItemByFileId db vr user@User {userId} fileId = do (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $ @@ -2085,7 +2166,13 @@ getChatItemByFileId db vr user@User {userId} fileId = do (userId, fileId) getAChatItem db vr user chatRef itemId -getChatItemByGroupId :: DB.Connection -> VersionRange -> User -> GroupId -> ExceptT StoreError IO AChatItem +lookupChatItemByFileId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem) +lookupChatItemByFileId db vr user fileId = do + fmap Just (getChatItemByFileId db vr user fileId) `catchError` \case + SEChatItemNotFoundByFileId {} -> pure Nothing + e -> throwError e + +getChatItemByGroupId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupId -> ExceptT StoreError IO AChatItem getChatItemByGroupId db vr user@User {userId} groupId = do (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByGroupId groupId) $ @@ -2109,12 +2196,12 @@ getChatRefViaItemId db User {userId} itemId = do toChatRef = \case (Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId (Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId - (_, _) -> Left $ SEBadChatItem itemId + (_, _) -> Left $ SEBadChatItem itemId Nothing -getAChatItem :: DB.Connection -> VersionRange -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem +getAChatItem :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem getAChatItem db vr user chatRef itemId = case chatRef of ChatRef CTDirect contactId -> do - ct <- getContact db user contactId + ct <- getContact db vr user contactId (CChatItem msgDir ci) <- getDirectChatItem db user contactId itemId pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci ChatRef CTGroup groupId -> do @@ -2145,11 +2232,6 @@ getChatItemVersions db itemId = do let formattedText = parseMaybeMarkdownList $ msgContentText msgContent in ChatItemVersion {chatItemVersionId, msgContent, formattedText, itemVersionTs, createdAt} -getDirectChatReactions_ :: DB.Connection -> Contact -> Chat 'CTDirect -> IO (Chat 'CTDirect) -getDirectChatReactions_ db ct c@Chat {chatItems} = do - chatItems' <- mapM (directCIWithReactions db ct) chatItems - pure c {chatItems = chatItems'} - directCIWithReactions :: DB.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect) directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of Just sharedMsgId -> do @@ -2356,9 +2438,9 @@ createCIModeration db GroupInfo {groupId} moderatorMember itemMemberId itemShare |] (groupId, groupMemberId' moderatorMember, itemMemberId, itemSharedMId, msgId, moderatedAtTs) -getCIModeration :: DB.Connection -> User -> GroupInfo -> MemberId -> Maybe SharedMsgId -> IO (Maybe CIModeration) -getCIModeration _ _ _ _ Nothing = pure Nothing -getCIModeration db user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = do +getCIModeration :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> MemberId -> Maybe SharedMsgId -> IO (Maybe CIModeration) +getCIModeration _ _ _ _ _ Nothing = pure Nothing +getCIModeration db vr user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = do r_ <- maybeFirstRow id $ DB.query @@ -2372,7 +2454,7 @@ getCIModeration db user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = do (groupId, itemMemberId, sharedMsgId) case r_ of Just (moderationId, moderatorId, createdByMsgId, moderatedAt) -> do - runExceptT (getGroupMember db user groupId moderatorId) >>= \case + runExceptT (getGroupMember db vr user groupId moderatorId) >>= \case Right moderatorMember -> pure (Just CIModeration {moderationId, moderatorMember, createdByMsgId, moderatedAt}) _ -> pure Nothing _ -> pure Nothing diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 2f5e61a5e6..d8bdbd6fd3 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -98,6 +98,10 @@ import Simplex.Chat.Migrations.M20240102_note_folders import Simplex.Chat.Migrations.M20240104_members_profile_update import Simplex.Chat.Migrations.M20240115_block_member_for_all import Simplex.Chat.Migrations.M20240122_indexes +import Simplex.Chat.Migrations.M20240214_redirect_file_id +import Simplex.Chat.Migrations.M20240222_app_settings +import Simplex.Chat.Migrations.M20240226_users_restrict +import Simplex.Chat.Migrations.M20240228_pq import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -195,7 +199,11 @@ schemaMigrations = ("20240102_note_folders", m20240102_note_folders, Just down_m20240102_note_folders), ("20240104_members_profile_update", m20240104_members_profile_update, Just down_m20240104_members_profile_update), ("20240115_block_member_for_all", m20240115_block_member_for_all, Just down_m20240115_block_member_for_all), - ("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes) + ("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes), + ("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id), + ("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings), + ("20240226_users_restrict", m20240226_users_restrict, Just down_m20240226_users_restrict), + ("20240228_pq", m20240228_pq, Just down_m20240228_pq) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index bffdb2a6d3..512c857b23 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -85,6 +85,8 @@ import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C +import qualified Simplex.Messaging.Crypto.Ratchet as CR +import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) @@ -267,7 +269,7 @@ updateUserProfile db user p' "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) updateContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db userId userContactId localDisplayName newName currentTs + updateContactLDN_ db user userContactId localDisplayName newName currentTs pure user {localDisplayName = newName, profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where updateUserMemberProfileUpdatedAt_ currentTs @@ -323,37 +325,39 @@ createUserContactLink db User {userId} agentConnId cReq subMode = "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff -getUserAddressConnections :: DB.Connection -> User -> ExceptT StoreError IO [Connection] -getUserAddressConnections db User {userId} = do +getUserAddressConnections :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ExceptT StoreError IO [Connection] +getUserAddressConnections db vr User {userId} = do cs <- liftIO getUserAddressConnections_ if null cs then throwError SEUserContactLinkNotFound else pure cs where getUserAddressConnections_ :: IO [Connection] getUserAddressConnections_ = - map toConnection + map (toConnection vr) <$> DB.query db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL |] (userId, userId) -getUserContactLinks :: DB.Connection -> User -> IO [(Connection, UserContact)] -getUserContactLinks db User {userId} = +getUserContactLinks :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> IO [(Connection, UserContact)] +getUserContactLinks db vr User {userId} = map toUserContactConnection <$> DB.query db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -362,7 +366,7 @@ getUserContactLinks db User {userId} = (userId, userId) where toUserContactConnection :: (ConnectionRow :. (Int64, ConnReqContact, Maybe GroupId)) -> (Connection, UserContact) - toUserContactConnection (connRow :. (userContactLinkId, connReqContact, groupId)) = (toConnection connRow, UserContact {userContactLinkId, connReqContact, groupId}) + toUserContactConnection (connRow :. (userContactLinkId, connReqContact, groupId)) = (toConnection vr connRow, UserContact {userContactLinkId, connReqContact, groupId}) deleteUserAddress :: DB.Connection -> User -> IO () deleteUserAddress db user@User {userId} = do @@ -388,6 +392,7 @@ deleteUserAddress db user@User {userId} = do JOIN user_contact_links uc USING (user_contact_link_id) WHERE uc.user_id = :user_id AND uc.local_display_name = '' AND uc.group_id IS NULL ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = :user_id) |] [":user_id" := userId] DB.executeNamed @@ -469,8 +474,8 @@ getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = |] (userId, cReqSchema1, cReqSchema2) -getContactWithoutConnViaAddress :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) -getContactWithoutConnViaAddress db user@User {userId} (cReqSchema1, cReqSchema2) = do +getContactWithoutConnViaAddress :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) +getContactWithoutConnViaAddress db vr user@User {userId} (cReqSchema1, cReqSchema2) = do ctId_ <- maybeFirstRow fromOnly $ DB.query @@ -483,7 +488,7 @@ getContactWithoutConnViaAddress db user@User {userId} (cReqSchema1, cReqSchema2) WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL |] (userId, cReqSchema1, cReqSchema2) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) ctId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) ctId_ updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept db user@User {userId} autoAccept = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index e4d47b32cc..6d5c41c1a5 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -4,6 +4,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeOperators #-} @@ -36,6 +37,8 @@ import Simplex.Messaging.Agent.Protocol (ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..)) +import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (allFinally) @@ -92,11 +95,12 @@ data StoreError | SEUniqueID | SELargeMsg | SEInternalError {message :: String} - | SEBadChatItem {itemId :: ChatItemId} + | SEBadChatItem {itemId :: ChatItemId, itemTs :: Maybe ChatItemTs} | SEChatItemNotFound {itemId :: ChatItemId} | SEChatItemNotFoundByText {text :: Text} | SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId} | SEChatItemNotFoundByFileId {fileId :: FileTransferId} + | SEChatItemNotFoundByContactId {contactId :: ContactId} | SEChatItemNotFoundByGroupId {groupId :: GroupId} | SEProfileNotFound {profileId :: Int64} | SEDuplicateGroupLink {groupInfo :: GroupInfo} @@ -110,6 +114,7 @@ data StoreError | SERemoteHostDuplicateCA | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} | SERemoteCtrlDuplicateCA + | SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId} deriving (Show, Exception) $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) @@ -146,17 +151,38 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int, Version, Version) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, PQSupport, PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, Maybe VersionChat, VersionChat, VersionChat) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int, Maybe Version, Maybe Version) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) -toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = - let entityId = entityId_ connType - connectionCode = SecurityCode <$> code_ <*> verifiedAt_ - peerChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - in Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias, entityId, connectionCode, authErrCounter, createdAt} +toConnection :: (PQSupport -> VersionRangeChat) -> ConnectionRow -> Connection +toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, chatV, minVer, maxVer)) = + Connection + { connId, + agentConnId = AgentConnId acId, + connChatVersion = fromMaybe (vr pqSupport `peerConnChatVersion` peerChatVRange) chatV, + peerChatVRange = peerChatVRange, + connLevel, + viaContact, + viaUserContactLink, + viaGroupLink, + groupLinkId, + customUserProfileId, + connStatus, + connType, + contactConnInitiated, + localAlias, + entityId = entityId_ connType, + connectionCode = SecurityCode <$> code_ <*> verifiedAt_, + pqSupport, + pqEncryption, + pqSndEnabled, + pqRcvEnabled, + authErrCounter, + createdAt + } where + peerChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId entityId_ ConnMember = groupMemberId @@ -164,13 +190,13 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnSndFile = sndFileId entityId_ ConnUserContact = userContactLinkId -toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter, Just minVer, Just maxVer)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) -toMaybeConnection _ = Nothing +toMaybeConnection :: (PQSupport -> VersionRangeChat) -> MaybeConnectionRow -> Maybe Connection +toMaybeConnection vr ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, connChatVersion, Just minVer, Just maxVer)) = + Just $ toConnection vr ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, connChatVersion, minVer, maxVer)) +toMaybeConnection _ _ = Nothing -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> IO Connection -createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode = do +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionChat -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection +createConnection_ db userId connType entityId acId connChatVersion peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode pqSup = do viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId -> maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId) let viaGroupLink = isJust viaLinkGroupId @@ -180,15 +206,39 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, ConnNew, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) - :. (minV, maxV, subMode == SMOnlyCreate) + :. (connChatVersion, minV, maxV, subMode == SMOnlyCreate, pqSup, pqSup) ) connId <- insertedRowId db - pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange = JVersionRange peerChatVRange, connType, contactConnInitiated = False, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure + Connection + { connId, + agentConnId = AgentConnId acId, + connChatVersion, + peerChatVRange, + connType, + contactConnInitiated = False, + entityId, + viaContact, + viaUserContactLink, + viaGroupLink, + groupLinkId = Nothing, + customUserProfileId, + connLevel, + connStatus = ConnNew, + localAlias = "", + createdAt = currentTs, + connectionCode = Nothing, + pqSupport = pqSup, + pqEncryption = CR.pqSupportToEnc pqSup, + pqSndEnabled = Nothing, + pqRcvEnabled = Nothing, + authErrCounter = 0 + } where ent ct = if connType == ct then entityId else Nothing @@ -203,18 +253,62 @@ createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, imag (displayName, fullName, image, userId, Just True, createdAt, createdAt) insertedRowId db -setPeerChatVRange :: DB.Connection -> Int64 -> VersionRange -> IO () -setPeerChatVRange db connId (VersionRange minVer maxVer) = +updateConnSupportPQ :: DB.Connection -> Int64 -> PQSupport -> PQEncryption -> IO () +updateConnSupportPQ db connId pqSup pqEnc = DB.execute db [sql| UPDATE connections - SET peer_chat_min_version = ?, peer_chat_max_version = ? + SET pq_support = ?, pq_encryption = ? WHERE connection_id = ? |] - (minVer, maxVer, connId) + (pqSup, pqEnc, connId) -setMemberChatVRange :: DB.Connection -> GroupMemberId -> VersionRange -> IO () +updateConnPQSndEnabled :: DB.Connection -> Int64 -> PQEncryption -> IO () +updateConnPQSndEnabled db connId pqSndEnabled = + DB.execute + db + [sql| + UPDATE connections + SET pq_snd_enabled = ? + WHERE connection_id = ? + |] + (pqSndEnabled, connId) + +updateConnPQRcvEnabled :: DB.Connection -> Int64 -> PQEncryption -> IO () +updateConnPQRcvEnabled db connId pqRcvEnabled = + DB.execute + db + [sql| + UPDATE connections + SET pq_rcv_enabled = ? + WHERE connection_id = ? + |] + (pqRcvEnabled, connId) + +updateConnPQEnabledCON :: DB.Connection -> Int64 -> PQEncryption -> IO () +updateConnPQEnabledCON db connId pqEnabled = + DB.execute + db + [sql| + UPDATE connections + SET pq_snd_enabled = ?, pq_rcv_enabled = ? + WHERE connection_id = ? + |] + (pqEnabled, pqEnabled, connId) + +setPeerChatVRange :: DB.Connection -> Int64 -> VersionChat -> VersionRangeChat -> IO () +setPeerChatVRange db connId chatV (VersionRange minVer maxVer) = + DB.execute + db + [sql| + UPDATE connections + SET conn_chat_version = ?, peer_chat_min_version = ?, peer_chat_max_version = ? + WHERE connection_id = ? + |] + (chatV, minVer, maxVer, connId) + +setMemberChatVRange :: DB.Connection -> GroupMemberId -> VersionRangeChat -> IO () setMemberChatVRange db mId (VersionRange minVer maxVer) = DB.execute db @@ -277,10 +371,10 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) -toContact :: User -> ContactRow :. MaybeConnectionRow -> Contact -toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = +toContact :: (PQSupport -> VersionRangeChat) -> User -> ContactRow :. MaybeConnectionRow -> Contact +toContact vr user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - activeConn = toMaybeConnection connRow + activeConn = toMaybeConnection vr connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} incognito = maybe False connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences incognito @@ -301,13 +395,13 @@ getProfileById db userId profileId = toProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) -> LocalProfile toProfile (displayName, fullName, image, contactLink, localAlias, preferences) = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, Maybe Preferences, UTCTime, UTCTime, Version, Version) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, PQSupport, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, preferences, createdAt, updatedAt, minVer, maxVer)) = do +toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, pqSupport, preferences, createdAt, updatedAt, minVer, maxVer)) = do let profile = Profile {displayName, fullName, image, contactLink, preferences} - cReqChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, cReqChatVRange, localDisplayName, profileId, profile, xContactId, createdAt, updatedAt} + cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, cReqChatVRange, localDisplayName, profileId, profile, xContactId, pqSupport, createdAt, updatedAt} userQuery :: Query userQuery = @@ -401,3 +495,33 @@ createWithRandomBytes' size gVar create = tryCreate 3 encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString encodedRandomBytes gVar n = atomically $ B64.encode <$> C.randomBytes n gVar + +assertNotUser :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () +assertNotUser db User {userId} Contact {contactId, localDisplayName} = do + r :: (Maybe Int64) <- + -- This query checks that the foreign keys in the users table + -- are not referencing the contact about to be deleted. + -- With the current schema it would cause cascade delete of user, + -- with mofified schema (in v5.6.0-beta.0) it would cause foreign key violation error. + liftIO . maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT 1 FROM users + WHERE (user_id = ? AND local_display_name = ?) + OR contact_id = ? + LIMIT 1 + |] + (userId, localDisplayName, contactId) + when (isJust r) $ throwError $ SEProhibitedDeleteUser userId contactId + +safeDeleteLDN :: DB.Connection -> User -> ContactName -> IO () +safeDeleteLDN db User {userId} localDisplayName = do + DB.execute + db + [sql| + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ? + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + |] + (userId, localDisplayName, userId) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index e65e1a916f..f16913439d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -23,7 +23,7 @@ module Simplex.Chat.Types where import Crypto.Number.Serialize (os2ip) -import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=)) +import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ @@ -38,6 +38,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Typeable (Typeable) +import Data.Word (Word16) import Database.SQLite.Simple (ResultError (..), SQLData (..)) import Database.SQLite.Simple.FromField (FromField (..), returnError) import Database.SQLite.Simple.Internal (Field (..)) @@ -46,13 +47,15 @@ import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) -import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) +import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, RcvFileId, SAEntity (..), SndFileId, UserId) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version +import Simplex.Messaging.Version.Internal class IsContact a where contactId' :: a -> ContactId @@ -211,6 +214,9 @@ contactDeleted Contact {contactStatus} = contactStatus == CSDeleted contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode =<< activeConn +contactPQEnabled :: Contact -> PQEncryption +contactPQEnabled Contact {activeConn} = maybe PQEncOff connPQEnabled activeConn + data ContactStatus = CSActive | CSDeleted -- contact deleted by contact @@ -272,13 +278,14 @@ data UserContactRequest = UserContactRequest agentInvitationId :: AgentInvId, userContactLinkId :: Int64, agentContactConnId :: AgentConnId, -- connection id of user contact - cReqChatVRange :: JVersionRange, + cReqChatVRange :: VersionRangeChat, localDisplayName :: ContactName, profileId :: Int64, profile :: Profile, createdAt :: UTCTime, updatedAt :: UTCTime, - xContactId :: Maybe XContactId + xContactId :: Maybe XContactId, + pqSupport :: PQSupport } deriving (Eq, Show) @@ -563,7 +570,8 @@ data GroupInvitation = GroupInvitation invitedMember :: MemberIdRole, connRequest :: ConnReqInvitation, groupProfile :: GroupProfile, - groupLinkId :: Maybe GroupLinkId + groupLinkId :: Maybe GroupLinkId, + groupSize :: Maybe Int } deriving (Eq, Show) @@ -571,7 +579,8 @@ data GroupLinkInvitation = GroupLinkInvitation { fromMember :: MemberIdRole, fromMemberName :: ContactName, invitedMember :: MemberIdRole, - groupProfile :: GroupProfile + groupProfile :: GroupProfile, + groupSize :: Maybe Int } deriving (Eq, Show) @@ -600,7 +609,7 @@ memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = MemberInfo { memberId, memberRole, - v = ChatVersionRange . fromJVersionRange . peerChatVRange <$> activeConn, + v = ChatVersionRange . peerChatVRange <$> activeConn, profile = redactedMemberProfile $ fromLocalProfile memberProfile } @@ -682,7 +691,7 @@ data GroupMember = GroupMember -- member chat protocol version range; if member has active connection, its version range is preferred; -- for membership current supportedChatVRange is set, it's not updated on protocol version increase in database, -- but it's correctly set on read (see toGroupInfo) - memberChatVRange :: JVersionRange + memberChatVRange :: VersionRangeChat } deriving (Eq, Show) @@ -699,11 +708,13 @@ memberConn GroupMember {activeConn} = activeConn memberConnId :: GroupMember -> Maybe ConnId memberConnId GroupMember {activeConn} = aConnId <$> activeConn -memberChatVRange' :: GroupMember -> VersionRange -memberChatVRange' GroupMember {activeConn, memberChatVRange} = - fromJVersionRange $ case activeConn of - Just Connection {peerChatVRange} -> peerChatVRange - Nothing -> memberChatVRange +memberChatVRange' :: GroupMember -> VersionRangeChat +memberChatVRange' GroupMember {activeConn, memberChatVRange} = case activeConn of + Just Connection {peerChatVRange} -> peerChatVRange + Nothing -> memberChatVRange + +supportsVersion :: GroupMember -> VersionChat -> Bool +supportsVersion m v = maxVersion (memberChatVRange' m) >= v groupMemberId' :: GroupMember -> GroupMemberId groupMemberId' GroupMember {groupMemberId} = groupMemberId @@ -1142,7 +1153,7 @@ instance FromField AgentConnId where fromField f = AgentConnId <$> fromField f instance ToField AgentConnId where toField (AgentConnId m) = toField m -newtype AgentSndFileId = AgentSndFileId ConnId +newtype AgentSndFileId = AgentSndFileId SndFileId deriving (Eq, Show) instance StrEncoding AgentSndFileId where @@ -1161,7 +1172,7 @@ instance FromField AgentSndFileId where fromField f = AgentSndFileId <$> fromFie instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField m -newtype AgentRcvFileId = AgentRcvFileId ConnId +newtype AgentRcvFileId = AgentRcvFileId RcvFileId deriving (Eq, Show) instance StrEncoding AgentRcvFileId where @@ -1210,6 +1221,7 @@ data FileTransfer data FileTransferMeta = FileTransferMeta { fileId :: FileTransferId, xftpSndFile :: Maybe XFTPSndFile, + xftpRedirectFor :: Maybe FileTransferId, fileName :: String, filePath :: String, fileSize :: Integer, @@ -1279,7 +1291,8 @@ type ConnReqContact = ConnectionRequestUri 'CMContact data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, - peerChatVRange :: JVersionRange, + connChatVersion :: VersionChat, + peerChatVRange :: VersionRangeChat, connLevel :: Int, viaContact :: Maybe Int64, -- group member contact ID, if not direct connection viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address" @@ -1292,6 +1305,10 @@ data Connection = Connection localAlias :: Text, entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID connectionCode :: Maybe SecurityCode, + pqSupport :: PQSupport, + pqEncryption :: PQEncryption, + pqSndEnabled :: Maybe PQEncryption, + pqRcvEnabled :: Maybe PQEncryption, authErrCounter :: Int, createdAt :: UTCTime } @@ -1326,6 +1343,10 @@ aConnId Connection {agentConnId = AgentConnId cId} = cId connIncognito :: Connection -> Bool connIncognito Connection {customUserProfileId} = isJust customUserProfileId +connPQEnabled :: Connection -> PQEncryption +connPQEnabled Connection {pqSndEnabled = Just (PQEncryption s), pqRcvEnabled = Just (PQEncryption r)} = PQEncryption $ s && r +connPQEnabled _ = PQEncOff + data PendingContactConnection = PendingContactConnection { pccConnId :: Int64, pccAgentConnId :: AgentConnId, @@ -1614,10 +1635,32 @@ data ServerCfg p = ServerCfg } deriving (Show) -newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRange} deriving (Eq, Show) +data ChatVersion -chatInitialVRange :: VersionRange -chatInitialVRange = versionToRange 1 +instance VersionScope ChatVersion + +type VersionChat = Version ChatVersion + +type VersionRangeChat = VersionRange ChatVersion + +pattern VersionChat :: Word16 -> VersionChat +pattern VersionChat v = Version v + +-- this newtype exists to have a concise JSON encoding of version ranges in chat protocol messages in the form of "1-2" or just "1" +newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRangeChat} deriving (Eq, Show) + +-- TODO v6.0 review +peerConnChatVersion :: VersionRangeChat -> VersionRangeChat -> VersionChat +peerConnChatVersion _local@(VersionRange lmin lmax) _peer@(VersionRange rmin rmax) + | lmin <= rmax && rmin <= lmax = min lmax rmax -- compatible + | rmin > lmax = rmin + | otherwise = rmax + +initialChatVersion :: VersionChat +initialChatVersion = VersionChat 1 + +chatInitialVRange :: VersionRangeChat +chatInitialVRange = versionToRange initialChatVersion instance FromJSON ChatVersionRange where parseJSON v = ChatVersionRange <$> strParseJSON "ChatVersionRange" v @@ -1626,18 +1669,6 @@ instance ToJSON ChatVersionRange where toJSON (ChatVersionRange vr) = strToJSON vr toEncoding (ChatVersionRange vr) = strToJEncoding vr -newtype JVersionRange = JVersionRange {fromJVersionRange :: VersionRange} deriving (Eq, Show) - -instance FromJSON JVersionRange where - parseJSON = J.withObject "JVersionRange" $ \o -> do - minv <- o .: "minVersion" - maxv <- o .: "maxVersion" - maybe (fail "bad version range") (pure . JVersionRange) $ safeVersionRange minv maxv - -instance ToJSON JVersionRange where - toJSON (JVersionRange (VersionRange minV maxV)) = J.object ["minVersion" .= minV, "maxVersion" .= maxV] - toEncoding (JVersionRange (VersionRange minV maxV)) = J.pairs $ "minVersion" .= minV <> "maxVersion" .= maxV - $(JQ.deriveJSON defaultJSON ''UserContact) $(JQ.deriveJSON defaultJSON ''Profile) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 05c90696b4..8aa917f044 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -49,13 +49,14 @@ import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (.. import Simplex.Chat.Styled import Simplex.Chat.Types import Simplex.Chat.Types.Preferences -import qualified Simplex.FileTransfer.Protocol as XFTP +import qualified Simplex.FileTransfer.Transport as XFTPTransport import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..)) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) @@ -198,19 +199,27 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRGroupMemberUpdated {} -> [] CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile + CRRcvStandaloneFileCreated u ft -> ttyUser u $ receivingFileStandalone "started" ft CRRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci + CRRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft - CRRcvFileError u ci e -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e] + CRRcvFileError u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e] + CRRcvFileError u Nothing e ft -> ttyUser u $ receivingFileStandalone "error" ft <> [sShow e] CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft + CRSndStandaloneFileCreated u ft -> ttyUser u $ uploadingFileStandalone "started" ft CRSndFileStartXFTP {} -> [] CRSndFileProgressXFTP {} -> [] + CRSndFileRedirectStartXFTP u ft ftRedirect -> ttyUser u $ standaloneUploadRedirect ft ftRedirect + CRSndStandaloneFileComplete u ft uris -> ttyUser u $ standaloneUploadComplete ft uris CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci CRSndFileCancelledXFTP {} -> [] - CRSndFileError u ci -> ttyUser u $ uploadingFile "error" ci + CRSndFileError u Nothing ft -> ttyUser u $ uploadingFileStandalone "error" ft + CRSndFileError u (Just ci) _ -> ttyUser u $ uploadingFile "error" ci CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] + CRStandaloneFileInfo info_ -> maybe ["no file information in URI"] (\j -> [plain . LB.toStrict $ J.encode j]) info_ CRContactConnecting u _ -> ttyUser u [] CRContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView CRContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] @@ -283,7 +292,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] - CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] + CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv] CRNtfMessages {} -> [] CRNtfMessage {} -> [] CRCurrentRemoteHost rhi_ -> @@ -333,6 +342,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlDeviceName} -> ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] CRRemoteCtrlStopped {} -> ["remote controller stopped"] + CRContactPQAllowed u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": enable " <> (if pqOn then "quantum resistant" else "standard") <> " end-to-end encryption"] + CRContactPQEnabled u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": " <> (if pqOn then "quantum resistant" else "standard") <> " end-to-end encryption enabled"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = @@ -378,6 +389,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRChatError u e -> ttyUser' u $ viewChatError logLevel testView e CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError logLevel testView) errs CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] + CRAppSettings as -> ["app settings: " <> plain (LB.unpack $ J.encode as)] CRTimedAction _ _ -> [] where ttyUser :: User -> [StyledString] -> [StyledString] @@ -1125,7 +1137,7 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case Just ProtocolTestFailure {testStep, testError} -> result <> [pName <> " server requires authorization to create queues, check password" | testStep == TSCreateQueue && testError == SMP SMP.AUTH] - <> [pName <> " server requires authorization to upload files, check password" | testStep == TSCreateFile && testError == XFTP XFTP.AUTH] + <> [pName <> " server requires authorization to upload files, check password" | testStep == TSCreateFile && testError == XFTP XFTPTransport.AUTH] <> ["Possibly, certificate fingerprint in " <> pName <> " server address is incorrect" | testStep == TSConnect && brokerErr] where result = [pName <> " server test failed at " <> plain (drop 2 $ show testStep) <> ", error: " <> plain (strEncode testError)] @@ -1165,6 +1177,7 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta incognitoProfile <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (contactSecurityCode ct)] + <> ["quantum resistant end-to-end encryption" | contactPQEnabled ct == CR.PQEncOn] <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] @@ -1188,8 +1201,8 @@ viewConnectionVerified :: Maybe SecurityCode -> StyledString viewConnectionVerified (Just _) = "connection verified" -- TODO show verification time? viewConnectionVerified _ = "connection not verified, use " <> highlight' "/code" <> " command to see security code" -viewPeerChatVRange :: JVersionRange -> StyledString -viewPeerChatVRange (JVersionRange (VersionRange minVer maxVer)) = "peer chat protocol version range: (" <> sShow minVer <> ", " <> sShow maxVer <> ")" +viewPeerChatVRange :: VersionRangeChat -> StyledString +viewPeerChatVRange (VersionRange minVer maxVer) = "peer chat protocol version range: (" <> sShow minVer <> ", " <> sShow maxVer <> ")" viewConnectionStats :: ConnectionStats -> [StyledString] viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} = @@ -1558,11 +1571,26 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = [status <> " sending " <> sndFile ft <> " to " <> ttyContact c] uploadingFile :: StyledString -> AChatItem -> [StyledString] -uploadingFile status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd}) = - [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c] -uploadingFile status (AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd}) = - [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g] -uploadingFile status _ = [status <> " uploading file"] -- shouldn't happen +uploadingFile status = \case + AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd} -> + [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c] + AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd} -> + [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g] + _ -> [status <> " uploading file"] + +uploadingFileStandalone :: StyledString -> FileTransferMeta -> [StyledString] +uploadingFileStandalone status FileTransferMeta {fileId, fileName} = [status <> " standalone uploading " <> fileTransferStr fileId fileName] + +standaloneUploadRedirect :: FileTransferMeta -> FileTransferMeta -> [StyledString] +standaloneUploadRedirect FileTransferMeta {fileId, fileName} FileTransferMeta {fileId = redirectId} = + [fileTransferStr fileId fileName <> " uploaded, preparing redirect file " <> sShow redirectId] + +standaloneUploadComplete :: FileTransferMeta -> [Text] -> [StyledString] +standaloneUploadComplete FileTransferMeta {fileId, fileName} = \case + [] -> [fileTransferStr fileId fileName <> " upload complete."] + uris -> + fileTransferStr fileId fileName <> " upload complete. download with:" + : map plain uris sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName @@ -1608,7 +1636,11 @@ receivingFile_' hu testView status (AChatItem _ _ chat ChatItem {file = Just CIF highlight ("/get remote file " <> show rhId <> " " <> LB.unpack (J.encode RemoteFile {userId, fileId, sent = False, fileSource = f})) ] _ -> [] -receivingFile_' _ _ status _ = [plain status <> " receiving file"] -- shouldn't happen +receivingFile_' _ _ status _ = [plain status <> " receiving file"] + +receivingFileStandalone :: String -> RcvFileTransfer -> [StyledString] +receivingFileStandalone status RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = + [plain status <> " standalone receiving " <> fileTransferStr fileId fileName] viewLocalFile :: StyledString -> CIFile d -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] viewLocalFile to CIFile {fileId, fileSource} ts tz = case fileSource of @@ -1627,7 +1659,7 @@ fileFrom _ _ = "" receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} = - [status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c] + [status <> " receiving " <> rcvFile ft <> if c == "" then "" else " from " <> ttyContact c] rcvFile :: RcvFileTransfer -> StyledString rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransferStr fileId fileName diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index f7982c5fb4..792f9642d3 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -3,7 +3,9 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} +{-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -15,6 +17,7 @@ import Control.Concurrent.STM import Control.Exception (bracket, bracket_) import Control.Monad import Control.Monad.Except +import Control.Monad.Reader import Data.ByteArray (ScrubbedBytes) import Data.Functor (($>)) import Data.List (dropWhileEnd, find) @@ -22,27 +25,32 @@ import Data.Maybe (isNothing) import qualified Data.Text as T import Network.Socket import Simplex.Chat -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..)) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..)) import Simplex.Chat.Core import Simplex.Chat.Options +import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion) import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Terminal import Simplex.Chat.Terminal.Output (newChatTerminal) -import Simplex.Chat.Types (AgentUserId (..), Profile, User (..)) +import Simplex.Chat.Types import Simplex.FileTransfer.Description (kb, mb) import Simplex.FileTransfer.Server (runXFTPServerBlocking) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration) import Simplex.Messaging.Agent.Env.SQLite +import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig) +import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange, pattern PQSupportOff) +import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Server (defaultTransportServerConfig) import Simplex.Messaging.Version +import Simplex.Messaging.Version.Internal import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive) import System.FilePath (()) import qualified System.Terminal as C @@ -129,67 +137,91 @@ testCfg = { agentConfig = testAgentCfg, showReceipts = False, testView = True, - tbqSize = 16, - xftpFileConfig = Nothing + tbqSize = 16 } testAgentCfgVPrev :: AgentConfig testAgentCfgVPrev = testAgentCfg - { smpAgentVRange = prevRange $ smpAgentVRange testAgentCfg, - smpClientVRange = prevRange $ smpClientVRange testAgentCfg, - e2eEncryptVRange = prevRange $ e2eEncryptVRange testAgentCfg, + { smpClientVRange = prevRange $ smpClientVRange testAgentCfg, + smpAgentVRange = \_ -> prevRange $ supportedSMPAgentVRange PQSupportOff, + e2eEncryptVRange = \_ -> prevRange $ supportedE2EEncryptVRange PQSupportOff, smpCfg = (smpCfg testAgentCfg) {serverVRange = prevRange $ serverVRange $ smpCfg testAgentCfg} } +testAgentCfgVNext :: AgentConfig +testAgentCfgVNext = + testAgentCfg + { smpClientVRange = nextRange $ smpClientVRange testAgentCfg, + smpAgentVRange = \_ -> mkVersionRange duplexHandshakeSMPAgentVersion $ max pqdrSMPAgentVersion currentSMPAgentVersion, + e2eEncryptVRange = \_ -> mkVersionRange CR.kdfX3DHE2EEncryptVersion $ max CR.pqRatchetE2EEncryptVersion CR.currentE2EEncryptVersion, + smpCfg = (smpCfg testAgentCfg) {serverVRange = nextRange $ serverVRange $ smpCfg testAgentCfg} + } + testAgentCfgV1 :: AgentConfig testAgentCfgV1 = testAgentCfg { smpClientVRange = v1Range, - smpAgentVRange = v1Range, - e2eEncryptVRange = v1Range, - smpCfg = (smpCfg testAgentCfg) {serverVRange = v1Range} + smpAgentVRange = \_ -> versionToRange duplexHandshakeSMPAgentVersion, + e2eEncryptVRange = \_ -> versionToRange CR.kdfX3DHE2EEncryptVersion, + smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange batchCmdsSMPVersion} } testCfgVPrev :: ChatConfig testCfgVPrev = testCfg - { chatVRange = prevRange $ chatVRange testCfg, + { chatVRange = \_ -> prevRange $ chatVRange testCfg PQSupportOff, agentConfig = testAgentCfgVPrev } +testCfgVNext :: ChatConfig +testCfgVNext = + testCfg + { chatVRange = \_ -> mkVersionRange initialChatVersion $ max pqEncryptionCompressionVersion currentChatVersion, + agentConfig = testAgentCfgVNext + } + testCfgV1 :: ChatConfig testCfgV1 = testCfg - { chatVRange = v1Range, + { chatVRange = const v1Range, agentConfig = testAgentCfgV1 } -prevRange :: VersionRange -> VersionRange -prevRange vr = vr {maxVersion = maxVersion vr - 1} +prevRange :: VersionRange v -> VersionRange v +prevRange vr = vr {maxVersion = max (minVersion vr) (prevVersion $ maxVersion vr)} -v1Range :: VersionRange -v1Range = mkVersionRange 1 1 +nextRange :: VersionRange v -> VersionRange v +nextRange vr = vr {maxVersion = max (minVersion vr) (nextVersion $ maxVersion vr)} + +v1Range :: VersionRange v +v1Range = mkVersionRange (Version 1) (Version 1) + +prevVersion :: Version v -> Version v +prevVersion (Version v) = Version (v - 1) + +nextVersion :: Version v -> Version v +nextVersion (Version v) = Version (v + 1) testCfgCreateGroupDirect :: ChatConfig testCfgCreateGroupDirect = mkCfgCreateGroupDirect testCfg mkCfgCreateGroupDirect :: ChatConfig -> ChatConfig -mkCfgCreateGroupDirect cfg = cfg {chatVRange = groupCreateDirectVRange} +mkCfgCreateGroupDirect cfg = cfg {chatVRange = const groupCreateDirectVRange} -groupCreateDirectVRange :: VersionRange -groupCreateDirectVRange = mkVersionRange 1 1 +groupCreateDirectVRange :: VersionRangeChat +groupCreateDirectVRange = mkVersionRange (VersionChat 1) (VersionChat 1) testCfgGroupLinkViaContact :: ChatConfig testCfgGroupLinkViaContact = mkCfgGroupLinkViaContact testCfg mkCfgGroupLinkViaContact :: ChatConfig -> ChatConfig -mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} +mkCfgGroupLinkViaContact cfg = cfg {chatVRange = const groupLinkViaContactVRange} -groupLinkViaContactVRange :: VersionRange -groupLinkViaContactVRange = mkVersionRange 1 2 +groupLinkViaContactVRange :: VersionRangeChat +groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do @@ -209,6 +241,7 @@ startTestChat_ db cfg opts user = do t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t opts cc <- newChatController db (Just user) cfg opts False + void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") `runReaderT` cc chatAsync <- async . runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO @@ -317,7 +350,8 @@ getTermLine cc = _ -> error "no output for 5 seconds" userName :: TestCC -> IO [Char] -userName (TestCC ChatController {currentUser} _ _ _ _ _) = maybe "no current user" (T.unpack . localDisplayName) <$> readTVarIO currentUser +userName (TestCC ChatController {currentUser} _ _ _ _ _) = + maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO currentUser testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () testChat2 = testChatCfgOpts2 testCfg testOpts @@ -384,7 +418,7 @@ serverCfg = logStatsStartTime = 0, serverStatsLogFile = "tests/smp-server-stats.daily.log", serverStatsBackupFile = Nothing, - smpServerVRange = supportedSMPServerVRange, + smpServerVRange = supportedServerSMPRelayVRange, transportConfig = defaultTransportServerConfig, smpHandshakeTimeout = 1000000, controlPort = Nothing @@ -407,7 +441,7 @@ xftpServerConfig = storeLogFile = Just "tests/tmp/xftp-server-store.log", filesPath = xftpServerFiles, fileSizeQuota = Nothing, - allowedChunkSizes = [kb 128, kb 256, mb 1, mb 4], + allowedChunkSizes = [kb 64, kb 128, kb 256, mb 1, mb 4], allowNewFiles = True, newFileBasicAuth = Nothing, fileExpiration = Just defaultFileExpiration, diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index 8492ab0f0d..7f02fafc2c 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -199,14 +199,14 @@ testPaginationAllChatTypes = ts7 <- iso8601Show <$> getCurrentTime - getChats_ alice "count=10" [("*", "psst"), ("@dan", "hey"), ("#team", ""), (":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice "count=3" [("*", "psst"), ("@dan", "hey"), ("#team", "")] + getChats_ alice "count=10" [("*", "psst"), ("@dan", "hey"), ("#team", e2eeInfoNoPQStr), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice "count=3" [("*", "psst"), ("@dan", "hey"), ("#team", e2eeInfoNoPQStr)] getChats_ alice ("after=" <> ts2 <> " count=2") [(":3", ""), ("<@cath", "")] - getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", ""), (":3", "")] - getChats_ alice ("after=" <> ts3 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", ""), (":3", "")] + getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", e2eeInfoNoPQStr), (":3", "")] + getChats_ alice ("after=" <> ts3 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", e2eeInfoNoPQStr), (":3", "")] getChats_ alice ("before=" <> ts4 <> " count=10") [(":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice ("after=" <> ts1 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", ""), (":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice ("before=" <> ts7 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", ""), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("after=" <> ts1 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", e2eeInfoNoPQStr), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("before=" <> ts7 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", e2eeInfoNoPQStr), (":3", ""), ("<@cath", ""), ("@bob", "hey")] getChats_ alice ("after=" <> ts7 <> " count=10") [] getChats_ alice ("before=" <> ts1 <> " count=10") [] @@ -218,11 +218,11 @@ testPaginationAllChatTypes = alice ##> "/_settings #1 {\"enableNtfs\":\"all\",\"favorite\":true}" alice <## "ok" - getChats_ alice queryFavorite [("#team", ""), ("@bob", "hey")] + getChats_ alice queryFavorite [("#team", e2eeInfoNoPQStr), ("@bob", "hey")] getChats_ alice ("before=" <> ts4 <> " count=1 " <> queryFavorite) [("@bob", "hey")] - getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", "")] + getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", e2eeInfoNoPQStr)] getChats_ alice ("after=" <> ts1 <> " count=1 " <> queryFavorite) [("@bob", "hey")] - getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", "")] + getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", e2eeInfoNoPQStr)] let queryUnread = "{\"type\": \"filters\", \"favorite\": false, \"unread\": true}" diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 8e7f8536ee..4e06f68fb6 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1,5 +1,6 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PostfixOperators #-} {-# LANGUAGE RankNTypes #-} @@ -9,18 +10,23 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) -import Control.Monad (forM_) +import Control.Monad (forM_, when) import Data.Aeson (ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB +import qualified Data.Text as T +import Simplex.Chat.AppSettings (defaultAppSettings) +import qualified Simplex.Chat.AppSettings as AS import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Options (ChatOpts (..)) -import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion, supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) -import Simplex.Chat.Types (authErrDisableCount, sameVerificationCode, verificationCode) +import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, VersionChat, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) +import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) import System.FilePath (()) @@ -84,8 +90,9 @@ chatDirectTests = do it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages it "user profile privacy: hide profiles and notificaitons" testUserPrivacy - describe "chat item expiration" $ do - it "set chat item TTL" testSetChatItemTTL + describe "settings" $ do + it "set chat item expiration TTL" testSetChatItemTTL + it "save/get app settings" testAppSettings describe "connection switch" $ do it "switch contact to a different queue" testSwitchContact it "stop switching contact to a different queue" testAbortSwitchContact @@ -109,18 +116,25 @@ chatDirectTests = do it "should send delivery receipts depending on configuration" testConfigureDeliveryReceipts describe "negotiate connection peer chat protocol version range" $ do describe "peer version range correctly set for new connection via invitation" $ do - testInvVRange supportedChatVRange supportedChatVRange - testInvVRange supportedChatVRange vr11 - testInvVRange vr11 supportedChatVRange + testInvVRange (supportedChatVRange PQSupportOff) (supportedChatVRange PQSupportOff) + testInvVRange (supportedChatVRange PQSupportOff) vr11 + testInvVRange vr11 (supportedChatVRange PQSupportOff) testInvVRange vr11 vr11 describe "peer version range correctly set for new connection via contact request" $ do - testReqVRange supportedChatVRange supportedChatVRange - testReqVRange supportedChatVRange vr11 - testReqVRange vr11 supportedChatVRange + testReqVRange (supportedChatVRange PQSupportOff) (supportedChatVRange PQSupportOff) + testReqVRange (supportedChatVRange PQSupportOff) vr11 + testReqVRange vr11 (supportedChatVRange PQSupportOff) testReqVRange vr11 vr11 it "update peer version range on received messages" testUpdatePeerChatVRange describe "network statuses" $ do it "should get network statuses" testGetNetworkStatuses + describe "PQ tests" $ do + describe "enable PQ before connection, connect via invitation link" $ pqMatrix2 runTestPQConnectViaLink + describe "enable PQ before connection, connect via contact address" $ pqMatrix2 runTestPQConnectViaAddress + describe "connect via invitation link with PQ encryption enabled" $ pqVersionTestMatrix2 runTestPQVersionsViaLink + describe "connect via contact address with PQ encryption enabled" $ pqVersionTestMatrix2 runTestPQVersionsViaAddress + it "should enable PQ after several messages in connection without PQ" testPQEnableContact + it "should enable PQ, reduce envelope size and enable compression" testPQEnableContactCompression where testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 @@ -628,13 +642,13 @@ testDirectLiveMessage = connectUsers alice bob -- non-empty live message is sent instantly alice `send` "/live @bob hello" - bob <# "alice> [LIVE started] use /show [on/off/6] hello" + bob <# "alice> [LIVE started] use /show [on/off/7] hello" alice ##> ("/_update item @2 " <> itemId 1 <> " text hello there") alice <# "@bob [LIVE] hello there" bob <# "alice> [LIVE ended] hello there" -- empty live message is also sent instantly alice `send` "/live @bob" - bob <# "alice> [LIVE started] use /show [on/off/7]" + bob <# "alice> [LIVE started] use /show [on/off/8]" alice ##> ("/_update item @2 " <> itemId 2 <> " text hello 2") alice <# "@bob [LIVE] hello 2" bob <# "alice> [LIVE ended] hello 2" @@ -1067,7 +1081,7 @@ testChatWorking alice bob = do alice <# "bob> hello too" testMaintenanceModeWithFiles :: HasCallStack => FilePath -> IO () -testMaintenanceModeWithFiles tmp = do +testMaintenanceModeWithFiles tmp = withXFTPServer $ do withNewTestChat tmp "bob" bobProfile $ \bob -> do withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/_start" @@ -1075,12 +1089,26 @@ testMaintenanceModeWithFiles tmp = do alice ##> "/_files_folder ./tests/tmp/alice_files" alice <## "ok" connectUsers alice bob - startFileTransferWithDest' bob alice "test.jpg" "136.5 KiB / 139737 bytes" Nothing - bob <## "completed sending file 1 (test.jpg) to alice" + + bob #> "/f @alice ./tests/fixtures/test.jpg" + bob <## "use /fc 1 to cancel sending" + alice <# "bob> sends file test.jpg (136.5 KiB / 139737 bytes)" + alice <## "use /fr 1 [/ | ] to receive it" + bob <## "completed uploading file 1 (test.jpg) for alice" + + alice ##> "/fr 1" + alice + <### [ "saving file 1 from bob to test.jpg", + "started receiving file 1 (test.jpg) from bob" + ] alice <## "completed receiving file 1 (test.jpg) from bob" + src <- B.readFile "./tests/fixtures/test.jpg" - B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src + dest <- B.readFile "./tests/tmp/alice_files/test.jpg" + dest `shouldBe` src + threadDelay 500000 + alice ##> "/_stop" alice <## "chat stopped" alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" @@ -1124,6 +1152,10 @@ testDatabaseEncryption tmp = do testChatWorking alice bob alice ##> "/_stop" alice <## "chat stopped" + alice ##> "/db test key wrongkey" + alice <## "error opening database after encryption: wrong passphrase or invalid database file" + alice ##> "/db test key mykey" + alice <## "ok" alice ##> "/db key wrongkey nextkey" alice <## "error encrypting database: wrong passphrase or invalid database file" alice ##> "/db key mykey nextkey" @@ -2060,15 +2092,16 @@ testUserPrivacy = alice <##? chatHistory alice ##> "/_get items count=10" alice <##? chatHistory - alice ##> "/_get items before=11 count=10" + alice ##> "/_get items before=13 count=10" alice - <##? [ "bob> Disappearing messages: allowed", + <##? [ ConsoleString ("bob> " <> e2eeInfoNoPQStr), + "bob> Disappearing messages: allowed", "bob> Full deletion: off", "bob> Message reactions: enabled", "bob> Voice messages: enabled", "bob> Audio/video calls: enabled" ] - alice ##> "/_get items after=10 count=10" + alice ##> "/_get items after=12 count=10" alice <##? [ "@bob hello", "bob> hey", @@ -2132,7 +2165,8 @@ testUserPrivacy = alice <## "messages are shown" alice <## "profile is visible" chatHistory = - [ "bob> Disappearing messages: allowed", + [ ConsoleString ("bob> " <> e2eeInfoNoPQStr), + "bob> Disappearing messages: allowed", "bob> Full deletion: off", "bob> Message reactions: enabled", "bob> Voice messages: enabled", @@ -2177,6 +2211,24 @@ testSetChatItemTTL = alice #$> ("/ttl none", id, "ok") alice #$> ("/ttl", id, "old messages are not being deleted") +testAppSettings :: HasCallStack => FilePath -> IO () +testAppSettings tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + let settings = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings + settingsApp = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings {AS.webrtcICEServers = Just ["non-default.value.com"]} + -- app-provided defaults + alice ##> ("/_get app settings " <> settingsApp) + alice <## ("app settings: " <> settingsApp) + -- parser defaults fallback + alice ##> "/_get app settings" + alice <## ("app settings: " <> settings) + -- store + alice ##> ("/_save app settings " <> settingsApp) + alice <## "ok" + -- read back + alice ##> "/_get app settings" + alice <## ("app settings: " <> settingsApp) + testSwitchContact :: HasCallStack => FilePath -> IO () testSwitchContact = testChat2 aliceProfile bobProfile $ @@ -2228,7 +2280,7 @@ testSwitchGroupMember = alice <## "#team: you started changing address for bob" bob <## "#team: alice changed address for you" alice <## "#team: you changed address for bob" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "started changing address for bob..."), (1, "you changed address for bob")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "started changing address for bob..."), (1, "you changed address for bob")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "changed address for you")]) alice #> "#team hey" bob <# "#team alice> hey" @@ -2259,7 +2311,7 @@ testAbortSwitchGroupMember tmp = do bob <## "#team: alice started changing address for you" bob <## "#team: alice changed address for you" alice <## "#team: you changed address for bob" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "started changing address for bob..."), (1, "started changing address for bob..."), (1, "you changed address for bob")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "started changing address for bob..."), (1, "started changing address for bob..."), (1, "you changed address for bob")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) alice #> "#team hey" bob <# "#team alice> hey" @@ -2613,10 +2665,10 @@ testConfigureDeliveryReceipts tmp = cc2 <# (name1 <> "> " <> msg) cc1 VersionRange -> VersionRange -> FilePath -> IO () +testConnInvChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> FilePath -> IO () testConnInvChatVRange ct1VRange ct2VRange tmp = - withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfg {chatVRange = const ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = const ct2VRange} "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/i bob" @@ -2625,10 +2677,10 @@ testConnInvChatVRange ct1VRange ct2VRange tmp = bob ##> "/i alice" contactInfoChatVRange bob ct1VRange -testConnReqChatVRange :: HasCallStack => VersionRange -> VersionRange -> FilePath -> IO () +testConnReqChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> FilePath -> IO () testConnReqChatVRange ct1VRange ct2VRange tmp = - withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfg {chatVRange = const ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = const ct2VRange} "bob" bobProfile $ \bob -> do alice ##> "/ad" cLink <- getContactLink alice True bob ##> ("/c " <> cLink) @@ -2655,7 +2707,7 @@ testUpdatePeerChatVRange tmp = contactInfoChatVRange alice vr11 bob ##> "/i alice" - contactInfoChatVRange bob supportedChatVRange + contactInfoChatVRange bob (supportedChatVRange PQSupportOff) withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" @@ -2664,10 +2716,10 @@ testUpdatePeerChatVRange tmp = alice <# "bob> hello 1" alice ##> "/i bob" - contactInfoChatVRange alice supportedChatVRange + contactInfoChatVRange alice (supportedChatVRange PQSupportOff) bob ##> "/i alice" - contactInfoChatVRange bob supportedChatVRange + contactInfoChatVRange bob (supportedChatVRange PQSupportOff) withTestChatCfg tmp cfg11 "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" @@ -2679,9 +2731,9 @@ testUpdatePeerChatVRange tmp = contactInfoChatVRange alice vr11 bob ##> "/i alice" - contactInfoChatVRange bob supportedChatVRange + contactInfoChatVRange bob (supportedChatVRange PQSupportOff) where - cfg11 = testCfg {chatVRange = vr11} :: ChatConfig + cfg11 = testCfg {chatVRange = const vr11} :: ChatConfig testGetNetworkStatuses :: HasCallStack => FilePath -> IO () testGetNetworkStatuses tmp = do @@ -2697,10 +2749,10 @@ testGetNetworkStatuses tmp = do where cfg = testCfg {coreApi = True} -vr11 :: VersionRange -vr11 = mkVersionRange 1 1 +vr11 :: VersionRangeChat +vr11 = mkVersionRange (VersionChat 1) (VersionChat 1) -contactInfoChatVRange :: TestCC -> VersionRange -> IO () +contactInfoChatVRange :: TestCC -> VersionRangeChat -> IO () contactInfoChatVRange cc (VersionRange minVer maxVer) = do cc <## "contact ID: 2" cc <## "receiving messages via: localhost" @@ -2708,3 +2760,253 @@ contactInfoChatVRange cc (VersionRange minVer maxVer) = do cc <## "you've shared main profile with this contact" cc <## "connection not verified, use /code command to see security code" cc <## ("peer chat protocol version range: (" <> show minVer <> ", " <> show maxVer <> ")") + +runTestPQConnectViaLink :: HasCallStack => (TestCC, PQEnabled) -> (TestCC, PQEnabled) -> IO () +runTestPQConnectViaLink (alice, aPQ) (bob, bPQ) = do + when aPQ $ pqOn alice + when bPQ $ pqOn bob + + connectUsers alice bob + + (alice, "hi") `pqSend` bob + (bob, "hey") `pqSend` alice + + alice ##> "/_get chat @2 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, e2eeInfo)] + alice `pqForContact` 2 `shouldReturn` PQEncryption pqEnabled + + bob ##> "/_get chat @2 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, e2eeInfo)] + bob `pqForContact` 2 `shouldReturn` PQEncryption pqEnabled + where + pqEnabled = aPQ && bPQ + pqSend = if pqEnabled then (+#>) else (\#>) + e2eeInfo = if pqEnabled then e2eeInfoPQStr else e2eeInfoNoPQStr + +pqOn :: TestCC -> IO () +pqOn cc = do + cc ##> "/pq on" + cc <## "ok" + +runTestPQConnectViaAddress :: HasCallStack => (TestCC, PQEnabled) -> (TestCC, PQEnabled) -> IO () +runTestPQConnectViaAddress (alice, aPQ) (bob, bPQ) = do + when aPQ $ pqOn alice + when bPQ $ pqOn bob + + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + + (alice, "hi") `pqSend` bob + (bob, "hey") `pqSend` alice + + alice ##> "/_get chat @2 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, e2eeInfo)] + alice `pqForContact` 2 `shouldReturn` PQEncryption pqEnabled + + bob ##> "/_get chat @2 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, e2eeInfo)] + bob `pqForContact` 2 `shouldReturn` PQEncryption pqEnabled + where + pqEnabled = aPQ && bPQ + pqSend = if pqEnabled then (+#>) else (\#>) + e2eeInfo = if pqEnabled then e2eeInfoPQStr else e2eeInfoNoPQStr + +runTestPQVersionsViaLink :: HasCallStack => TestCC -> TestCC -> Bool -> VersionChat -> IO () +runTestPQVersionsViaLink alice bob pqExpected vExpected = do + img <- genProfileImg + let profileImage = "data:image/png;base64," <> B.unpack img + alice `send` ("/set profile image " <> profileImage) + _trimmedCmd1 <- getTermLine alice + alice <## "profile image updated" + bob `send` ("/set profile image " <> profileImage) + _trimmedCmd2 <- getTermLine bob + bob <## "profile image updated" + + pqOn alice + pqOn bob + + connectUsers alice bob + + (alice, "hi", vExpected) `pqSend` (bob, vExpected) + (bob, "hey", vExpected) `pqSend` (alice, vExpected) + + alice ##> "/_get chat @2 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, e2eeInfo)] + alice `pqForContact` 2 `shouldReturn` PQEncryption pqExpected + + bob ##> "/_get chat @2 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, e2eeInfo)] + bob `pqForContact` 2 `shouldReturn` PQEncryption pqExpected + where + pqSend = if pqExpected then (+:#>) else (\:#>) + e2eeInfo = if pqExpected then e2eeInfoPQStr else e2eeInfoNoPQStr + +runTestPQVersionsViaAddress :: HasCallStack => TestCC -> TestCC -> Bool -> VersionChat -> IO () +runTestPQVersionsViaAddress alice bob pqExpected vExpected = do + img <- genProfileImg + let profileImage = "data:image/png;base64," <> B.unpack img + alice `send` ("/set profile image " <> profileImage) + _trimmedCmd1 <- getTermLine alice + alice <## "profile image updated" + bob `send` ("/set profile image " <> profileImage) + _trimmedCmd2 <- getTermLine bob + bob <## "profile image updated" + + pqOn alice + pqOn bob + + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + + (alice, "hi", vExpected) `pqSend` (bob, vExpected) + (bob, "hey", vExpected) `pqSend` (alice, vExpected) + + alice ##> "/_get chat @2 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, e2eeInfo)] + alice `pqForContact` 2 `shouldReturn` PQEncryption pqExpected + + bob ##> "/_get chat @2 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, e2eeInfo)] + bob `pqForContact` 2 `shouldReturn` PQEncryption pqExpected + where + pqSend = if pqExpected then (+:#>) else (\:#>) + e2eeInfo = if pqExpected then e2eeInfoPQStr else e2eeInfoNoPQStr + +testPQEnableContact :: HasCallStack => FilePath -> IO () +testPQEnableContact = + testChat2 aliceProfile bobProfile $ \alice bob -> do + connectUsers alice bob + (alice, "hi") \#> bob + (bob, "hey") \#> alice + + alice ##> "/_get chat @2 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, e2eeInfoNoPQStr)] + PQEncOff <- alice `pqForContact` 2 + + bob ##> "/_get chat @2 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, e2eeInfoNoPQStr)] + PQEncOff <- bob `pqForContact` 2 + + sendMany PQEncOff alice bob + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + + -- enabling experimental flags doesn't enable PQ in previously created connection + pqOn alice + sendMany PQEncOff alice bob + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + + pqOn bob + sendMany PQEncOff alice bob + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + + -- if only one contact allows PQ, it's not enabled + alice ##> "/pq @bob on" + alice <## "bob: enable quantum resistant end-to-end encryption" + sendMany PQEncOff alice bob + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + + -- both contacts have to allow PQ to enable it + bob ##> "/pq @alice on" + bob <## "alice: enable quantum resistant end-to-end encryption" + + (alice, "1") \#> bob + (bob, "2") \#> alice + (alice, "3") \#> bob + (bob, "4") \#> alice + (alice, "5") +#> bob + + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + + (bob, "6") ++#> alice + -- equivalent to: + -- bob `send` "@alice 6" + -- bob <## "alice: quantum resistant end-to-end encryption enabled" + -- bob <# "@alice 6" + -- alice <## "bob: quantum resistant end-to-end encryption enabled" + -- alice <# "bob> 6" + + PQEncOn <- alice `pqForContact` 2 + alice #$> ("/_get chat @2 count=2", chat, [(0, e2eeInfoPQStr), (0, "6")]) + + PQEncOn <- bob `pqForContact` 2 + bob #$> ("/_get chat @2 count=2", chat, [(1, e2eeInfoPQStr), (1, "6")]) + + (alice, "6") +#> bob + (bob, "7") +#> alice + + sendMany PQEncOn alice bob + + PQEncOn <- alice `pqForContact` 2 + PQEncOn <- bob `pqForContact` 2 + pure () + +sendMany :: PQEncryption -> TestCC -> TestCC -> IO () +sendMany pqEnc alice bob = + forM_ [(1 :: Int) .. 10] $ \i -> do + sndRcv pqEnc False (alice, show i) bob + sndRcv pqEnc False (bob, show i) alice + +testPQEnableContactCompression :: HasCallStack => FilePath -> IO () +testPQEnableContactCompression = + testChat2 aliceProfile bobProfile $ \alice bob -> do + connectUsers alice bob + (alice, "hi") \#> bob + (bob, "hey") \#> alice + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + (alice, "lrg 1", v) \:#> (bob, v) + (bob, "lrg 2", v) \:#> (alice, v) + PQSupportOff <- alice `pqSupportForCt` 2 + alice ##> "/pq @bob on" + alice <## "bob: enable quantum resistant end-to-end encryption" + PQSupportOn <- alice `pqSupportForCt` 2 + (alice, "lrg 3", v) \:#> (bob, v) + (bob, "lrg 4", v) \:#> (alice, v) + PQSupportOff <- bob `pqSupportForCt` 2 + bob ##> "/pq @alice on" + bob <## "alice: enable quantum resistant end-to-end encryption" + PQSupportOn <- bob `pqSupportForCt` 2 + (alice, "lrg 1", v) \:#> (bob, v') + (bob, "lrg 2", v') \:#> (alice, v') + (alice, "lrg 3", v') \:#> (bob, v') + (bob, "lrg 4", v') \:#> (alice, v') + (alice, "lrg 5", v') +:#> (bob, v') + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + (bob, "lrg 6", v') ++:#> (alice, v') + (alice, "lrg 7", v') +:#> (bob, v') + (bob, "lrg 8", v') +:#> (alice, v') + where + v = currentChatVersion + v' = pqEncryptionCompressionVersion diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index a6b0f56ba3..1e72df9156 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -9,58 +9,33 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) +import Control.Logger.Simple import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB +import Network.HTTP.Types.URI (urlEncode) import Simplex.Chat (roundedFDCount) -import Simplex.Chat.Controller (ChatConfig (..), InlineFilesConfig (..), XFTPFileConfig (..), defaultInlineFilesConfig) +import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Mobile.File import Simplex.Chat.Options (ChatOpts (..)) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..)) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Util (unlessM) import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize) import Test.Hspec hiding (it) chatFileTests :: SpecWith FilePath chatFileTests = do - describe "sending and receiving files" $ do - describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer - describe "send file, receive and locally encrypt file" $ fileTestMatrix2 runTestFileTransferEncrypted - it "send and receive file inline (without accepting)" testInlineFileTransfer - it "send inline file, receive (without accepting) and locally encrypt" testInlineFileTransferEncrypted - xit'' "accept inline file transfer, sender cancels during transfer" testAcceptInlineFileSndCancelDuringTransfer - it "send and receive small file inline (default config)" testSmallInlineFileTransfer - it "small file sent without acceptance is ignored in terminal by default" testSmallInlineFileIgnored - it "receive file inline with inline=on option" testReceiveInline - describe "send and receive a small file" $ fileTestMatrix2 runTestSmallFileTransfer - describe "sender cancelled file transfer before transfer" $ fileTestMatrix2 runTestFileSndCancelBeforeTransfer - it "sender cancelled file transfer during transfer" testFileSndCancelDuringTransfer - it "recipient cancelled file transfer" testFileRcvCancel - describe "send and receive file to group" $ fileTestMatrix3 runTestGroupFileTransfer - it "send and receive file inline to group (without accepting)" testInlineGroupFileTransfer - it "send and receive small file inline to group (default config)" testSmallInlineGroupFileTransfer - it "small file sent without acceptance is ignored in terminal by default" testSmallInlineGroupFileIgnored - describe "sender cancelled group file transfer before transfer" $ fileTestMatrix3 runTestGroupFileSndCancelBeforeTransfer describe "messages with files" $ do - describe "send and receive message with file" $ fileTestMatrix2 runTestMessageWithFile + it "send and receive message with file" runTestMessageWithFile it "send and receive image" testSendImage - it "sender marking chat item deleted during file transfer cancels file" testSenderMarkItemDeletedTransfer + it "sender marking chat item deleted cancels file" testSenderMarkItemDeleted it "files folder: send and receive image" testFilesFoldersSendImage - it "files folder: sender deleted file during transfer" testFilesFoldersImageSndDelete - it "files folder: recipient deleted file during transfer" testFilesFoldersImageRcvDelete + it "files folder: sender deleted file" testFilesFoldersImageSndDelete -- TODO add test deleting during upload + it "files folder: recipient deleted file" testFilesFoldersImageRcvDelete -- TODO add test deleting during download it "send and receive image with text and quote" testSendImageWithTextAndQuote it "send and receive image to group" testGroupSendImage it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote - describe "async sending and receiving files" $ do - -- fails on CI - xit'' "send and receive file, sender restarts" testAsyncFileTransferSenderRestarts - xit'' "send and receive file, receiver restarts" testAsyncFileTransferReceiverRestarts - xdescribe "send and receive file, fully asynchronous" $ do - it "v2" testAsyncFileTransfer - it "v1" testAsyncFileTransferV1 - xit "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer describe "file transfer over XFTP" $ do it "round file description count" $ const testXFTPRoundFDCount it "send and receive file" testXFTPFileTransfer @@ -69,7 +44,6 @@ chatFileTests = do it "send and receive file in group" testXFTPGroupFileTransfer it "delete uploaded file" testXFTPDeleteUploadedFile it "delete uploaded file in group" testXFTPDeleteUploadedFileGroup - it "with changed XFTP config: send and receive file" testXFTPWithChangedConfig it "with relative paths: send and receive file" testXFTPWithRelativePaths xit' "continue receiving file after restart" testXFTPContinueRcv xit' "receive file marked to receive on chat start" testXFTPMarkToReceive @@ -77,482 +51,19 @@ chatFileTests = do it "cancel receiving file, repeat receive" testXFTPCancelRcvRepeat it "should accept file automatically with CLI option" testAutoAcceptFile it "should prohibit file transfers in groups based on preference" testProhibitFiles + describe "file transfer over XFTP without chat items" $ do + it "send and receive small standalone file" testXFTPStandaloneSmall + it "send and receive small standalone file with extra information" testXFTPStandaloneSmallInfo + it "send and receive large standalone file" testXFTPStandaloneLarge + it "send and receive large standalone file with extra information" testXFTPStandaloneLargeInfo + it "send and receive large standalone file using relative paths" testXFTPStandaloneRelativePaths + xit "removes sent file from server" testXFTPStandaloneCancelSnd -- no error shown in tests + it "removes received temporary files" testXFTPStandaloneCancelRcv -runTestFileTransfer :: HasCallStack => TestCC -> TestCC -> IO () -runTestFileTransfer alice bob = do +runTestMessageWithFile :: HasCallStack => FilePath -> IO () +runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob - startFileTransfer' alice bob "test.pdf" "266.0 KiB / 272376 bytes" - concurrentlyN_ - [ do - bob #> "@alice receiving here..." - bob <## "completed receiving file 1 (test.pdf) from alice", - alice - <### [ WithTime "bob> receiving here...", - "completed sending file 1 (test.pdf) to bob" - ] - ] - src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" - dest `shouldBe` src -runTestFileTransferEncrypted :: HasCallStack => TestCC -> TestCC -> IO () -runTestFileTransferEncrypted alice bob = do - connectUsers alice bob - alice #> "/f @bob ./tests/fixtures/test.pdf" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 encrypt=on ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.pdf" - concurrently_ - (bob <## "started receiving file 1 (test.pdf) from alice") - (alice <## "started sending file 1 (test.pdf) to bob") - - concurrentlyN_ - [ do - bob #> "@alice receiving here..." - -- uncomment this and below to test encryption error in encryptFile - -- bob <## "cannot write file ./tests/tmp/test.pdf: test error, received file not encrypted" - bob <## "completed receiving file 1 (test.pdf) from alice", - alice - <### [ WithTime "bob> receiving here...", - "completed sending file 1 (test.pdf) to bob" - ] - ] - Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob - src <- B.readFile "./tests/fixtures/test.pdf" - -- dest <- B.readFile "./tests/tmp/test.pdf" - -- dest `shouldBe` src - Right dest <- chatReadFile "./tests/tmp/test.pdf" (strEncode key) (strEncode nonce) - LB.toStrict dest `shouldBe` src - -testInlineFileTransfer :: HasCallStack => FilePath -> IO () -testInlineFileTransfer = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/" - bob <## "ok" - alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/test.jpg\"}" - alice <# "@bob voice message (00:10)" - alice <# "/f @bob ./tests/fixtures/test.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - bob <# "alice> voice message (00:10)" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - -- below is not shown in "sent" mode - -- bob <## "use /fr 1 [/ | ] to receive it" - bob <## "started receiving file 1 (test.jpg) from alice" - concurrently_ - (alice <## "completed sending file 1 (test.jpg) to bob") - (bob <## "completed receiving file 1 (test.jpg) from alice") - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - where - cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} - -testInlineFileTransferEncrypted :: HasCallStack => FilePath -> IO () -testInlineFileTransferEncrypted = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/" - bob <## "ok" - bob ##> "/_files_encrypt on" - bob <## "ok" - alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/test.jpg\"}" - alice <# "@bob voice message (00:10)" - alice <# "/f @bob ./tests/fixtures/test.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - bob <# "alice> voice message (00:10)" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - -- below is not shown in "sent" mode - -- bob <## "use /fr 1 [/ | ] to receive it" - bob <## "started receiving file 1 (test.jpg) from alice" - concurrently_ - (alice <## "completed sending file 1 (test.jpg) to bob") - (bob <## "completed receiving file 1 (test.jpg) from alice") - Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob - src <- B.readFile "./tests/fixtures/test.jpg" - Right dest <- chatReadFile "./tests/tmp/test.jpg" (strEncode key) (strEncode nonce) - LB.toStrict dest `shouldBe` src - where - cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} - -testAcceptInlineFileSndCancelDuringTransfer :: HasCallStack => FilePath -> IO () -testAcceptInlineFileSndCancelDuringTransfer = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/" - bob <## "ok" - alice #> "/f @bob ./tests/fixtures/test_1MB.pdf" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 inline=on" - bob <## "saving file 1 from alice to test_1MB.pdf" - alice <## "started sending file 1 (test_1MB.pdf) to bob" - bob <## "started receiving file 1 (test_1MB.pdf) from alice" - alice ##> "/fc 1" -- test that inline file cancel doesn't delete contact connection - concurrentlyN_ - [ do - alice <##. "cancelled sending file 1 (test_1MB.pdf)" - alice <## "completed sending file 1 (test_1MB.pdf) to bob", - bob <## "completed receiving file 1 (test_1MB.pdf) from alice" - ] - alice #> "@bob hi" - bob <# "alice> hi" - bob #> "@alice hey" - alice <# "bob> hey" - where - cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, receiveChunks = 50}} - -testSmallInlineFileTransfer :: HasCallStack => FilePath -> IO () -testSmallInlineFileTransfer = - testChat2 aliceProfile bobProfile $ \alice bob -> do - connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/" - bob <## "ok" - alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/logo.jpg\"}" - alice <# "@bob voice message (00:10)" - alice <# "/f @bob ./tests/fixtures/logo.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - bob <# "alice> voice message (00:10)" - bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - -- below is not shown in "sent" mode - -- bob <## "use /fr 1 [/ | ] to receive it" - bob <## "started receiving file 1 (logo.jpg) from alice" - concurrently_ - (alice <## "completed sending file 1 (logo.jpg) to bob") - (bob <## "completed receiving file 1 (logo.jpg) from alice") - src <- B.readFile "./tests/fixtures/logo.jpg" - dest <- B.readFile "./tests/tmp/logo.jpg" - dest `shouldBe` src - -testSmallInlineFileIgnored :: HasCallStack => FilePath -> IO () -testSmallInlineFileIgnored tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> - withNewTestChatOpts tmp testOpts {allowInstantFiles = False} "bob" bobProfile $ \bob -> do - connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/" - bob <## "ok" - alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/logo.jpg\"}" - alice <# "@bob voice message (00:10)" - alice <# "/f @bob ./tests/fixtures/logo.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - bob <# "alice> voice message (00:10)" - bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob <## "A small file sent without acceptance - you can enable receiving such files with -f option." - -- below is not shown in "sent" mode - -- bob <## "use /fr 1 [/ | ] to receive it" - alice <## "completed sending file 1 (logo.jpg) to bob" - bob ##> "/fr 1" - bob <## "file is already being received: logo.jpg" - -testReceiveInline :: HasCallStack => FilePath -> IO () -testReceiveInline = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - connectUsers alice bob - alice #> "/f @bob ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 inline=on ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob" - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - where - cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 10, receiveChunks = 5}} - -runTestSmallFileTransfer :: HasCallStack => TestCC -> TestCC -> IO () -runTestSmallFileTransfer alice bob = do - connectUsers alice bob - alice #> "/f @bob ./tests/fixtures/test.txt" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.txt (11 bytes / 11 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.txt" - concurrentlyN_ - [ do - bob <## "started receiving file 1 (test.txt) from alice" - bob <## "completed receiving file 1 (test.txt) from alice", - do - alice <## "started sending file 1 (test.txt) to bob" - alice <## "completed sending file 1 (test.txt) to bob" - ] - src <- B.readFile "./tests/fixtures/test.txt" - dest <- B.readFile "./tests/tmp/test.txt" - dest `shouldBe` src - -runTestFileSndCancelBeforeTransfer :: HasCallStack => TestCC -> TestCC -> IO () -runTestFileSndCancelBeforeTransfer alice bob = do - connectUsers alice bob - alice #> "/f @bob ./tests/fixtures/test.txt" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.txt (11 bytes / 11 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - alice ##> "/fc 1" - concurrentlyN_ - [ alice <##. "cancelled sending file 1 (test.txt)", - bob <## "alice cancelled sending file 1 (test.txt)" - ] - alice ##> "/fs 1" - alice - <##.. [ "sending file 1 (test.txt): no file transfers", - "sending file 1 (test.txt) cancelled: bob" - ] - alice <## "file transfer cancelled" - bob ##> "/fs 1" - bob <## "receiving file 1 (test.txt) cancelled" - bob ##> "/fr 1 ./tests/tmp" - bob <## "file cancelled: test.txt" - -testFileSndCancelDuringTransfer :: HasCallStack => FilePath -> IO () -testFileSndCancelDuringTransfer = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" - alice ##> "/fc 1" - concurrentlyN_ - [ do - alice <## "cancelled sending file 1 (test_1MB.pdf) to bob" - alice ##> "/fs 1" - alice <## "sending file 1 (test_1MB.pdf) cancelled: bob" - alice <## "file transfer cancelled", - do - bob <## "alice cancelled sending file 1 (test_1MB.pdf)" - bob ##> "/fs 1" - bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: ./tests/tmp/test_1MB.pdf" - ] - checkPartialTransfer "test_1MB.pdf" - -testFileRcvCancel :: HasCallStack => FilePath -> IO () -testFileRcvCancel = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - startFileTransfer alice bob - bob ##> "/fs 1" - getTermLine bob >>= (`shouldStartWith` "receiving file 1 (test.jpg) progress") - waitFileExists "./tests/tmp/test.jpg" - bob ##> "/fc 1" - concurrentlyN_ - [ do - bob <## "cancelled receiving file 1 (test.jpg) from alice" - bob ##> "/fs 1" - bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg", - do - alice <## "bob cancelled receiving file 1 (test.jpg)" - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled: bob" - alice <## "file transfer cancelled" - ] - checkPartialTransfer "test.jpg" - -runTestGroupFileTransfer :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () -runTestGroupFileTransfer alice bob cath = do - createGroup3 "team" alice bob cath - alice #> "/f #team ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it", - do - cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - cath <## "use /fr 1 [/ | ] to receive it" - ] - alice ##> "/fs 1" - getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg): no file transfers") - bob ##> "/fr 1 ./tests/tmp/" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob" - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) complete: bob", - do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - ] - cath ##> "/fr 1 ./tests/tmp/" - cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to cath" - alice <## "completed sending file 1 (test.jpg) to cath" - alice ##> "/fs 1" - getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg) complete"), - do - cath <## "started receiving file 1 (test.jpg) from alice" - cath <## "completed receiving file 1 (test.jpg) from alice" - ] - src <- B.readFile "./tests/fixtures/test.jpg" - dest1 <- B.readFile "./tests/tmp/test.jpg" - dest2 <- B.readFile "./tests/tmp/test_1.jpg" - dest1 `shouldBe` src - dest2 `shouldBe` src - -testInlineGroupFileTransfer :: HasCallStack => FilePath -> IO () -testInlineGroupFileTransfer = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - bob ##> "/_files_folder ./tests/tmp/bob/" - bob <## "ok" - cath ##> "/_files_folder ./tests/tmp/cath/" - cath <## "ok" - alice ##> "/_send #1 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/logo.jpg\"}" - alice <# "#team voice message (00:10)" - alice <# "/f #team ./tests/fixtures/logo.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - alice - <### [ "completed sending file 1 (logo.jpg) to bob", - "completed sending file 1 (logo.jpg) to cath" - ] - alice ##> "/fs 1" - alice <##. "sending file 1 (logo.jpg) complete", - do - bob <# "#team alice> voice message (00:10)" - bob <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - bob <## "started receiving file 1 (logo.jpg) from alice" - bob <## "completed receiving file 1 (logo.jpg) from alice", - do - cath <# "#team alice> voice message (00:10)" - cath <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - cath <## "started receiving file 1 (logo.jpg) from alice" - cath <## "completed receiving file 1 (logo.jpg) from alice" - ] - src <- B.readFile "./tests/fixtures/logo.jpg" - dest1 <- B.readFile "./tests/tmp/bob/logo.jpg" - dest2 <- B.readFile "./tests/tmp/cath/logo.jpg" - dest1 `shouldBe` src - dest2 `shouldBe` src - where - cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, totalSendChunks = 100, receiveChunks = 100}} - -testSmallInlineGroupFileTransfer :: HasCallStack => FilePath -> IO () -testSmallInlineGroupFileTransfer = - testChatCfg3 testCfg aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - bob ##> "/_files_folder ./tests/tmp/bob/" - bob <## "ok" - cath ##> "/_files_folder ./tests/tmp/cath/" - cath <## "ok" - alice ##> "/_send #1 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/logo.jpg\"}" - alice <# "#team voice message (00:10)" - alice <# "/f #team ./tests/fixtures/logo.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - alice - <### [ "completed sending file 1 (logo.jpg) to bob", - "completed sending file 1 (logo.jpg) to cath" - ] - alice ##> "/fs 1" - alice <##. "sending file 1 (logo.jpg) complete", - do - bob <# "#team alice> voice message (00:10)" - bob <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - bob <## "started receiving file 1 (logo.jpg) from alice" - bob <## "completed receiving file 1 (logo.jpg) from alice", - do - cath <# "#team alice> voice message (00:10)" - cath <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - cath <## "started receiving file 1 (logo.jpg) from alice" - cath <## "completed receiving file 1 (logo.jpg) from alice" - ] - src <- B.readFile "./tests/fixtures/logo.jpg" - dest1 <- B.readFile "./tests/tmp/bob/logo.jpg" - dest2 <- B.readFile "./tests/tmp/cath/logo.jpg" - dest1 `shouldBe` src - dest2 `shouldBe` src - -testSmallInlineGroupFileIgnored :: HasCallStack => FilePath -> IO () -testSmallInlineGroupFileIgnored tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> - withNewTestChatOpts tmp testOpts {allowInstantFiles = False} "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {allowInstantFiles = False} "cath" cathProfile $ \cath -> do - createGroup3 "team" alice bob cath - bob ##> "/_files_folder ./tests/tmp/bob/" - bob <## "ok" - cath ##> "/_files_folder ./tests/tmp/cath/" - cath <## "ok" - alice ##> "/_send #1 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/logo.jpg\"}" - alice <# "#team voice message (00:10)" - alice <# "/f #team ./tests/fixtures/logo.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - alice - <### [ "completed sending file 1 (logo.jpg) to bob", - "completed sending file 1 (logo.jpg) to cath" - ] - alice ##> "/fs 1" - alice <##. "sending file 1 (logo.jpg) complete", - do - bob <# "#team alice> voice message (00:10)" - bob <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob <## "A small file sent without acceptance - you can enable receiving such files with -f option." - bob ##> "/fr 1" - bob <## "file is already being received: logo.jpg", - do - cath <# "#team alice> voice message (00:10)" - cath <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - cath <## "use /fr 1 [/ | ] to receive it" - cath <## "A small file sent without acceptance - you can enable receiving such files with -f option." - cath ##> "/fr 1" - cath <## "file is already being received: logo.jpg" - ] - -runTestGroupFileSndCancelBeforeTransfer :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () -runTestGroupFileSndCancelBeforeTransfer alice bob cath = do - createGroup3 "team" alice bob cath - alice #> "/f #team ./tests/fixtures/test.txt" - alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - bob <# "#team alice> sends file test.txt (11 bytes / 11 bytes)" - bob <## "use /fr 1 [/ | ] to receive it", - do - cath <# "#team alice> sends file test.txt (11 bytes / 11 bytes)" - cath <## "use /fr 1 [/ | ] to receive it" - ] - alice ##> "/fc 1" - concurrentlyN_ - [ alice <## "cancelled sending file 1 (test.txt)", - bob <## "alice cancelled sending file 1 (test.txt)", - cath <## "alice cancelled sending file 1 (test.txt)" - ] - alice ##> "/fs 1" - alice <## "sending file 1 (test.txt): no file transfers" - alice <## "file transfer cancelled" - bob ##> "/fs 1" - bob <## "receiving file 1 (test.txt) cancelled" - bob ##> "/fr 1 ./tests/tmp" - bob <## "file cancelled: test.txt" - -runTestMessageWithFile :: HasCallStack => TestCC -> TestCC -> IO () -runTestMessageWithFile alice bob = do - connectUsers alice bob alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" alice <# "@bob hi, sending a file" alice <# "/f @bob ./tests/fixtures/test.jpg" @@ -560,14 +71,15 @@ runTestMessageWithFile alice bob = do bob <# "alice> hi, sending a file" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - concurrently_ - (bob <## "completed receiving file 1 (test.jpg) from alice") - (alice <## "completed sending file 1 (test.jpg) to bob") + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src @@ -577,21 +89,22 @@ runTestMessageWithFile alice bob = do testSendImage :: HasCallStack => FilePath -> IO () testSendImage = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f @bob ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - concurrently_ - (bob <## "completed receiving file 1 (test.jpg) from alice") - (alice <## "completed sending file 1 (test.jpg) to bob") + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src @@ -604,10 +117,10 @@ testSendImage = fileExists <- doesFileExist "./tests/tmp/test.jpg" fileExists `shouldBe` True -testSenderMarkItemDeletedTransfer :: HasCallStack => FilePath -> IO () -testSenderMarkItemDeletedTransfer = +testSenderMarkItemDeleted :: HasCallStack => FilePath -> IO () +testSenderMarkItemDeleted = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test_1MB.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" alice <# "@bob hi, sending a file" @@ -616,28 +129,21 @@ testSenderMarkItemDeletedTransfer = bob <# "alice> hi, sending a file" bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test_1MB.pdf" - concurrently_ - (bob <## "started receiving file 1 (test_1MB.pdf) from alice") - (alice <## "started sending file 1 (test_1MB.pdf) to bob") + alice <## "completed uploading file 1 (test_1MB.pdf) for bob" alice #$> ("/_delete item @2 " <> itemId 1 <> " broadcast", id, "message marked deleted") - - alice ##> "/fs 1" - alice <## "sending file 1 (test_1MB.pdf) cancelled: bob" - alice <## "file transfer cancelled" - bob <# "alice> [marked deleted] hi, sending a file" - bob ##> "/fs 1" - bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: ./tests/tmp/test_1MB.pdf" - checkPartialTransfer "test_1MB.pdf" + bob ##> "/fr 1 ./tests/tmp" + bob <## "file cancelled: test_1MB.pdf" + + bob ##> "/fs 1" + bob <## "receiving file 1 (test_1MB.pdf) cancelled" testFilesFoldersSendImage :: HasCallStack => FilePath -> IO () testFilesFoldersSendImage = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob alice #$> ("/_files_folder ./tests/fixtures", id, "ok") bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") @@ -646,14 +152,15 @@ testFilesFoldersSendImage = alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + bob ##> "/fr 1" - bob <## "saving file 1 from alice to test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - concurrently_ - (bob <## "completed receiving file 1 (test.jpg) from alice") - (alice <## "completed sending file 1 (test.jpg) to bob") + bob + <### [ "saving file 1 from alice to test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/app_files/test.jpg" dest `shouldBe` src @@ -668,7 +175,7 @@ testFilesFoldersSendImage = testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO () testFilesFoldersImageSndDelete = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf" @@ -678,19 +185,22 @@ testFilesFoldersImageSndDelete = alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test_1MB.pdf) for bob" + bob ##> "/fr 1" - bob <## "saving file 1 from alice to test_1MB.pdf" - concurrently_ - (bob <## "started receiving file 1 (test_1MB.pdf) from alice") - (alice <## "started sending file 1 (test_1MB.pdf) to bob") - -- deleting contact should cancel and remove file + bob + <### [ "saving file 1 from alice to test_1MB.pdf", + "started receiving file 1 (test_1MB.pdf) from alice" + ] + bob <## "completed receiving file 1 (test_1MB.pdf) from alice" + + -- deleting contact should remove file checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do alice ##> "/d bob" alice <## "bob: contact is deleted" bob <## "alice (Alice) deleted contact with you" bob ##> "/fs 1" - bob <##. "receiving file 1 (test_1MB.pdf) progress" - -- deleting contact should remove cancelled file + bob <##. "receiving file 1 (test_1MB.pdf) complete" checkActionDeletesFile "./tests/tmp/bob_app_files/test_1MB.pdf" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" @@ -698,7 +208,7 @@ testFilesFoldersImageSndDelete = testFilesFoldersImageRcvDelete :: HasCallStack => FilePath -> IO () testFilesFoldersImageRcvDelete = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob alice #$> ("/_files_folder ./tests/fixtures", id, "ok") bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") @@ -707,28 +217,25 @@ testFilesFoldersImageRcvDelete = alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + bob ##> "/fr 1" - bob <## "saving file 1 from alice to test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - -- deleting contact should cancel and remove file - waitFileExists "./tests/tmp/app_files/test.jpg" + bob + <### [ "saving file 1 from alice to test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + + -- deleting contact should remove file checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" - alice - <### [ "bob (Bob) deleted contact with you", - "bob cancelled receiving file 1 (test.jpg)" - ] - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled: bob" - alice <## "file transfer cancelled" + alice <## "bob (Bob) deleted contact with you" testSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () testSendImageWithTextAndQuote = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob bob #> "@alice hi alice" alice <# "bob> hi alice" @@ -741,20 +248,22 @@ testSendImageWithTextAndQuote = bob <## " hey bob" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - concurrently_ - (bob <## "completed receiving file 1 (test.jpg) from alice") - (alice <## "completed sending file 1 (test.jpg) to bob") + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" B.readFile "./tests/tmp/test.jpg" `shouldReturn` src alice #$> ("/_get chat @2 count=100", chat'', chatFeatures'' <> [((0, "hi alice"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi alice"), Just "./tests/fixtures/test.jpg")]) alice @@@ [("@bob", "hey bob")] bob #$> ("/_get chat @2 count=100", chat'', chatFeatures'' <> [((1, "hi alice"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi alice"), Just "./tests/tmp/test.jpg")]) bob @@@ [("@alice", "hey bob")] + -- quoting (file + text) with file uses quoted text bob ##> ("/_send @2 json {\"filePath\": \"./tests/fixtures/test.pdf\", \"quotedItemId\": " <> itemId 2 <> ", \"msgContent\": {\"text\":\"\",\"type\":\"file\"}}") bob <# "@alice > hey bob" @@ -765,16 +274,18 @@ testSendImageWithTextAndQuote = alice <## " test.pdf" alice <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" alice <## "use /fr 2 [/ | ] to receive it" + bob <## "completed uploading file 2 (test.pdf) for alice" + alice ##> "/fr 2 ./tests/tmp" - alice <## "saving file 2 from bob to ./tests/tmp/test.pdf" - concurrently_ - (alice <## "started receiving file 2 (test.pdf) from bob") - (bob <## "started sending file 2 (test.pdf) to alice") - concurrently_ - (alice <## "completed receiving file 2 (test.pdf) from bob") - (bob <## "completed sending file 2 (test.pdf) to alice") + alice + <### [ "saving file 2 from bob to ./tests/tmp/test.pdf", + "started receiving file 2 (test.pdf) from bob" + ] + alice <## "completed receiving file 2 (test.pdf) from bob" + txtSrc <- B.readFile "./tests/fixtures/test.pdf" B.readFile "./tests/tmp/test.pdf" `shouldReturn` txtSrc + -- quoting (file without text) with file uses file name alice ##> ("/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": " <> itemId 3 <> ", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}") alice <# "@bob > test.pdf" @@ -785,20 +296,21 @@ testSendImageWithTextAndQuote = bob <## " test.jpg" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 3 [/ | ] to receive it" + alice <## "completed uploading file 3 (test.jpg) for bob" + bob ##> "/fr 3 ./tests/tmp" - bob <## "saving file 3 from alice to ./tests/tmp/test_1.jpg" - concurrently_ - (bob <## "started receiving file 3 (test.jpg) from alice") - (alice <## "started sending file 3 (test.jpg) to bob") - concurrently_ - (bob <## "completed receiving file 3 (test.jpg) from alice") - (alice <## "completed sending file 3 (test.jpg) to bob") + bob + <### [ "saving file 3 from alice to ./tests/tmp/test_1.jpg", + "started receiving file 3 (test.jpg) from alice" + ] + bob <## "completed receiving file 3 (test.jpg) from alice" + B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src testGroupSendImage :: HasCallStack => FilePath -> IO () testGroupSendImage = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath threadDelay 1000000 alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" @@ -812,26 +324,22 @@ testGroupSendImage = cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" cath <## "use /fr 1 [/ | ] to receive it" ] - bob ##> "/fr 1 ./tests/tmp/" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob", - do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - ] - cath ##> "/fr 1 ./tests/tmp/" - cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to cath" - alice <## "completed sending file 1 (test.jpg) to cath", - do - cath <## "started receiving file 1 (test.jpg) from alice" - cath <## "completed receiving file 1 (test.jpg) from alice" - ] + alice <## "completed uploading file 1 (test.jpg) for #team" + + bob ##> "/fr 1 ./tests/tmp" + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + + cath ##> "/fr 1 ./tests/tmp" + cath + <### [ "saving file 1 from alice to ./tests/tmp/test_1.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + cath <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src @@ -844,7 +352,7 @@ testGroupSendImage = testGroupSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () testGroupSendImageWithTextAndQuote = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath threadDelay 1000000 bob #> "#team hi team" @@ -870,26 +378,22 @@ testGroupSendImageWithTextAndQuote = cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" cath <## "use /fr 1 [/ | ] to receive it" ] - bob ##> "/fr 1 ./tests/tmp/" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob", - do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - ] - cath ##> "/fr 1 ./tests/tmp/" - cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to cath" - alice <## "completed sending file 1 (test.jpg) to cath", - do - cath <## "started receiving file 1 (test.jpg) from alice" - cath <## "completed receiving file 1 (test.jpg) from alice" - ] + alice <## "completed uploading file 1 (test.jpg) for #team" + + bob ##> "/fr 1 ./tests/tmp" + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + + cath ##> "/fr 1 ./tests/tmp" + cath + <### [ "saving file 1 from alice to ./tests/tmp/test_1.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + cath <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src @@ -902,142 +406,6 @@ testGroupSendImageWithTextAndQuote = cath #$> ("/_get chat #1 count=2", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")]) cath @@@ [("#team", "hey bob"), ("@alice", "received invitation to join group team as admin")] -testAsyncFileTransferSenderRestarts :: HasCallStack => FilePath -> IO () -testAsyncFileTransferSenderRestarts tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - connectUsers alice bob - startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" - threadDelay 100000 - withTestChatContactConnected tmp "alice" $ \alice -> do - alice <## "completed sending file 1 (test_1MB.pdf) to bob" - bob <## "completed receiving file 1 (test_1MB.pdf) from alice" - src <- B.readFile "./tests/fixtures/test_1MB.pdf" - dest <- B.readFile "./tests/tmp/test_1MB.pdf" - dest `shouldBe` src - -testAsyncFileTransferReceiverRestarts :: HasCallStack => FilePath -> IO () -testAsyncFileTransferReceiverRestarts tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - connectUsers alice bob - startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" - threadDelay 100000 - withTestChatContactConnected tmp "bob" $ \bob -> do - alice <## "completed sending file 1 (test_1MB.pdf) to bob" - bob <## "completed receiving file 1 (test_1MB.pdf) from alice" - src <- B.readFile "./tests/fixtures/test_1MB.pdf" - dest <- B.readFile "./tests/tmp/test_1MB.pdf" - dest `shouldBe` src - -testAsyncFileTransfer :: HasCallStack => FilePath -> IO () -testAsyncFileTransfer tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - connectUsers alice bob - withTestChatContactConnected tmp "alice" $ \alice -> do - alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\":\"text\", \"text\": \"hi, sending a file\"}}" - alice <# "@bob hi, sending a file" - alice <# "/f @bob ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - withTestChatContactConnected tmp "bob" $ \bob -> do - bob <# "alice> hi, sending a file" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - -- withTestChatContactConnected' tmp "alice" -- TODO not needed in v2 - -- withTestChatContactConnected' tmp "bob" -- TODO not needed in v2 - withTestChatContactConnected' tmp "alice" - withTestChatContactConnected' tmp "bob" - withTestChatContactConnected tmp "alice" $ \alice -> do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob" - withTestChatContactConnected tmp "bob" $ \bob -> do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - -testAsyncFileTransferV1 :: HasCallStack => FilePath -> IO () -testAsyncFileTransferV1 tmp = do - withNewTestChatV1 tmp "alice" aliceProfile $ \alice -> - withNewTestChatV1 tmp "bob" bobProfile $ \bob -> - connectUsers alice bob - withTestChatContactConnectedV1 tmp "alice" $ \alice -> do - alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\":\"text\", \"text\": \"hi, sending a file\"}}" - alice <# "@bob hi, sending a file" - alice <# "/f @bob ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - withTestChatContactConnectedV1 tmp "bob" $ \bob -> do - bob <# "alice> hi, sending a file" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - withTestChatContactConnectedV1' tmp "alice" -- TODO not needed in v2 - withTestChatContactConnectedV1' tmp "bob" -- TODO not needed in v2 - withTestChatContactConnectedV1' tmp "alice" - withTestChatContactConnectedV1' tmp "bob" - withTestChatContactConnectedV1 tmp "alice" $ \alice -> do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob" - withTestChatContactConnectedV1 tmp "bob" $ \bob -> do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - -testAsyncGroupFileTransfer :: HasCallStack => FilePath -> IO () -testAsyncGroupFileTransfer tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> - createGroup3 "team" alice bob cath - withTestChatGroup3Connected tmp "alice" $ \alice -> do - alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"text\"}}" - alice <# "/f #team ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - withTestChatGroup3Connected tmp "bob" $ \bob -> do - bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp/" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - withTestChatGroup3Connected tmp "cath" $ \cath -> do - cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - cath <## "use /fr 1 [/ | ] to receive it" - cath ##> "/fr 1 ./tests/tmp/" - cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" - withTestChatGroup3Connected' tmp "alice" - withTestChatGroup3Connected' tmp "bob" - withTestChatGroup3Connected' tmp "cath" - -- withTestChatGroup3Connected' tmp "alice" -- TODO not needed in v2 - -- withTestChatGroup3Connected' tmp "bob" -- TODO not needed in v2 - -- withTestChatGroup3Connected' tmp "cath" -- TODO not needed in v2 - withTestChatGroup3Connected' tmp "alice" - withTestChatGroup3Connected tmp "bob" $ \bob -> do - bob <## "started receiving file 1 (test.jpg) from alice" - withTestChatGroup3Connected tmp "cath" $ \cath -> do - cath <## "started receiving file 1 (test.jpg) from alice" - withTestChatGroup3Connected tmp "alice" $ \alice -> do - alice - <### [ "started sending file 1 (test.jpg) to bob", - "completed sending file 1 (test.jpg) to bob", - "started sending file 1 (test.jpg) to cath", - "completed sending file 1 (test.jpg) to cath" - ] - withTestChatGroup3Connected tmp "bob" $ \bob -> do - bob <## "completed receiving file 1 (test.jpg) from alice" - withTestChatGroup3Connected tmp "cath" $ \cath -> do - cath <## "completed receiving file 1 (test.jpg) from alice" - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - dest2 <- B.readFile "./tests/tmp/test_1.jpg" - dest2 `shouldBe` src - testXFTPRoundFDCount :: Expectation testXFTPRoundFDCount = do roundedFDCount (-100) `shouldBe` 4 @@ -1053,13 +421,12 @@ testXFTPRoundFDCount = do testXFTPFileTransfer :: HasCallStack => FilePath -> IO () testXFTPFileTransfer = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" alice <## "use /fc 1 to cancel sending" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 ./tests/tmp" @@ -1080,12 +447,10 @@ testXFTPFileTransfer = src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPFileTransferEncrypted :: HasCallStack => FilePath -> IO () testXFTPFileTransferEncrypted = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do src <- B.readFile "./tests/fixtures/test.pdf" srcLen <- getFileSize "./tests/fixtures/test.pdf" let srcPath = "./tests/tmp/alice/test.pdf" @@ -1109,12 +474,10 @@ testXFTPFileTransferEncrypted = Right dest <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce) LB.length dest `shouldBe` fromIntegral srcLen LB.toStrict dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPAcceptAfterUpload :: HasCallStack => FilePath -> IO () testXFTPAcceptAfterUpload = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do connectUsers alice bob @@ -1122,7 +485,6 @@ testXFTPAcceptAfterUpload = alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" threadDelay 100000 @@ -1137,12 +499,10 @@ testXFTPAcceptAfterUpload = src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPGroupFileTransfer :: HasCallStack => FilePath -> IO () testXFTPGroupFileTransfer = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do createGroup3 "team" alice bob cath @@ -1156,7 +516,6 @@ testXFTPGroupFileTransfer = cath <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)" cath <## "use /fr 1 [/ | ] to receive it" ] - -- alice <## "started sending file 1 (test.pdf) to #team" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for #team" bob ##> "/fr 1 ./tests/tmp" @@ -1178,12 +537,10 @@ testXFTPGroupFileTransfer = dest2 <- B.readFile "./tests/tmp/test_1.pdf" dest1 `shouldBe` src dest2 `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPDeleteUploadedFile :: HasCallStack => FilePath -> IO () testXFTPDeleteUploadedFile = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do connectUsers alice bob @@ -1191,7 +548,6 @@ testXFTPDeleteUploadedFile = alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" alice ##> "/fc 1" @@ -1202,12 +558,10 @@ testXFTPDeleteUploadedFile = bob ##> "/fr 1 ./tests/tmp" bob <## "file cancelled: test.pdf" - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPDeleteUploadedFileGroup :: HasCallStack => FilePath -> IO () testXFTPDeleteUploadedFileGroup = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do createGroup3 "team" alice bob cath @@ -1221,7 +575,6 @@ testXFTPDeleteUploadedFileGroup = cath <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)" cath <## "use /fr 1 [/ | ] to receive it" ] - -- alice <## "started sending file 1 (test.pdf) to #team" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for #team" bob ##> "/fr 1 ./tests/tmp" @@ -1257,45 +610,10 @@ testXFTPDeleteUploadedFileGroup = cath ##> "/fr 1 ./tests/tmp" cath <## "file cancelled: test.pdf" - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} - -testXFTPWithChangedConfig :: HasCallStack => FilePath -> IO () -testXFTPWithChangedConfig = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - withXFTPServer $ do - alice #$> ("/_xftp off", id, "ok") - alice #$> ("/_xftp on {\"minFileSize\":1024}", id, "ok") - - bob #$> ("/xftp off", id, "ok") - bob #$> ("/xftp on size=1kb", id, "ok") - - connectUsers alice bob - - alice #> "/f @bob ./tests/fixtures/test.pdf" - alice <## "use /fc 1 to cancel sending" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? - bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - concurrentlyN_ - [ alice <## "completed uploading file 1 (test.pdf) for bob", - bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", - "started receiving file 1 (test.pdf) from alice" - ] - ] - bob <## "completed receiving file 1 (test.pdf) from alice" - - src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" - dest `shouldBe` src - where - cfg = testCfg {tempDir = Just "./tests/tmp"} testXFTPWithRelativePaths :: HasCallStack => FilePath -> IO () testXFTPWithRelativePaths = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do -- agent is passed xftp work directory only on chat start, -- so for test we work around by stopping and starting chat @@ -1317,7 +635,6 @@ testXFTPWithRelativePaths = alice #> "/f @bob test.pdf" alice <## "use /fc 1 to cancel sending" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1" @@ -1333,25 +650,22 @@ testXFTPWithRelativePaths = src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/bob_files/test.pdf" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}} testXFTPContinueRcv :: HasCallStack => FilePath -> IO () testXFTPContinueRcv tmp = do withXFTPServer $ do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" -- server is down - file is not received - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/fr 1 ./tests/tmp" bob @@ -1366,18 +680,16 @@ testXFTPContinueRcv tmp = do withXFTPServer $ do -- server is up - file reception is continued - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPMarkToReceive :: HasCallStack => FilePath -> IO () testXFTPMarkToReceive = do - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do connectUsers alice bob @@ -1385,7 +697,6 @@ testXFTPMarkToReceive = do alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" bob #$> ("/_set_file_to_receive 1", id, "ok") @@ -1412,26 +723,23 @@ testXFTPMarkToReceive = do src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/bob_files/test.pdf" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}} testXFTPRcvError :: HasCallStack => FilePath -> IO () testXFTPRcvError tmp = do withXFTPServer $ do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" -- server is up w/t store log - file reception should fail withXFTPServer' xftpServerConfig {storeLogFile = Nothing} $ do - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/fr 1 ./tests/tmp" bob @@ -1443,8 +751,6 @@ testXFTPRcvError tmp = do bob ##> "/fs 1" bob <## "receiving file 1 (test.pdf) error" - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPCancelRcvRepeat :: HasCallStack => FilePath -> IO () testXFTPCancelRcvRepeat = @@ -1456,7 +762,6 @@ testXFTPCancelRcvRepeat = alice #> "/f @bob ./tests/tmp/testfile" alice <## "use /fc 1 to cancel sending" - -- alice <## "started sending file 1 (testfile) to bob" -- TODO "started uploading" ? bob <# "alice> sends file testfile (17.0 MiB / 17825792 bytes)" bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 ./tests/tmp" @@ -1493,11 +798,11 @@ testXFTPCancelRcvRepeat = dest <- B.readFile "./tests/tmp/testfile_1" dest `shouldBe` src where - cfg = testCfg {xftpDescrPartSize = 200, xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + cfg = testCfg {xftpDescrPartSize = 200} testAutoAcceptFile :: HasCallStack => FilePath -> IO () testAutoAcceptFile = - testChatCfgOpts2 cfg opts aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob bob ##> "/_files_folder ./tests/tmp/bob_files" bob <## "ok" @@ -1518,12 +823,11 @@ testAutoAcceptFile = -- no auto accept for large files (bob FilePath -> IO () testProhibitFiles = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath alice ##> "/set files #team off" alice <## "updated group preferences:" @@ -1542,22 +846,207 @@ testProhibitFiles = alice <## "bad chat command: feature not allowed Files and media" (bob TestCC -> TestCC -> IO () -startFileTransfer alice bob = - startFileTransfer' alice bob "test.jpg" "136.5 KiB / 139737 bytes" +testXFTPStandaloneSmall :: HasCallStack => FilePath -> IO () +testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do + withXFTPServer $ do + logNote "sending" + src ##> "/_upload 1 ./tests/fixtures/logo.jpg" + src <## "started standalone uploading file 1 (logo.jpg)" + -- silent progress events + threadDelay 250000 + src <## "file 1 (logo.jpg) upload complete. download with:" + -- file description fits, enjoy the direct URIs + _uri1 <- getTermLine src + _uri2 <- getTermLine src + uri3 <- getTermLine src + _uri4 <- getTermLine src -startFileTransfer' :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () -startFileTransfer' cc1 cc2 fName fSize = startFileTransferWithDest' cc1 cc2 fName fSize $ Just "./tests/tmp" + logNote "receiving" + let dstFile = "./tests/tmp/logo.jpg" + dst ##> ("/_download 1 " <> uri3 <> " " <> dstFile) + dst <## "started standalone receiving file 1 (logo.jpg)" + -- silent progress events + threadDelay 250000 + dst <## "completed standalone receiving file 1 (logo.jpg)" + srcBody <- B.readFile "./tests/fixtures/logo.jpg" + B.readFile dstFile `shouldReturn` srcBody -checkPartialTransfer :: HasCallStack => String -> IO () -checkPartialTransfer fileName = do - src <- B.readFile $ "./tests/fixtures/" <> fileName - dest <- B.readFile $ "./tests/tmp/" <> fileName - B.unpack src `shouldStartWith` B.unpack dest - B.length src > B.length dest `shouldBe` True +testXFTPStandaloneSmallInfo :: HasCallStack => FilePath -> IO () +testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do + withXFTPServer $ do + logNote "sending" + src ##> "/_upload 1 ./tests/fixtures/logo.jpg" + src <## "started standalone uploading file 1 (logo.jpg)" + -- silent progress events + threadDelay 250000 + src <## "file 1 (logo.jpg) upload complete. download with:" + -- file description fits, enjoy the direct URIs + _uri1 <- getTermLine src + _uri2 <- getTermLine src + uri3 <- getTermLine src + _uri4 <- getTermLine src + let uri = uri3 <> "&data=" <> B.unpack (urlEncode False . LB.toStrict . J.encode $ J.object ["secret" J..= J.String "*********"]) -waitFileExists :: HasCallStack => FilePath -> IO () -waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f + logNote "info" + dst ##> ("/_download info " <> uri) + dst <## "{\"secret\":\"*********\"}" + + logNote "receiving" + let dstFile = "./tests/tmp/logo.jpg" + dst ##> ("/_download 1 " <> uri <> " " <> dstFile) -- download sucessfully discarded extra info + dst <## "started standalone receiving file 1 (logo.jpg)" + -- silent progress events + threadDelay 250000 + dst <## "completed standalone receiving file 1 (logo.jpg)" + srcBody <- B.readFile "./tests/fixtures/logo.jpg" + B.readFile dstFile `shouldReturn` srcBody + +testXFTPStandaloneLarge :: HasCallStack => FilePath -> IO () +testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do + withXFTPServer $ do + xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] + + logNote "sending" + src ##> "/_upload 1 ./tests/tmp/testfile.in" + src <## "started standalone uploading file 1 (testfile.in)" + -- silent progress events + threadDelay 250000 + src <## "file 1 (testfile.in) uploaded, preparing redirect file 2" + src <## "file 1 (testfile.in) upload complete. download with:" + uri <- getTermLine src + _uri2 <- getTermLine src + _uri3 <- getTermLine src + _uri4 <- getTermLine src + + logNote "receiving" + let dstFile = "./tests/tmp/testfile.out" + dst ##> ("/_download 1 " <> uri <> " " <> dstFile) + dst <## "started standalone receiving file 1 (testfile.out)" + -- silent progress events + threadDelay 250000 + dst <## "completed standalone receiving file 1 (testfile.out)" + srcBody <- B.readFile "./tests/tmp/testfile.in" + B.readFile dstFile `shouldReturn` srcBody + +testXFTPStandaloneLargeInfo :: HasCallStack => FilePath -> IO () +testXFTPStandaloneLargeInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do + withXFTPServer $ do + xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] + + logNote "sending" + src ##> "/_upload 1 ./tests/tmp/testfile.in" + src <## "started standalone uploading file 1 (testfile.in)" + + -- silent progress events + threadDelay 250000 + src <## "file 1 (testfile.in) uploaded, preparing redirect file 2" + src <## "file 1 (testfile.in) upload complete. download with:" + uri1 <- getTermLine src + _uri2 <- getTermLine src + _uri3 <- getTermLine src + _uri4 <- getTermLine src + let uri = uri1 <> "&data=" <> B.unpack (urlEncode False . LB.toStrict . J.encode $ J.object ["secret" J..= J.String "*********"]) + + logNote "info" + dst ##> ("/_download info " <> uri) + dst <## "{\"secret\":\"*********\"}" + + logNote "receiving" + let dstFile = "./tests/tmp/testfile.out" + dst ##> ("/_download 1 " <> uri <> " " <> dstFile) + dst <## "started standalone receiving file 1 (testfile.out)" + -- silent progress events + threadDelay 250000 + dst <## "completed standalone receiving file 1 (testfile.out)" + srcBody <- B.readFile "./tests/tmp/testfile.in" + B.readFile dstFile `shouldReturn` srcBody + +testXFTPStandaloneCancelSnd :: HasCallStack => FilePath -> IO () +testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do + withXFTPServer $ do + xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] + + logNote "sending" + src ##> "/_upload 1 ./tests/tmp/testfile.in" + src <## "started standalone uploading file 1 (testfile.in)" + -- silent progress events + threadDelay 250000 + src <## "file 1 (testfile.in) uploaded, preparing redirect file 2" + src <## "file 1 (testfile.in) upload complete. download with:" + uri <- getTermLine src + _uri2 <- getTermLine src + _uri3 <- getTermLine src + _uri4 <- getTermLine src + + logNote "cancelling" + src ##> "/fc 1" + src <## "cancelled sending file 1 (testfile.in)" + threadDelay 1000000 + + logNote "trying to receive cancelled" + dst ##> ("/_download 1 " <> uri <> " " <> "./tests/tmp/should.not.extist") + dst <## "started standalone receiving file 1 (should.not.extist)" + threadDelay 100000 + logWarn "no error?" + dst <## "error receiving file 1 (should.not.extist)" + dst <## "INTERNAL {internalErr = \"XFTP {xftpErr = AUTH}\"}" + +testXFTPStandaloneRelativePaths :: HasCallStack => FilePath -> IO () +testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do + withXFTPServer $ do + logNote "sending" + src #$> ("/_files_folder ./tests/tmp/src_files", id, "ok") + src #$> ("/_temp_folder ./tests/tmp/src_xftp_temp", id, "ok") + + xftpCLI ["rand", "./tests/tmp/src_files/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/src_files/testfile.in"] + + src ##> "/_upload 1 testfile.in" + src <## "started standalone uploading file 1 (testfile.in)" + -- silent progress events + threadDelay 250000 + src <## "file 1 (testfile.in) uploaded, preparing redirect file 2" + src <## "file 1 (testfile.in) upload complete. download with:" + uri <- getTermLine src + _uri2 <- getTermLine src + _uri3 <- getTermLine src + _uri4 <- getTermLine src + + logNote "receiving" + dst #$> ("/_files_folder ./tests/tmp/dst_files", id, "ok") + dst #$> ("/_temp_folder ./tests/tmp/dst_xftp_temp", id, "ok") + dst ##> ("/_download 1 " <> uri <> " testfile.out") + dst <## "started standalone receiving file 1 (testfile.out)" + -- silent progress events + threadDelay 250000 + dst <## "completed standalone receiving file 1 (testfile.out)" + srcBody <- B.readFile "./tests/tmp/src_files/testfile.in" + B.readFile "./tests/tmp/dst_files/testfile.out" `shouldReturn` srcBody + +testXFTPStandaloneCancelRcv :: HasCallStack => FilePath -> IO () +testXFTPStandaloneCancelRcv = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do + withXFTPServer $ do + xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] + + logNote "sending" + src ##> "/_upload 1 ./tests/tmp/testfile.in" + src <## "started standalone uploading file 1 (testfile.in)" + -- silent progress events + threadDelay 250000 + src <## "file 1 (testfile.in) uploaded, preparing redirect file 2" + src <## "file 1 (testfile.in) upload complete. download with:" + uri <- getTermLine src + _uri2 <- getTermLine src + _uri3 <- getTermLine src + _uri4 <- getTermLine src + + logNote "receiving" + let dstFile = "./tests/tmp/testfile.out" + dst ##> ("/_download 1 " <> uri <> " " <> dstFile) + dst <## "started standalone receiving file 1 (testfile.out)" + threadDelay 25000 -- give workers some time to avoid internal errors from starting tasks + logNote "cancelling" + dst ##> "/fc 1" + dst <## "cancelled receiving file 1 (testfile.out)" + threadDelay 25000 + doesFileExist dstFile `shouldReturn` False diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 51a5b352a7..bf9b445925 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PostfixOperators #-} module ChatTests.Groups where @@ -11,12 +12,12 @@ import Control.Monad (void, when) import qualified Data.ByteString as B import Data.List (isInfixOf) import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) -import Simplex.Chat.Types (GroupMemberRole (..)) +import Simplex.Chat.Types (GroupMemberRole (..), VersionRangeChat) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Messaging.Version +import Simplex.Messaging.Crypto.Ratchet (pattern PQSupportOff) import System.Directory (copyFile) import System.FilePath (()) import Test.Hspec hiding (it) @@ -148,19 +149,19 @@ chatGroupTests = do it "member was blocked before joining group" testBlockForAllBeforeJoining it "can't repeat block, unblock" testBlockForAllCantRepeat where - _0 = supportedChatVRange -- don't create direct connections + _0 = supportedChatVRange PQSupportOff -- don't create direct connections _1 = groupCreateDirectVRange -- having host configured with older version doesn't have effect in tests -- because host uses current code and sends version in MemberInfo testNoDirect vrMem2 vrMem3 noConns = it ( "host " - <> vRangeStr supportedChatVRange + <> vRangeStr (supportedChatVRange PQSupportOff) <> (", 2nd mem " <> vRangeStr vrMem2) <> (", 3rd mem " <> vRangeStr vrMem3) <> (if noConns then " : 2 3" else " : 2 <##> 3") ) - $ testNoGroupDirectConns supportedChatVRange vrMem2 vrMem3 noConns + $ testNoGroupDirectConns (supportedChatVRange PQSupportOff) vrMem2 vrMem3 noConns testGroup :: HasCallStack => FilePath -> IO () testGroup = @@ -336,11 +337,11 @@ testGroupShared alice bob cath checkMessages directConnections = do getReadChats :: HasCallStack => String -> String -> IO () getReadChats msgItem1 msgItem2 = do alice @@@ [("#team", "hey team"), ("@cath", "sent invitation to join group team as admin"), ("@bob", "sent invitation to join group team as admin")] - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there"), (0, "hey team")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there"), (0, "hey team")]) -- "before" and "after" define a chat item id across all chats, -- so we take into account group event items as well as sent group invitations in direct chats alice #$> ("/_get chat #1 after=" <> msgItem1 <> " count=100", chat, [(0, "hi there"), (0, "hey team")]) - alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) + alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")]) bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")]) @@ -499,9 +500,10 @@ testGroup2 = dan <##> cath dan <##> alice -- show last messages - alice ##> "/t #club 8" + alice ##> "/t #club 9" alice -- these strings are expected in any order because of sorting by time and rounding of time for sent - <##? [ "#club bob> connected", + <##? [ ConsoleString ("#club " <> e2eeInfoNoPQStr), + "#club bob> connected", "#club cath> connected", "#club bob> added dan (Daniel)", "#club dan> connected", @@ -1858,7 +1860,7 @@ testGroupLink = bob <## "#team: you joined the group" ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "invited via your group link"), (0, "connected")]) -- contacts connected via group link are not in chat previews alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected")] @@ -2697,7 +2699,7 @@ testGroupLinkNoContact = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected")] @@ -2760,7 +2762,7 @@ testGroupLinkNoContactInviteesWereConnected = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected"), ("@cath", "hey")] @@ -2841,7 +2843,7 @@ testGroupLinkNoContactAllMembersWereConnected = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) alice @@@ [("#team", "connected"), ("@bob", "hey"), ("@cath", "hey")] bob @@@ [("#team", "connected"), ("@alice", "hey"), ("@cath", "hey")] @@ -2996,7 +2998,7 @@ testGroupLinkNoContactHostIncognito = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "invited via your group link"), (0, "connected")]) alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected")] @@ -3029,7 +3031,7 @@ testGroupLinkNoContactInviteeIncognito = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "invited via your group link"), (0, "connected")]) alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected")] @@ -3096,7 +3098,7 @@ testGroupLinkNoContactExistingContactMerged = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "invited via your group link"), (0, "connected")]) alice <##> bob @@ -3579,11 +3581,11 @@ testConfigureGroupDeliveryReceipts tmp = cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) cc1 VersionRange -> VersionRange -> VersionRange -> Bool -> FilePath -> IO () +testNoGroupDirectConns :: HasCallStack => VersionRangeChat -> VersionRangeChat -> VersionRangeChat -> Bool -> FilePath -> IO () testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns tmp = - withNewTestChatCfg tmp testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do + withNewTestChatCfg tmp testCfg {chatVRange = const hostVRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = const mem2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfg {chatVRange = const mem3VRange} "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath if noDirectConns then contactsDontExist bob cath @@ -4321,7 +4323,7 @@ testGroupMsgForwardDeletion = testGroupMsgForwardFile :: HasCallStack => FilePath -> IO () testGroupMsgForwardFile = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do setupGroupForwarding3 "team" alice bob cath @@ -4343,8 +4345,6 @@ testGroupMsgForwardFile = src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupMsgForwardChangeRole :: HasCallStack => FilePath -> IO () testGroupMsgForwardChangeRole = @@ -4577,7 +4577,7 @@ testGroupHistoryPreferenceOff = testGroupHistoryHostFile :: HasCallStack => FilePath -> IO () testGroupHistoryHostFile = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup2 "team" alice bob @@ -4613,12 +4613,10 @@ testGroupHistoryHostFile = src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupHistoryMemberFile :: HasCallStack => FilePath -> IO () testGroupHistoryMemberFile = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup2 "team" alice bob @@ -4654,8 +4652,6 @@ testGroupHistoryMemberFile = src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupHistoryLargeFile :: HasCallStack => FilePath -> IO () testGroupHistoryLargeFile = @@ -4713,11 +4709,11 @@ testGroupHistoryLargeFile = destCath <- B.readFile "./tests/tmp/testfile_2" destCath `shouldBe` src where - cfg = testCfg {xftpDescrPartSize = 200, xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + cfg = testCfg {xftpDescrPartSize = 200} testGroupHistoryMultipleFiles :: HasCallStack => FilePath -> IO () testGroupHistoryMultipleFiles = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"] xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"] @@ -4794,12 +4790,10 @@ testGroupHistoryMultipleFiles = `shouldContain` [ ((0, "hi alice"), Just "./tests/tmp/testfile_bob_1"), ((0, "hey bob"), Just "./tests/tmp/testfile_alice_1") ] - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupHistoryFileCancel :: HasCallStack => FilePath -> IO () testGroupHistoryFileCancel = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"] xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"] @@ -4851,12 +4845,10 @@ testGroupHistoryFileCancel = bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "#team: new member cath is connected" ] - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupHistoryFileCancelNoText :: HasCallStack => FilePath -> IO () testGroupHistoryFileCancelNoText = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"] xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"] @@ -4912,8 +4904,6 @@ testGroupHistoryFileCancelNoText = bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "#team: new member cath is connected" ] - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupHistoryQuotes :: HasCallStack => FilePath -> IO () testGroupHistoryQuotes = @@ -5062,8 +5052,7 @@ testGroupHistoryDeletedMessage = testGroupHistoryDisappearingMessage :: HasCallStack => FilePath -> IO () testGroupHistoryDisappearingMessage = testChat3 aliceProfile bobProfile cathProfile $ - -- \alice bob cath -> do -- revert when test is stable - \a b c -> withTestOutput a $ \alice -> withTestOutput b $ \bob -> withTestOutput c $ \cath -> do + \alice bob cath -> do createGroup2 "team" alice bob threadDelay 1000000 diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs index 1d0c540d76..40ebe51b83 100644 --- a/tests/ChatTests/Local.hs +++ b/tests/ChatTests/Local.hs @@ -12,7 +12,6 @@ import Simplex.Chat.Controller (ChatConfig (..), InlineFilesConfig (..), default import System.Directory (copyFile, doesFileExist) import System.FilePath (()) import Test.Hspec hiding (it) -import UnliftIO.Async (concurrently_) chatLocalChatsTests :: SpecWith FilePath chatLocalChatsTests = do @@ -152,30 +151,30 @@ testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/clear *" alice ##> "/fs 1" - alice <## "chat db error: SEChatItemNotFoundByFileId {fileId = 1}" + alice <## "file 1 not found" alice ##> "/tail" doesFileExist stored `shouldReturn` False testOtherFiles :: FilePath -> IO () testOtherFiles = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob createCCNoteFolder bob bob ##> "/_files_folder ./tests/tmp/" bob <## "ok" - alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/test.jpg\"}" - alice <# "@bob voice message (00:10)" - alice <# "/f @bob ./tests/fixtures/test.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - bob <# "alice> voice message (00:10)" + + alice #> "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - -- below is not shown in "sent" mode - -- bob <## "use /fr 1 [/ | ] to receive it" - bob <## "started receiving file 1 (test.jpg) from alice" - concurrently_ - (alice <## "completed sending file 1 (test.jpg) to bob") - (bob <## "completed receiving file 1 (test.jpg) from alice") + bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + + bob ##> "/fr 1" + bob + <### [ "saving file 1 from alice to test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" bob /* "test" bob ##> "/tail *" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 80cdc34c76..7996fde3ad 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1493,7 +1493,7 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ testSetContactPrefs :: HasCallStack => FilePath -> IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") bob #$> ("/_files_folder ./tests/tmp/bob", id, "ok") createDirectoryIfMissing True "./tests/tmp/alice" @@ -1509,7 +1509,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ alice ##> "/_set prefs @2 {}" alice <## "your preferences for bob did not change" (bob ("/_get chat @2 count=100", chat, startFeatures) bob #$> ("/_get chat @2 count=100", chat, startFeatures) let sendVoice = "/_send @2 json {\"filePath\": \"test.txt\", \"msgContent\": {\"type\": \"voice\", \"text\": \"\", \"duration\": 10}}" @@ -1528,15 +1528,24 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you")]) alice ##> sendVoice alice <## voiceNotAllowed + + -- sending voice message allowed bob ##> sendVoice bob <# "@alice voice message (00:10)" bob <# "/f @alice test.txt" - bob <## "completed sending file 1 (test.txt) to alice" + bob <## "use /fc 1 to cancel sending" alice <# "bob> voice message (00:10)" alice <# "bob> sends file test.txt (11 bytes / 11 bytes)" - alice <## "started receiving file 1 (test.txt) from bob" + alice <## "use /fr 1 [/ | ] to receive it" + bob <## "completed uploading file 1 (test.txt) for alice" + alice ##> "/fr 1" + alice + <### [ "saving file 1 from bob to test_1.txt", + "started receiving file 1 (test.txt) from bob" + ] alice <## "completed receiving file 1 (test.txt) from bob" (bob "/_profile 1 {\"displayName\": \"alice\", \"fullName\": \"Alice\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" alice ##> "/set voice no" alice <## "updated preferences:" @@ -1599,13 +1608,13 @@ testUpdateGroupPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected")]) threadDelay 500000 bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected")]) alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"on\"}, \"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}" alice <## "updated group preferences:" alice <## "Full deletion: on" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Full deletion: on")]) bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" @@ -1615,7 +1624,7 @@ testUpdateGroupPrefs = alice <## "updated group preferences:" alice <## "Full deletion: off" alice <## "Voice messages: off" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off")]) bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: off" @@ -1625,7 +1634,7 @@ testUpdateGroupPrefs = alice ##> "/set voice #team on" alice <## "updated group preferences:" alice <## "Voice messages: on" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Voice messages: on" @@ -1635,14 +1644,14 @@ testUpdateGroupPrefs = alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}, \"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}" -- no update threadDelay 500000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) alice #> "#team hey" bob <# "#team alice> hey" threadDelay 1000000 bob #> "#team hi" alice <# "#team bob> hi" threadDelay 500000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on"), (1, "hey"), (0, "hi")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on"), (1, "hey"), (0, "hi")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off"), (0, "Voice messages: on"), (0, "hey"), (1, "hi")]) testAllowFullDeletionContact :: HasCallStack => FilePath -> IO () @@ -1668,7 +1677,7 @@ testAllowFullDeletionGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob - threadDelay 1000000 + threadDelay 1500000 alice #> "#team hi" bob <# "#team alice> hi" threadDelay 1000000 @@ -1682,11 +1691,11 @@ testAllowFullDeletionGroup = bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "hi"), (0, "hey"), (1, "Full deletion: on")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "hi"), (0, "hey"), (1, "Full deletion: on")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "hi"), (1, "hey"), (0, "Full deletion: on")]) bob #$> ("/_delete item #1 " <> msgItemId <> " broadcast", id, "message deleted") alice <# "#team bob> [deleted] hey" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "hi"), (1, "Full deletion: on")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "hi"), (1, "Full deletion: on")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "hi"), (0, "Full deletion: on")]) testProhibitDirectMessages :: HasCallStack => FilePath -> IO () @@ -1808,12 +1817,12 @@ testEnableTimedMessagesGroup = alice #> "#team hi" bob <# "#team alice> hi" threadDelay 500000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Disappearing messages: on (1 sec)"), (1, "hi")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Disappearing messages: on (1 sec)"), (1, "hi")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (1 sec)"), (0, "hi")]) threadDelay 1000000 alice <## "timed message deleted: hi" bob <## "timed message deleted: hi" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Disappearing messages: on (1 sec)")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Disappearing messages: on (1 sec)")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (1 sec)")]) -- turn off, messages are not disappearing alice ##> "/set disappear #team off" @@ -1826,7 +1835,7 @@ testEnableTimedMessagesGroup = alice #> "#team hey" bob <# "#team alice> hey" threadDelay 1500000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Disappearing messages: on (1 sec)"), (1, "Disappearing messages: off"), (1, "hey")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Disappearing messages: on (1 sec)"), (1, "Disappearing messages: off"), (1, "hey")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (1 sec)"), (0, "Disappearing messages: off"), (0, "hey")]) -- test api alice ##> "/set disappear #team on 30s" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 433bf46036..3b0748e7d0 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -2,6 +2,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} module ChatTests.Utils where @@ -12,6 +13,8 @@ import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM import Control.Monad (unless, when) import Control.Monad.Except (runExceptT) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Char8 as B import Data.Char (isDigit) import Data.List (isPrefixOf, isSuffixOf) @@ -19,8 +22,10 @@ import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T import Database.SQLite.Simple (Only (..)) -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), InlineFilesConfig (..), defaultInlineFilesConfig) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) +import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText) import Simplex.Chat.Protocol +import Simplex.Chat.Store.Direct (getContact) import Simplex.Chat.Store.NoteFolders (createNoteFolder) import Simplex.Chat.Store.Profiles (getUserContactProfiles) import Simplex.Chat.Types @@ -28,11 +33,12 @@ import Simplex.Chat.Types.Preferences import Simplex.FileTransfer.Client.Main (xftpClientCLI) import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version import System.Directory (doesFileExist) import System.Environment (lookupEnv, withArgs) -import System.FilePath (()) import System.IO.Silently (capture_) import System.Info (os) import Test.Hspec hiding (it) @@ -77,15 +83,18 @@ ifCI xrun run d t = do ci <- runIO $ lookupEnv "CI" (if ci == Just "true" then xrun else run) d t +skip :: String -> SpecWith a -> SpecWith a +skip = before_ . pendingWith + versionTestMatrix2 :: (HasCallStack => TestCC -> TestCC -> IO ()) -> SpecWith FilePath versionTestMatrix2 runTest = do it "current" $ testChat2 aliceProfile bobProfile runTest it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile runTest it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev runTest it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg runTest - it "v1" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest - it "v1 to v2" $ runTestCfg2 testCfg testCfgV1 runTest - it "v2 to v1" $ runTestCfg2 testCfgV1 testCfg runTest + it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest + it "old to curr" $ runTestCfg2 testCfg testCfgV1 runTest + it "curr to old" $ runTestCfg2 testCfgV1 testCfg runTest versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath versionTestMatrix3 runTest = do @@ -96,29 +105,6 @@ versionTestMatrix3 runTest = do it "curr to prev" $ runTestCfg3 testCfgVPrev testCfg testCfg runTest it "curr+prev to prev" $ runTestCfg3 testCfgVPrev testCfg testCfgVPrev runTest -inlineCfg :: Integer -> ChatConfig -inlineCfg n = testCfg {inlineFiles = defaultInlineFilesConfig {sendChunks = 0, offerChunks = n, receiveChunks = n}} - -fileTestMatrix2 :: (HasCallStack => TestCC -> TestCC -> IO ()) -> SpecWith FilePath -fileTestMatrix2 runTest = do - it "via connection" $ runTestCfg2 viaConn viaConn runTest - it "inline (accepting)" $ runTestCfg2 inline inline runTest - it "via connection (inline offered)" $ runTestCfg2 inline viaConn runTest - it "via connection (inline supported)" $ runTestCfg2 viaConn inline runTest - where - inline = inlineCfg 100 - viaConn = inlineCfg 0 - -fileTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath -fileTestMatrix3 runTest = do - it "via connection" $ runTestCfg3 viaConn viaConn viaConn runTest - it "inline" $ runTestCfg3 inline inline inline runTest - it "via connection (inline offered)" $ runTestCfg3 inline viaConn viaConn runTest - it "via connection (inline supported)" $ runTestCfg3 viaConn inline inline runTest - where - inline = inlineCfg 100 - viaConn = inlineCfg 0 - runTestCfg2 :: ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () runTestCfg2 aliceCfg bobCfg runTest tmp = withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> @@ -132,6 +118,34 @@ runTestCfg3 aliceCfg bobCfg cathCfg runTest tmp = withNewTestChatCfg tmp cathCfg "cath" cathProfile $ \cath -> runTest alice bob cath +type PQEnabled = Bool + +pqMatrix2 :: (HasCallStack => (TestCC, PQEnabled) -> (TestCC, PQEnabled) -> IO ()) -> SpecWith FilePath +pqMatrix2 runTest = do + it "PQ: off, off" $ test False False + it "PQ: on, off" $ test False True + it "PQ: off, on" $ test True False + it "PQ: on, on" $ test True True + where + test aPQ bPQ = testChat2 aliceProfile bobProfile $ \a b -> runTest (a, aPQ) (b, bPQ) + +pqVersionTestMatrix2 :: (HasCallStack => TestCC -> TestCC -> Bool -> VersionChat -> IO ()) -> SpecWith FilePath +pqVersionTestMatrix2 runTest = do + it "current" $ testChat2 aliceProfile bobProfile (runTest' True pqEncryptionCompressionVersion) + it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile (runTest' False (VersionChat 6)) + it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev (runTest' False (VersionChat 6)) + it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg (runTest' False (VersionChat 6)) + it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile (runTest' False (VersionChat 1)) + it "old to curr" $ runTestCfg2 testCfg testCfgV1 (runTest' False (VersionChat 1)) + it "curr to old" $ runTestCfg2 testCfgV1 testCfg (runTest' False (VersionChat 1)) + it "next" $ testChatCfg2 testCfgVNext aliceProfile bobProfile (runTest' True pqEncryptionCompressionVersion) + it "next to curr" $ runTestCfg2 testCfg testCfgVNext (runTest' True pqEncryptionCompressionVersion) + it "curr to next" $ runTestCfg2 testCfgVNext testCfg (runTest' True pqEncryptionCompressionVersion) + it "next to prev" $ runTestCfg2 testCfgVPrev testCfgVNext (runTest' False (VersionChat 6)) + it "prev to next" $ runTestCfg2 testCfgVNext testCfgVPrev (runTest' False (VersionChat 6)) + where + runTest' pqExpected v a b = runTest a b pqExpected v + withTestChatGroup3Connected :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a withTestChatGroup3Connected tmp dbPrefix action = do withTestChat tmp dbPrefix $ \cc -> do @@ -190,6 +204,65 @@ cc #$> (cmd, f, res) = do cc ##> cmd (f <$> getTermLine cc) `shouldReturn` res +-- / PQ combinators + +(\#>) :: HasCallStack => (TestCC, String) -> TestCC -> IO () +(\#>) = sndRcv PQEncOff False + +(+#>) :: HasCallStack => (TestCC, String) -> TestCC -> IO () +(+#>) = sndRcv PQEncOn False + +(++#>) :: HasCallStack => (TestCC, String) -> TestCC -> IO () +(++#>) = sndRcv PQEncOn True + +sndRcv :: HasCallStack => PQEncryption -> Bool -> (TestCC, String) -> TestCC -> IO () +sndRcv pqEnc enabled (cc1, msg) cc2 = do + name1 <- userName cc1 + name2 <- userName cc2 + let cmd = "@" <> name2 <> " " <> msg + cc1 `send` cmd + when enabled $ cc1 <## (name2 <> ": quantum resistant end-to-end encryption enabled") + cc1 <# cmd + cc1 `pqSndForContact` 2 `shouldReturn` pqEnc + when enabled $ cc2 <## (name1 <> ": quantum resistant end-to-end encryption enabled") + cc2 <# (name1 <> "> " <> msg) + cc2 `pqRcvForContact` 2 `shouldReturn` pqEnc + +(\:#>) :: HasCallStack => (TestCC, String, VersionChat) -> (TestCC, VersionChat) -> IO () +(\:#>) = sndRcvImg PQEncOff False + +(+:#>) :: HasCallStack => (TestCC, String, VersionChat) -> (TestCC, VersionChat) -> IO () +(+:#>) = sndRcvImg PQEncOn False + +(++:#>) :: HasCallStack => (TestCC, String, VersionChat) -> (TestCC, VersionChat) -> IO () +(++:#>) = sndRcvImg PQEncOn True + +sndRcvImg :: HasCallStack => PQEncryption -> Bool -> (TestCC, String, VersionChat) -> (TestCC, VersionChat) -> IO () +sndRcvImg pqEnc enabled (cc1, msg, v1) (cc2, v2) = do + name1 <- userName cc1 + name2 <- userName cc2 + g <- C.newRandom + img <- atomically $ B64.encode <$> C.randomBytes lrgLen g + cc1 `send` ("/_send @2 json {\"msgContent\":{\"type\":\"image\",\"text\":\"" <> msg <> "\",\"image\":\"" <> B.unpack img <> "\"}}") + cc1 .<## "}}" + cc1 <### ([ConsoleString (name2 <> ": quantum resistant end-to-end encryption enabled") | enabled] <> [WithTime ("@" <> name2 <> " " <> msg)]) + cc1 `pqSndForContact` 2 `shouldReturn` pqEnc + cc1 `pqVerForContact` 2 `shouldReturn` v1 + cc2 <### ([ConsoleString (name1 <> ": quantum resistant end-to-end encryption enabled") | enabled] <> [WithTime (name1 <> "> " <> msg)]) + cc2 `pqRcvForContact` 2 `shouldReturn` pqEnc + cc2 `pqVerForContact` 2 `shouldReturn` v2 + where + lrgLen = maxEncodedMsgLength * 3 `div` 4 - 110 -- 98 is ~ max size for binary image preview given the rest of the message + +genProfileImg :: IO ByteString +genProfileImg = do + g <- C.newRandom + atomically $ B64.encode <$> C.randomBytes lrgLen g + where + lrgLen = maxEncodedInfoLength * 3 `div` 4 - 420 + +-- PQ combinators / + chat :: String -> [(Int, String)] chat = map (\(a, _, _) -> a) . chat'' @@ -213,13 +286,20 @@ chatFeaturesF = map (\(a, _, c) -> (a, c)) chatFeatures'' chatFeatures'' :: [((Int, String), Maybe (Int, String), Maybe String)] chatFeatures'' = - [ ((0, "Disappearing messages: allowed"), Nothing, Nothing), + [ ((0, e2eeInfoNoPQStr), Nothing, Nothing), + ((0, "Disappearing messages: allowed"), Nothing, Nothing), ((0, "Full deletion: off"), Nothing, Nothing), ((0, "Message reactions: enabled"), Nothing, Nothing), ((0, "Voice messages: enabled"), Nothing, Nothing), ((0, "Audio/video calls: enabled"), Nothing, Nothing) ] +e2eeInfoNoPQStr :: String +e2eeInfoNoPQStr = T.unpack e2eInfoNoPQText + +e2eeInfoPQStr :: String +e2eeInfoPQStr = T.unpack e2eInfoPQText + lastChatFeature :: String lastChatFeature = snd $ last chatFeatures @@ -228,7 +308,8 @@ groupFeatures = map (\(a, _, _) -> a) groupFeatures'' groupFeatures'' :: [((Int, String), Maybe (Int, String), Maybe String)] groupFeatures'' = - [ ((0, "Disappearing messages: off"), Nothing, Nothing), + [ ((0, e2eeInfoNoPQStr), Nothing, Nothing), + ((0, "Disappearing messages: off"), Nothing, Nothing), ((0, "Direct messages: on"), Nothing, Nothing), ((0, "Full deletion: off"), Nothing, Nothing), ((0, "Message reactions: on"), Nothing, Nothing), @@ -489,6 +570,34 @@ getProfilePictureByName cc displayName = maybeFirstRow fromOnly $ DB.query db "SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1" (Only displayName) +pqSndForContact :: TestCC -> ContactId -> IO PQEncryption +pqSndForContact = pqForContact_ pqSndEnabled PQEncOff + +pqRcvForContact :: TestCC -> ContactId -> IO PQEncryption +pqRcvForContact = pqForContact_ pqRcvEnabled PQEncOff + +pqForContact :: TestCC -> ContactId -> IO PQEncryption +pqForContact = pqForContact_ (Just . connPQEnabled) (error "impossible") + +pqSupportForCt :: TestCC -> ContactId -> IO PQSupport +pqSupportForCt = pqForContact_ (\Connection {pqSupport} -> Just pqSupport) PQSupportOff + +pqVerForContact :: TestCC -> ContactId -> IO VersionChat +pqVerForContact = pqForContact_ (Just . connChatVersion) (error "impossible") + +pqForContact_ :: (Connection -> Maybe a) -> a -> TestCC -> ContactId -> IO a +pqForContact_ pqSel def cc contactId = (fromMaybe def . pqSel) <$> getCtConn cc contactId + +getCtConn :: TestCC -> ContactId -> IO Connection +getCtConn cc contactId = getTestCCContact cc contactId >>= maybe (fail "no connection") pure . contactConn + +getTestCCContact :: TestCC -> ContactId -> IO Contact +getTestCCContact cc contactId = do + let TestCC {chatController = ChatController {config = ChatConfig {chatVRange = vr}}} = cc + withCCTransaction cc $ \db -> + withCCUser cc $ \user -> + runExceptT (getContact db vr user contactId) >>= either (fail . show) pure + lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do cc ##> "/last_item_id" @@ -595,25 +704,11 @@ checkActionDeletesFile file action = do fileExistsAfter <- doesFileExist file fileExistsAfter `shouldBe` False -startFileTransferWithDest' :: HasCallStack => TestCC -> TestCC -> String -> String -> Maybe String -> IO () -startFileTransferWithDest' cc1 cc2 fileName fileSize fileDest_ = do - name1 <- userName cc1 - name2 <- userName cc2 - cc1 #> ("/f @" <> name2 <> " ./tests/fixtures/" <> fileName) - cc1 <## "use /fc 1 to cancel sending" - cc2 <# (name1 <> "> sends file " <> fileName <> " (" <> fileSize <> ")") - cc2 <## "use /fr 1 [/ | ] to receive it" - cc2 ##> ("/fr 1" <> maybe "" (" " <>) fileDest_) - cc2 <## ("saving file 1 from " <> name1 <> " to " <> maybe id () fileDest_ fileName) - concurrently_ - (cc2 <## ("started receiving file 1 (" <> fileName <> ") from " <> name1)) - (cc1 <## ("started sending file 1 (" <> fileName <> ") to " <> name2)) - currentChatVRangeInfo :: String currentChatVRangeInfo = - "peer chat protocol version range: " <> vRangeStr supportedChatVRange + "peer chat protocol version range: " <> vRangeStr (supportedChatVRange PQSupportOff) -vRangeStr :: VersionRange -> String +vRangeStr :: VersionRange v -> String vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxVer <> ")" linkAnotherSchema :: String -> String diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 1cd2aa2c47..d2d15dc166 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -153,13 +153,13 @@ textWithUri = describe "text with Uri" do parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat" parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat" it "SimpleX links" do - let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" + let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv) parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv) - let ct = "/contact#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D" + let ct = "/contact#/?v=2&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D" parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) - let gr = "/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" + let gr = "/contact#/?v=2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) email :: Text -> Markdown diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 1a9d968718..54a0ae4f1c 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -17,7 +17,7 @@ import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Messages.Batch import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) import Simplex.Chat.Messages (SndMessage (..)) -import Simplex.Chat.Protocol (SharedMsgId (..), maxChatMsgSize) +import Simplex.Chat.Protocol (SharedMsgId (..), maxEncodedMsgLength) import Test.Hspec batchingTests :: Spec @@ -99,7 +99,7 @@ testImageFitsSingleBatch = do msg s = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = s} batched = "[" <> xMsgNewStr <> "," <> descrStr <> "]" - runBatcherTest' maxChatMsgSize [msg xMsgNewStr, msg descrStr] [] [batched] + runBatcherTest' maxEncodedMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] runBatcherTest :: Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec runBatcherTest maxLen msgs expectedErrors expectedBatches = diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 783f7fb344..082af825e5 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} module ProtocolTests where @@ -15,6 +16,7 @@ import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet import Simplex.Messaging.Protocol (supportedSMPClientVRange) +import Simplex.Messaging.ServiceScheme import Simplex.Messaging.Version import Test.Hspec @@ -37,8 +39,8 @@ queue = connReqData :: ConnReqUriData connReqData = ConnReqUriData - { crScheme = CRSSimplex, - crAgentVRange = mkVersionRange 1 1, + { crScheme = SSSimplex, + crAgentVRange = mkVersionRange (VersionSMPA 1) (VersionSMPA 1), crSmpQueues = [queue], crClientData = Nothing } @@ -46,8 +48,8 @@ connReqData = testDhPubKey :: C.PublicKeyX448 testDhPubKey = "MEIwBQYDK2VvAzkAmKuSYeQ/m0SixPDS8Wq8VBaTS1cW+Lp0n0h4Diu+kUpR+qXx4SDJ32YGEFoGFGSbGPry5Ychr6U=" -testE2ERatchetParams :: E2ERatchetParamsUri 'C.X448 -testE2ERatchetParams = E2ERatchetParamsUri supportedE2EEncryptVRange testDhPubKey testDhPubKey +testE2ERatchetParams :: RcvE2ERatchetParamsUri 'C.X448 +testE2ERatchetParams = E2ERatchetParamsUri (supportedE2EEncryptVRange PQSupportOn) testDhPubKey testDhPubKey Nothing testConnReq :: ConnectionRequestUri 'CMInvitation testConnReq = CRInvitationUri connReqData testE2ERatchetParams @@ -70,12 +72,12 @@ s ==## msg = do (##==) :: MsgEncodingI e => ByteString -> ChatMessage e -> Expectation s ##== msg = do - let r = encodeChatMessage msg + let r = encodeChatMessage maxEncodedMsgLength msg case r of ECMEncoded encodedBody -> J.eitherDecodeStrict' encodedBody `shouldBe` (J.eitherDecodeStrict' s :: Either String J.Value) - ECMLarge -> expectationFailure $ "large message" + ECMLarge -> expectationFailure "large message" (##==##) :: MsgEncodingI e => ByteString -> ChatMessage e -> Expectation s ##==## msg = do @@ -130,7 +132,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ "{\"v\":\"1-7\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) + ##==## ChatMessage (supportedChatVRange PQSupportOff) (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" ##==## ChatMessage @@ -191,7 +193,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" @@ -200,7 +202,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" @@ -227,11 +229,11 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" - #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing} + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing, groupSize = Nothing} it "x.grp.inv with group link id" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" - #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"} + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} it "x.grp.acpt without incognito profile" $ "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") @@ -240,28 +242,28 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} + #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQSupportOff, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing + #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQSupportOff, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked}) it "x.grp.mem.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.inv w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQSupportOff, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile @@ -281,10 +283,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.grp.direct.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XGrpDirectInv testConnReq (Just $ MCText "hello") it "x.grp.direct.inv without content" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XGrpDirectInv testConnReq Nothing -- it "x.grp.msg.forward" -- $ "{\"v\":\"1\",\"event\":\"x.grp.msg.forward\",\"params\":{\"msgForward\":{\"memberId\":\"AQIDBA==\",\"msg\":\"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}\",\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}}" diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 25c3514e4a..ac6fa7b23a 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -13,7 +13,7 @@ import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB import qualified Data.Map.Strict as M import Simplex.Chat.Archive (archiveFilesFolder) -import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..), versionNumber) +import Simplex.Chat.Controller (versionNumber) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File import Simplex.Chat.Remote.Types @@ -194,7 +194,7 @@ remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob remoteStoreFileTest :: HasCallStack => FilePath -> IO () remoteStoreFileTest = - testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> + testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) @@ -317,15 +317,13 @@ remoteStoreFileTest = stopMobile mobile desktop where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"} hostError cc err = do r <- getTermLine cc r `shouldStartWith` "remote host 1 error" r `shouldContain` err remoteCLIFileTest :: HasCallStack => FilePath -> IO () -remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do - createDirectoryIfMissing True "./tests/tmp/tmp/" +remoteCLIFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) mobile <## "ok" @@ -392,8 +390,6 @@ remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile B.readFile (bobFiles "test.jpg") `shouldReturn` src' stopMobile mobile desktop - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"} switchRemoteHostTest :: FilePath -> IO () switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do diff --git a/website/langs/fr.json b/website/langs/fr.json index 304630a091..2cdf1b9ae9 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -105,7 +105,7 @@ "simplex-network-overlay-card-1-li-3": "Le P2P ne résout pas l'attaque MITM et la plupart des implémentations existantes n'utilisent pas de messages hors bande pour l'échange de clé initial. SimpleX utilise des messages hors bande ou, dans certains cas, des connexions sécurisées et approuvées préexistantes pour l'échange de clé initial .", "simplex-network-overlay-card-1-li-4": "Les réseaux P2P peuvent être bloquées par certains fournisseurs Internet (comme BitTorrent). SimpleX est indépendant du transport - il peut fonctionner sur des protocoles Web standard, par exemple WebSockets.", "simplex-network-overlay-card-1-li-5": "Tous les réseaux P2P connus sont susceptibles d'être vulnérables à une attaque Sybil, car chaque nœud peut être découvert et le réseau fonctionne comme un tout. Les mesures connues pour réduire la probabilité d'une attaque Sybil nécessitent soit un composant centralisé, soit des preuves de travail coûteuses. Le réseau SimpleX ne permet pas de découvrir les serveurs, il est fragmenté et fonctionne comme de multiples sous-réseaux isolées, ce qui rend impossible les attaques à l'échelle du réseau.", - "simplex-network-overlay-card-1-li-6": "Les réseaux P2P sont susceptibles d'être vulnérables aux attaques DRDoS, lorsque les clients peuvent rediffuser et amplifier le trafic, entraînant un déni de service à l'échelle du réseau. Les clients SimpleX relaient uniquement le trafic à partir d'une connexion connue et ne peuvent pas être utilisés par un attaquant pour amplifier le trafic sur l'ensemble du réseau.", + "simplex-network-overlay-card-1-li-6": "Les réseaux P2P sont susceptibles d'être vulnérables aux attaques DRDoS, lorsque les clients peuvent rediffuser et amplifier le trafic, entraînant un déni de service à l'échelle du réseau. Les clients SimpleX relaient uniquement le trafic à partir d'une connexion connue et ne peuvent pas être utilisés par un attaquant pour amplifier le trafic sur l'ensemble du réseau.", "privacy-matters-overlay-card-1-p-1": "De nombreuses grandes entreprises utilisent les informations sur les personnes avec lesquelles vous êtes connecté pour estimer vos revenus, vous vendre des produits dont vous n'avez pas vraiment besoin et déterminer les prix.", "privacy-matters-overlay-card-1-p-2": "Les vendeurs en ligne savent que les personnes à faible revenu sont plus susceptibles d'effectuer des achats urgents. Ils peuvent donc pratiquer des prix plus élevés ou supprimer des remises.", "privacy-matters-overlay-card-1-p-3": "Certaines sociétés financières et d'assurance utilisent des graphiques sociaux pour déterminer les taux d'intérêt et les primes. Cela fait souvent payer plus les personnes à faible revenu - c'est connu sous le nom de 'prime à la pauvreté'.", diff --git a/website/langs/ja.json b/website/langs/ja.json index 967d6c27f3..2b6d657608 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -93,7 +93,7 @@ "docs-dropdown-1": "SimpleXプラットフォーム", "hero-overlay-card-1-p-5": "クライアント デバイスのみがユーザー プロファイル、連絡先、およびグループを保存します。 メッセージは 2 レイヤーのエンドツーエンド暗号化を使用して送信されます。", "simplex-chat-for-the-terminal": "ターミナル用 SimpleX チャット", - "simplex-network-overlay-card-1-li-3": "P2P は MITM 攻撃 問題を解決せず、既存の実装のほとんどは最初の鍵交換に帯域外メッセージを使用していません 。 SimpleX は、最初のキー交換に帯域外メッセージを使用するか、場合によっては既存の安全で信頼できる接続を使用します。", + "simplex-network-overlay-card-1-li-3": "P2P は MITM 攻撃 問題を解決せず、既存の実装のほとんどは最初の鍵交換に帯域外メッセージを使用していません 。 SimpleX は、最初のキー交換に帯域外メッセージを使用するか、場合によっては既存の安全で信頼できる接続を使用します。", "the-instructions--source-code": "ソース コードからダウンロードまたはコンパイルする方法を説明します。", "simplex-network-section-desc": "Simplex Chat は、P2P とフェデレーション ネットワークの利点を組み合わせて最高のプライバシーを提供します。", "privacy-matters-section-subheader": "メタデータのプライバシーを保護する — 話す相手 — 以下のことからあなたを守ります:", @@ -150,7 +150,7 @@ "privacy-matters-2-overlay-1-title": "プライバシーはあなたに力を与えます", "simplex-unique-overlay-card-2-p-2": "オプションのユーザー アドレスを使用しても、スパムの連絡先リクエストの送信に使用される可能性がありますが、接続を失うことなく変更または完全に削除できます。", "simplex-unique-4-overlay-1-title": "完全に分散化されています — ユーザーは SimpleX ネットワークを所有します", - "simplex-network-overlay-card-1-li-5": "すべての既知の P2P ネットワークは、各ノードが検出可能であり、ネットワーク全体が動作するため、Sybil 攻撃に対して脆弱である可能性があります。 この問題を軽減する既知の対策には、一元化されたコンポーネントか、高価な作業証明が必要です。 SimpleX ネットワークにはサーバーの検出機能がなく、断片化されており、複数の分離されたサブネットワークとして動作するため、ネットワーク全体への攻撃は不可能です。", + "simplex-network-overlay-card-1-li-5": "すべての既知の P2P ネットワークは、各ノードが検出可能であり、ネットワーク全体が動作するため、Sybil 攻撃に対して脆弱である可能性があります。 この問題を軽減する既知の対策には、一元化されたコンポーネントか、高価な作業証明が必要です。 SimpleX ネットワークにはサーバーの検出機能がなく、断片化されており、複数の分離されたサブネットワークとして動作するため、ネットワーク全体への攻撃は不可能です。", "simplex-private-2-title": "追加レイヤーの
サーバー暗号化", "hero-overlay-card-1-p-4": "この設計により、ユーザーの情報の漏洩が防止されます' アプリケーションレベルのメタデータ。 プライバシーをさらに向上させ、IP アドレスを保護するために、Tor 経由でメッセージング サーバーに接続できます。", "f-droid-org-repo": "F-Droid.org リポジトリ", diff --git a/website/src/_data/glossary.json b/website/src/_data/glossary.json index fd420ccaa6..3420ba3700 100644 --- a/website/src/_data/glossary.json +++ b/website/src/_data/glossary.json @@ -67,6 +67,10 @@ "term": "Message padding", "definition": "Message padding" }, + { + "term": "Non-repudiation", + "definition": "Non-repudiation" + }, { "term": "Onion routing", "definition": "Onion routing" @@ -103,6 +107,10 @@ "term": "Recovery from compromise", "definition": "Post-compromise security" }, + { + "term": "Repudiation", + "definition": "Repudiation" + }, { "term": "User identity", "definition": "User identity" diff --git a/website/src/_includes/blog_previews/20221206.html b/website/src/_includes/blog_previews/20221206.html index 0b54f5c32a..55530cd301 100644 --- a/website/src/_includes/blog_previews/20221206.html +++ b/website/src/_includes/blog_previews/20221206.html @@ -3,7 +3,7 @@

Privacy Guides recommendations.

Review by Mike Kuketz.

The messenger matrix.

-

Supernova review and messenger ratings.

+

Supernova review and messenger ratings.

v4.3 is released:

diff --git a/website/src/finneyforum.html b/website/src/finneyforum.html new file mode 100644 index 0000000000..06229e4b5d --- /dev/null +++ b/website/src/finneyforum.html @@ -0,0 +1,8 @@ +--- +layout: layouts/group_link.html +title: "SimpleX Chat - Finney Forum group" +description: "Join the group of attendees of Finney Forum 2024" +groupLink: "https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FTlom_0qzRaEWo_4cweE_hzj6KBmqXC8R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAZzyx3sm1tpGsYjXAOR2LxXD0ty1hlAR7Hg0fbCxEoig%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22IfdftVGf9odVOQImmz1I9A%3D%3D%22%7D" +groupLinkText: Open Finney Forum group link +templateEngineOverride: njk +---