Compare commits

..

1 Commits

Author SHA1 Message Date
Evgeny Poberezkin
41d7a47b37 core: api to load all chat items 2022-11-20 21:38:42 +00:00
361 changed files with 12201 additions and 82340 deletions

View File

@@ -5,7 +5,7 @@ on:
branches:
- master
- stable
- users
- sqlcipher
tags:
- "v*"
pull_request:
@@ -109,7 +109,7 @@ jobs:
- name: Unix test
if: matrix.os != 'windows-latest' && matrix.os != 'ubuntu-20.04'
timeout-minutes: 20
timeout-minutes: 10
shell: bash
run: cabal test --test-show-details=direct

1
.gitignore vendored
View File

@@ -42,7 +42,6 @@ stack.yaml.lock
# Temporary test files
tests/tmp
tests/tmp*
logs/

View File

@@ -1,7 +1,7 @@
FROM ubuntu:focal AS build
# 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
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev
# Install ghcup
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
@@ -21,9 +21,6 @@ WORKDIR /project
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Adjust build
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
RUN cabal install

View File

@@ -5,8 +5,8 @@
[![build](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml/badge.svg?branch=stable)](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
[![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases)
[![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases)
[![Follow on Twitter](https://img.shields.io/twitter/follow/SimpleXChat?style=social)](https://twitter.com/SimpleXChat)
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat)
[![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@simplex)
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
&nbsp;
@@ -43,7 +43,6 @@
- [For developers](#for-developers)
- [Roadmap](#roadmap)
- [Join a user group](#join-a-user-group)
- [Translate the apps](#translate-the-apps)
- [Contribute](#contribute)
- [Help us with donations](#help-us-with-donations)
- [Disclaimers, Security contact, License](#disclaimers)
@@ -86,15 +85,15 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent updates:
[Jan 03, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
[Dec 06, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md)
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md).
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
[All updates](./blog)
@@ -104,7 +103,7 @@ You need to share a link or scan a QR code (in person or during a video call) to
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/conversation.png" alt="Make a private connection" width="594" height="360">
## :zap: Quick installation of a terminal app
@@ -151,7 +150,7 @@ What is already implemented:
We plan to add soon:
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
@@ -188,26 +187,21 @@ If you are considering developing with SimpleX platform please get in touch for
- ✅ Chat database encryption.
- ✅ Automatic chat history deletion.
- ✅ Links to join groups and improve groups stability.
- ✅ Voice messages (with recipient opt-out per contact).
- ✅ Basic authentication for SMP servers (to authorize creating new queues).
- ✅ View deleted messages, full message deletion by sender (with recipient opt-in per contact).
- ✅ Block screenshots and view in recent apps.
- ✅ Advanced server configuration.
- ✅ Disappearing messages (with recipient opt-in per-contact).
- ✅ "Live" messages.
- ✅ Contact verification via a separate out-of-band channel.
- 🏗 Multiple user profiles in the same chat database.
- 🏗 Optionally avoid re-using the same TCP session for multiple connections.
- 🏗 File server to optimize for efficient and private sending of large files.
- 🏗 SMP queue redundancy and rotation (manual is supported).
- 🏗 Reduced battery and traffic usage in large groups.
- 🏗 Preserve message drafts.
- 🏗 Support older Android OS and 32-bit CPUs.
- Ephemeral/disappearing/OTR conversations with the existing contacts.
- 🏗 Voice messages (with recipient opt-out per contact).
- 🏗 Basic authentication for SMP servers (to authorize creating new queues).
- View deleted messages, full message deletion by sender (with recipient opt-in per contact).
- Block screenshots and view in recent apps.
- Optionally avoid re-using the same TCP session for multiple connections.
- Access password/pin (with optional alternative access password).
- Ephemeral/disappearing/OTR conversations with the existing contacts.
- Media server to optimize sending large files to groups.
- Video messages.
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
- Multiple user profiles in the same chat database.
- Advanced server configuration.
- Feeds/broadcasts.
- Unconfirmed: disappearing messages (with recipient opt-in per-contact).
- Web widgets for custom interactivity in the chats.
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
- Supporting the same profile on multiple devices.
@@ -219,44 +213,24 @@ If you are considering developing with SimpleX platform please get in touch for
## Join a user group
You can join a general English-speaking group: [#SimpleX-Group](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FcIS0gu1h0Y8pZpQkDaSz7HZGSHcKpMB9%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAKzzWAJYrVt1zdgRp4pD3FBst6eK7233DJeNElENLJRA%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%228mazMhefXoM5HxWBfZnvwQ%3D%3D%22%7D). Just bear in mind that it has ~300 members now, and that it is fully decentralized, so sending a message and connecting to all members in this group will take some time, only join it if you:
- want to see how larger groups work.
- traffic is not a concern (sending each message is ~5mb).
You can join a general group with more than 100 members: [#SimpleX-Group](https://simplex.chat/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).
You can also join a new and smaller English-speaking group if you want to ask questions without too much traffic: [#SimpleX-Group-2](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FQP8zaGjjmlXV-ix_Er4JgJ0lNPYGS1KX%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEApAgBkRZ3x12ayZ7sHrjHQWNMvqzZpWUgM_fFCUdLXwo%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xWpPXEZZsQp_F7vwAcAYDw%3D%3D%22%7D)
There are also several groups in languages other than English, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users. We do not always answer questions there, so please ask them in one of the English-speaking groups.
- [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking).
- [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking).
- [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking).
- [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
You can also join smaller groups by countries/languages: [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FmIorjTDPG24jdLKXwutS6o9hdQQRZwfQ%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA9N0BZaECrAw3we3S1Wq4QO7NERBuPt9447immrB50wo%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22S8aISlOgkTMytSox9gAM2Q%3D%3D%22%7D) (German), [\#SimpleX-US](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FlTWmQplLEaoJyHnEL1-B3f2PtDsikcTs%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-hMBlsQjNxK2vaVhqW_UyAVtuoYqgYTigK4B9dJ9CGc%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22G0UtRHIn0TmPoo08h_cbTA%3D%3D%22%7D) (US/English), [\#SimpleX-France](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F11r6XyjwVMj0WDIUMbmNDXO996M_EN_1%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAXDmc2Lrj9WQOjEcWa0DeQHF3HcYOp9b68s8M_BJ7gEk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22EZCeSYpeIBkaQwCcpcF00w%3D%3D%22%7D), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FZSYM278L5WoZiApx3925EAjSXcsAVNVu%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA7RJ2wfT8zdfOLyE5OtWLEAPowj-q6F2HB0ExbATw8Gk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22fsVoklNGptt7n-droqJYUQ%3D%3D%22%7D) (Russian), [#SimpleX-NL](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmP0LbswSbfxoVkkxiWE2NYnBCgZ9Snvj%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAVwZuSsw4Mf52EaBNdNI3RebsLm0jg65ZIkcmH9E5uy8%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22M9xIULUNZx51Wsa5Kdb0Sg%3D%3D%22%7D) (Netherlands/Dutch), [#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaZ_wjh6QAYHB-LjyGtp8bllkzoq880u-%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-_Wulzc3j16i7t77XJ5wgwxeW8_Ea8GxetMo7K4MgjI%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22QWmXdrFzIeMd2OoEPMFkBQ%3D%3D%22%7D) (Italian).
You can join these groups either by opening these links in the app or by opening them in a desktop browser and scanning QR code.
Let us know if you'd like to add some other countries to the list.
Join via the app to share what's going on and ask any questions!
## Translate the apps
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps are translated to many other languages. Join our translators to help SimpleX grow faster!
Current interface languages:
English (development language)
German: [@mlanp](https://github.com/mlanp)
French: link to be added
Italian: [@unbranched](https://github.com/unbranched)
Russian: project team
Languages in progress: Chinese, Hindi, Japanese, Dutch and [many others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed please suggest new languages and get in touch with us!
## Contribute
We would love to have you join the development! You can contribute to SimpleX Chat with:
- translate website homepage - there is a lot of content we would like to share, it would help to bring the new users.
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- developing features - please connect to us via chat so we can help you get started.
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- translate UI to some language - we are currently setting up the UI to simplify it, please get in touch and let us know if you would be able to support and update the translations.
- translate website homepage - there is a lot of content we would like to share, it would help to bring the new users.
## Help us with donations
@@ -272,11 +246,8 @@ It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- please let us know, via GitHub issue or chat, if you want to create a donation in some other cryptocurrency - we will add the address to the list.
Thank you,

View File

@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 97
versionName "4.5-beta.3"
versionCode 69
versionName "4.3-beta.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {

View File

@@ -24,8 +24,6 @@
<application
android:name="SimplexApp"
android:allowBackup="true"
android:fullBackupOnly="true"
android:backupAgent="BackupAgent"
android:icon="@mipmap/icon"
android:label="${app_name}"
android:extractNativeLibs="${extract_native_libs}"
@@ -104,9 +102,7 @@
<activity android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true"
android:exported="false"
android:launchMode="singleTask"/>
android:showOnLockScreen="true"/>
<provider
android:name="androidx.core.content.FileProvider"

View File

@@ -22,7 +22,6 @@ var TransformOperation;
TransformOperation["Decrypt"] = "decrypt";
})(TransformOperation || (TransformOperation = {}));
let activeCall;
let answerTimeout = 30000;
const processCommand = (function () {
const defaultIceServers = [
{ urls: ["stun:stun.simplex.im:443"] },
@@ -101,16 +100,9 @@ const processCommand = (function () {
const iceCandidates = getIceCandidates(pc, config);
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker };
await setupMediaStreams(call);
let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
pc.addEventListener("connectionstatechange", connectionStateChange);
return call;
async function connectionStateChange() {
// "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView)
// See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70)
if (pc.connectionState !== "failed")
connectionHandler();
}
async function connectionHandler() {
sendMessageToNative({
resp: {
type: "connection",
@@ -123,7 +115,6 @@ const processCommand = (function () {
},
});
if (pc.connectionState == "disconnected" || pc.connectionState == "failed") {
clearConnectionTimeout();
pc.removeEventListener("connectionstatechange", connectionStateChange);
if (activeCall) {
setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
@@ -131,7 +122,6 @@ const processCommand = (function () {
endCall();
}
else if (pc.connectionState == "connected") {
clearConnectionTimeout();
const stats = (await pc.getStats());
for (const stat of stats.values()) {
const { type, state } = stat;
@@ -151,12 +141,6 @@ const processCommand = (function () {
}
}
}
function clearConnectionTimeout() {
if (connectionTimeout) {
clearTimeout(connectionTimeout);
connectionTimeout = undefined;
}
}
}
function serialize(x) {
return LZString.compressToBase64(JSON.stringify(x));

View File

@@ -29,7 +29,6 @@ extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
@@ -77,11 +76,3 @@ Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclas
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}

View File

@@ -1,18 +0,0 @@
package chat.simplex.app
import android.app.backup.BackupAgentHelper
import android.app.backup.FullBackupDataOutput
import android.content.Context
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
class BackupAgent: BackupAgentHelper() {
override fun onFullBackup(data: FullBackupDataOutput?) {
if (applicationContext
.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
) {
super.onFullBackup(data)
}
}
}

View File

@@ -3,12 +3,10 @@ package chat.simplex.app
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.os.SystemClock.elapsedRealtime
import android.util.Log
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.*
@@ -29,7 +27,6 @@ import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.Companion.getUserIdFromIntent
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.SplashView
@@ -43,6 +40,7 @@ import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class MainActivity: FragmentActivity() {
@@ -75,13 +73,6 @@ class MainActivity: FragmentActivity() {
processIntent(intent, m)
processExternalIntent(intent, m)
}
if (m.controller.appPrefs.privacyProtectScreen.get()) {
Log.d(TAG, "onCreate: set FLAG_SECURE")
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
}
setContent {
SimpleXTheme {
Surface(
@@ -134,15 +125,7 @@ class MainActivity: FragmentActivity() {
}
override fun onBackPressed() {
if (
onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
|| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
|| isTaskRoot // there are still other tasks after we reach the main (home) activity
) {
// https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06
super.onBackPressed()
}
super.onBackPressed()
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
clearAuthState()
@@ -374,6 +357,22 @@ fun MainPage(
.collect {
if (it != null) currentChatId = it
else onComposed()
// Deletes files that were not sent but already stored in files directory.
// Currently, it's voice records only
if (it == null && chatModel.filesToDelete.isNotEmpty()) {
chatModel.filesToDelete.forEach { it.delete() }
chatModel.filesToDelete.clear()
}
}
}
launch {
snapshotFlow { chatModel.sharedContent.value }
.distinctUntilChanged()
.filter { it != null }
.collect {
chatModel.chatId.value = null
currentChatId = null
}
}
}
@@ -387,8 +386,7 @@ fun MainPage(
}
}
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
onboarding == OnboardingStage.Step3_SetNotificationsMode -> SetNotificationsMode(chatModel)
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
}
ModalManager.shared.showInView()
val invitation = chatModel.activeCallInvitation.value
@@ -398,31 +396,20 @@ fun MainPage(
}
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
val userId = getUserIdFromIntent(intent)
when (intent?.action) {
NtfManager.OpenChatAction -> {
val chatId = intent.getStringExtra("chatId")
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
if (chatId != null) {
withBGApi {
if (userId != null && userId != chatModel.currentUser.value?.userId) {
chatModel.controller.changeActiveUser(userId)
}
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
if (cInfo != null) openChat(cInfo, chatModel)
}
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
if (cInfo != null) withApi { openChat(cInfo, chatModel) }
}
}
NtfManager.ShowChatsAction -> {
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
withBGApi {
if (userId != null && userId != chatModel.currentUser.value?.userId) {
chatModel.controller.changeActiveUser(userId)
}
chatModel.chatId.value = null
chatModel.clearOverlays.value = true
}
chatModel.chatId.value = null
chatModel.clearOverlays.value = true
}
NtfManager.AcceptCallAction -> {
val chatId = intent.getStringExtra("chatId")
@@ -483,6 +470,7 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
// TODO open from chat list view
chatModel.appOpenUrl.value = uri
} else {
withUriAction(uri) { linkType ->

View File

@@ -11,7 +11,8 @@ import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import java.io.*
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
@@ -31,15 +32,12 @@ external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
external fun chatRecvMsg(ctrl: ChatCtrl): String
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
external fun chatParseServer(str: String): String
class SimplexApp: Application(), LifecycleEventObserver {
lateinit var chatController: ChatController
var isAppOnForeground: Boolean = false
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey)
val res: DBMigrationResult = kotlin.runCatching {
@@ -90,7 +88,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
context = this
initChatController()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
@@ -98,7 +95,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
withApi {
when (event) {
Lifecycle.Event.ON_START -> {
isAppOnForeground = true
if (chatModel.chatRunning.value == true) {
kotlin.runCatching {
val chats = chatController.apiGetChats()
@@ -107,7 +103,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
}
Lifecycle.Event.ON_RESUME -> {
isAppOnForeground = true
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
chatController.showBackgroundServiceNoticeIfNeeded()
}
@@ -116,14 +111,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
* It can happen when app was started and a user enables battery optimization while app in background
* */
if (chatModel.chatRunning.value != false &&
chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
) {
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
SimplexService.start(applicationContext)
}
}
else -> isAppOnForeground = false
else -> {}
}
}
}
@@ -177,18 +168,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d(TAG, "starting server")
var server: LocalServerSocket? = null
for (i in 0..100) {
try {
server = LocalServerSocket(socketName + i)
break
} catch (e: IOException) {
Log.e(TAG, e.stackTraceToString())
}
}
if (server == null) {
throw Error("Unable to setup local server socket. Contact developers")
}
val server = LocalServerSocket(socketName)
Log.d(TAG, "started server")
s.release()
val receiver = server.accept()

View File

@@ -3,11 +3,9 @@ package chat.simplex.app.model
import android.app.*
import android.content.*
import android.graphics.BitmapFactory
import android.hardware.display.DisplayManager
import android.media.AudioAttributes
import android.net.Uri
import android.util.Log
import android.view.Display
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
@@ -25,18 +23,12 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
// DO NOT change notification channel settings / names
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1"
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
const val CallNotificationId: Int = -1
private const val UserIdKey: String = "userId"
private const val ChatIdKey: String = "chatId"
fun getUserIdFromIntent(intent: Intent?): Long? {
val userId = intent?.getLongExtra(UserIdKey, -1L)
return if (userId == -1L || userId == null) null else userId
}
}
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -45,29 +37,24 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
init {
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
// Remove old channels since they can't be edited
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(callNotificationChannel())
}
enum class NotificationAction {
ACCEPT_CONTACT_REQUEST
}
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
private fun callNotificationChannel(): NotificationChannel {
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
val attrs = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.build()
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
Log.d(TAG,"callNotificationChannel sound: $soundUri")
callChannel.setSound(soundUri, attrs)
callChannel.enableVibration(true)
// the numbers below are explained here: https://developer.android.com/reference/android/os/Vibrator
// (wait, vibration duration, wait till off, wait till on again = ringtone mp3 duration - vibration duration - ~50ms lost somewhere)
callChannel.vibrationPattern = longArrayOf(250, 250, 0, 2600)
return callChannel
}
@@ -83,9 +70,8 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
}
}
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
fun notifyContactRequestReceived(cInfo: ChatInfo.ContactRequest) {
notifyMessageReceived(
user = user,
chatId = cInfo.id,
displayName = cInfo.displayName,
msgText = generalGetString(R.string.notification_new_contact_request),
@@ -94,22 +80,21 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
)
}
fun notifyContactConnected(user: User, contact: Contact) {
fun notifyContactConnected(contact: Contact) {
notifyMessageReceived(
user = user,
chatId = contact.id,
displayName = contact.displayName,
msgText = generalGetString(R.string.notification_contact_connected)
)
}
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
if (!cInfo.ntfsEnabled) return
notifyMessageReceived(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
}
fun notifyMessageReceived(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
fun notifyMessageReceived(chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
Log.d(TAG, "notifyMessageReceived $chatId")
val now = Clock.System.now().toEpochMilliseconds()
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
@@ -134,14 +119,13 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
.setColor(0x88FFFF)
.setAutoCancel(true)
.setVibrate(if (actions.isEmpty()) null else longArrayOf(0, 250, 250, 250))
.setContentIntent(chatPendingIntent(OpenChatAction, user.userId, chatId))
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
.setSilent(if (actions.isEmpty()) recentNotification else false)
for (action in actions) {
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val actionIntent = Intent(SimplexApp.context, NtfActionReceiver::class.java)
actionIntent.action = action.name
actionIntent.putExtra(UserIdKey, user.userId)
actionIntent.putExtra(ChatIdKey, chatId)
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
val actionButton = when (action) {
@@ -156,7 +140,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setGroupSummary(true)
.setContentIntent(chatPendingIntent(ShowChatsAction, user.userId))
.setContentIntent(chatPendingIntent(ShowChatsAction))
.build()
with(NotificationManagerCompat.from(context)) {
@@ -167,34 +151,24 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
}
fun notifyCallInvitation(invitation: RcvCallInvitation) {
val keyguardManager = getKeyguardManager(context)
Log.d(TAG,
"notifyCallInvitation pre-requests: " +
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
"onForeground ${SimplexApp.context.isAppOnForeground}"
)
if (SimplexApp.context.isAppOnForeground) return
if (isAppOnForeground(context)) return
val contactId = invitation.contact.id
Log.d(TAG, "notifyCallInvitation $contactId")
val keyguardManager = getKeyguardManager(context)
val image = invitation.contact.image
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
var ntfBuilder =
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CallChannel)
NotificationCompat.Builder(context, LockScreenCallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setSilent(true)
} else {
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CallChannel)
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.user.userId, invitation.contact.id))
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, invitation.user.userId, contactId))
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, invitation.user.userId, contactId, true))
.setFullScreenIntent(fullScreenPendingIntent, true)
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
.setSound(soundUri)
}
val text = generalGetString(
@@ -223,11 +197,8 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
.setLargeIcon(largeIcon)
.setColor(0x88FFFF)
.setAutoCancel(true)
val notification = ntfBuilder.build()
// This makes notification sound and vibration repeat endlessly
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
with(NotificationManagerCompat.from(context)) {
notify(CallNotificationId, notification)
notify(CallNotificationId, ntfBuilder.build())
}
}
@@ -235,36 +206,33 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
manager.cancel(CallNotificationId)
}
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText
return if (md != null) {
return if (md == null) {
if (cItem.content.text != "") {
cItem.content.text
} else {
if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
}
} else {
var res = ""
for (ft in md) {
res += if (ft.format is Format.Secret) "..." else ft.text
}
res
} else {
cItem.text
}
}
private fun chatPendingIntent(intentAction: String, userId: Long, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
Log.d(TAG, "chatPendingIntent for $intentAction")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
var intent = Intent(context, if (!broadcast) MainActivity::class.java else NtfActionReceiver::class.java)
var intent = Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.setAction(intentAction)
.putExtra(UserIdKey, userId)
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
return if (!broadcast) {
TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
}
} else {
PendingIntent.getBroadcast(SimplexApp.context, uniqueInt, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
}
}
@@ -274,26 +242,13 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
* */
class NtfActionReceiver: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val userId = getUserIdFromIntent(intent)
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
val m = SimplexApp.context.chatModel
val cInfo = SimplexApp.context.chatModel.getChat(chatId)?.chatInfo
when (intent.action) {
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
val isCurrentUser = m.currentUser.value?.userId == userId
val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
(m.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
} else {
null
}
val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
acceptContactRequest(apiId, cInfo, isCurrentUser, m)
m.controller.ntfManager.cancelNotificationsForChat(chatId)
}
RejectCallAction -> {
val invitation = m.callInvitations[chatId]
if (invitation != null) {
m.callManager.endCall(invitation = invitation)
}
if (cInfo !is ChatInfo.ContactRequest) return
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
}
else -> {
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")

View File

@@ -1,21 +1,29 @@
package chat.simplex.app.views
import android.content.Context
import android.content.res.Configuration
import android.os.SystemClock
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
@@ -23,18 +31,70 @@ import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
private val lastSuccessfulAuth: MutableState<Long?> = mutableStateOf(null)
@Composable
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
val lastSuccessfulAuth = remember { lastSuccessfulAuth }
BackHandler(onBack = {
lastSuccessfulAuth.value = null
close()
})
val authorized = remember { !chatModel.controller.appPrefs.performLA.get() }
val context = LocalContext.current
LaunchedEffect(lastSuccessfulAuth.value) {
if (!authorized && !authorizedPreviously(lastSuccessfulAuth)) {
runAuth(lastSuccessfulAuth, context)
}
}
if (authorized || authorizedPreviously(lastSuccessfulAuth)) {
LaunchedEffect(Unit) {
// Update auth each time user visits this screen in authenticated state just to prolong authorized time
lastSuccessfulAuth.value = SystemClock.elapsedRealtime()
}
TerminalLayout(
remember { chatModel.terminalItems },
chatModel.terminalItems,
composeState,
sendCommand = { sendCommand(chatModel, composeState) },
close
)
} else {
Surface(Modifier.fillMaxSize()) {
Column(Modifier.background(MaterialTheme.colors.background)) {
CloseSheetBar(close)
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(R.string.auth_unlock),
icon = Icons.Outlined.Lock,
click = {
runAuth(lastSuccessfulAuth, context)
}
)
}
}
}
}
}
private fun authorizedPreviously(lastSuccessfulAuth: State<Long?>): Boolean =
lastSuccessfulAuth.value?.let { SystemClock.elapsedRealtime() - it < 30_000 } ?: false
private fun runAuth(lastSuccessfulAuth: MutableState<Long?>, context: Context) {
authenticate(
generalGetString(R.string.auth_open_chat_console),
generalGetString(R.string.auth_log_in_using_credential),
context as FragmentActivity,
completed = { laResult ->
lastSuccessfulAuth.value = when (laResult) {
LAResult.Success, LAResult.Unavailable -> SystemClock.elapsedRealtime()
is LAResult.Error, LAResult.Failed -> null
}
}
)
}
private fun sendCommand(chatModel: ChatModel, composeState: MutableState<ComposeState>) {
@@ -42,9 +102,9 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState<Compose
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
val s = composeState.value.message
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
val resp = CR.ChatCmdError(null, ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
chatModel.addTerminalItem(TerminalItem.cmd(CC.Console(s)))
chatModel.addTerminalItem(TerminalItem.resp(resp))
val resp = CR.ChatCmdError(ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
chatModel.terminalItems.add(TerminalItem.cmd(CC.Console(s)))
chatModel.terminalItems.add(TerminalItem.resp(resp))
composeState.value = ComposeState(useLinkPreviews = false)
} else {
withApi {
@@ -75,21 +135,7 @@ fun TerminalLayout(
topBar = { CloseSheetBar(close) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(
composeState = composeState,
showVoiceRecordIcon = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = false,
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = false,
allowVoiceToContact = {},
sendMessage = sendCommand,
sendLiveMessage = null,
updateLiveMessage = null,
onMessageChange = ::onMessageChange,
textStyle = textStyle
)
SendMsgView(composeState, false, sendCommand, ::onMessageChange, { _, _, _ -> }, textStyle)
}
},
modifier = Modifier.navigationBarsWithImePadding()
@@ -114,8 +160,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
val context = LocalContext.current
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
LazyColumn(state = listState, reverseLayout = true) {
items(reversedTerminalItems) { item ->
Text(
@@ -126,7 +171,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
modifier = Modifier
.fillMaxWidth()
.clickable {
ModalManager.shared.showModal(endButtons = { ShareButton { shareText(context, item.details) } }) {
ModalManager.shared.showModal {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
}

View File

@@ -38,7 +38,7 @@ fun isValidDisplayName(name: String) : Boolean {
}
@Composable
fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
fun CreateProfilePanel(chatModel: ChatModel) {
val displayName = remember { mutableStateOf("") }
val fullName = remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
@@ -72,12 +72,10 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
ProfileNameField(fullName)
Spacer(Modifier.fillMaxHeight().weight(1f))
Row {
if (chatModel.users.isEmpty()) {
SimpleButton(
text = stringResource(R.string.about_simplex),
icon = Icons.Outlined.ArrowBackIosNew
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
}
SimpleButton(
text = stringResource(R.string.about_simplex),
icon = Icons.Outlined.ArrowBackIosNew
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
Spacer(Modifier.fillMaxWidth().weight(1f))
@@ -85,7 +83,7 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
val createModifier: Modifier
val createColor: Color
if (enabled) {
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp)
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value) }.padding(8.dp)
createColor = MaterialTheme.colors.primary
} else {
createModifier = Modifier.padding(8.dp)
@@ -107,22 +105,15 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
}
}
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName, null)
) ?: return@withApi
chatModel.currentUser.value = user
if (chatModel.users.isEmpty()) {
chatModel.controller.startChat(user)
chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
} else {
val users = chatModel.controller.listUsers()
chatModel.users.clear()
chatModel.users.addAll(users)
chatModel.controller.getUserChatData()
close()
}
)
chatModel.controller.startChat(user)
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
SimplexService.start(chatModel.controller.appContext)
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
}
}

View File

@@ -18,7 +18,7 @@ class CallManager(val chatModel: ChatModel) {
controller.ntfManager.notifyCallInvitation(invitation)
} else {
val contact = invitation.contact
controller.ntfManager.notifyMessageReceived(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
controller.ntfManager.notifyMessageReceived(chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
}
}
}

View File

@@ -1,6 +1,5 @@
package chat.simplex.app.views.call
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
@@ -44,7 +43,8 @@ class IncomingCallActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { IncomingCallActivityView(vm.chatModel) }
val activity = this
setContent { IncomingCallActivityView(vm.chatModel, activity) }
unlockForIncomingCall()
}
@@ -83,12 +83,11 @@ fun getKeyguardManager(context: Context): KeyguardManager =
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
@Composable
fun IncomingCallActivityView(m: ChatModel) {
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
val switchingCall = m.switchingCall.value
val invitation = m.activeCallInvitation.value
val call = m.activeCall.value
val showCallView = m.showCallView.value
val activity = LocalContext.current as Activity
LaunchedEffect(invitation, call, switchingCall, showCallView) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
Log.d(TAG, "IncomingCallActivityView: finishing activity")
@@ -106,43 +105,36 @@ fun IncomingCallActivityView(m: ChatModel) {
if (invitation != null) IncomingCallAlertView(invitation, m)
}
} else if (invitation != null) {
IncomingCallLockScreenAlert(invitation, m)
IncomingCallLockScreenAlert(invitation, m, activity)
}
}
}
}
@Composable
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
val cm = chatModel.callManager
val callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
val context = LocalContext.current
DisposableEffect(Unit) {
onDispose {
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
chatModel.controller.ntfManager.cancelCallNotification()
}
}
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
IncomingCallLockScreenAlertLayout(
invitation,
callOnLockScreen,
chatModel,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = {
chatModel.activeCallInvitation.value = null
chatModel.controller.ntfManager.cancelCallNotification()
},
ignoreCall = { chatModel.activeCallInvitation.value = null },
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
openApp = {
val intent = Intent(context, MainActivity::class.java)
SoundPlayer.shared.stop()
var intent = Intent(activity, MainActivity::class.java)
.setAction(OpenChatAction)
.putExtra("userId", invitation.user.userId)
.putExtra("chatId", invitation.contact.id)
context.startActivity(intent)
activity.startActivity(intent)
activity.finish()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getKeyguardManager(context).requestDismissKeyguard((context as Activity), null)
getKeyguardManager(activity).requestDismissKeyguard(activity, null)
}
(context as Activity).finish()
}
)
}
@@ -151,7 +143,6 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
fun IncomingCallLockScreenAlertLayout(
invitation: RcvCallInvitation,
callOnLockScreen: CallOnLockScreen?,
chatModel: ChatModel,
rejectCall: () -> Unit,
ignoreCall: () -> Unit,
acceptCall: () -> Unit,
@@ -163,7 +154,7 @@ fun IncomingCallLockScreenAlertLayout(
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
IncomingCallInfo(invitation, chatModel)
IncomingCallInfo(invitation)
Spacer(Modifier.fillMaxHeight().weight(1f))
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
@@ -220,14 +211,12 @@ fun PreviewIncomingCallLockScreenAlert() {
.fillMaxSize()) {
IncomingCallLockScreenAlertLayout(
invitation = RcvCallInvitation(
user = User.sampleData,
contact = Contact.sampleData,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
sharedKey = null,
callTs = Clock.System.now()
),
callOnLockScreen = null,
chatModel = SimplexApp.context.chatModel,
rejectCall = {},
ignoreCall = {},
acceptCall = {},

View File

@@ -17,10 +17,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Contact
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.usersettings.ProfilePreview
import kotlinx.datetime.Clock
@@ -33,12 +32,8 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
IncomingCallAlertLayout(
invitation,
chatModel,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = {
chatModel.activeCallInvitation.value = null
chatModel.controller.ntfManager.cancelCallNotification()
},
ignoreCall = { chatModel.activeCallInvitation.value = null },
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
)
}
@@ -46,14 +41,13 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
@Composable
fun IncomingCallAlertLayout(
invitation: RcvCallInvitation,
chatModel: ChatModel,
rejectCall: () -> Unit,
ignoreCall: () -> Unit,
acceptCall: () -> Unit
) {
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
Column(Modifier.fillMaxWidth().background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
IncomingCallInfo(invitation, chatModel)
IncomingCallInfo(invitation)
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
@@ -69,13 +63,9 @@ fun IncomingCallAlertLayout(
}
@Composable
fun IncomingCallInfo(invitation: RcvCallInvitation, chatModel: ChatModel) {
fun IncomingCallInfo(invitation: RcvCallInvitation) {
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
Row(verticalAlignment = Alignment.CenterVertically) {
if (chatModel.users.size > 1) {
ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.secondary)
Spacer(Modifier.width(4.dp))
}
Row {
if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
Spacer(Modifier.width(4.dp))
@@ -108,13 +98,11 @@ fun PreviewIncomingCallAlertLayout() {
SimpleXTheme {
IncomingCallAlertLayout(
invitation = RcvCallInvitation(
user = User.sampleData,
contact = Contact.sampleData,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
sharedKey = null,
callTs = Clock.System.now()
),
chatModel = SimplexApp.context.chatModel,
rejectCall = {},
ignoreCall = {},
acceptCall = {}

View File

@@ -5,7 +5,6 @@ import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.Contact
import chat.simplex.app.model.User
import chat.simplex.app.views.helpers.generalGetString
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
@@ -62,39 +61,39 @@ enum class CallState {
}
}
@Serializable data class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
@Serializable data class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
@Serializable
sealed class WCallCommand {
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@Serializable @SerialName("camera") class Camera(val camera: VideoCamera): WCallCommand()
@Serializable @SerialName("end") object End: WCallCommand()
}
@Serializable
sealed class WCallResponse {
@Serializable @SerialName("capabilities") data class Capabilities(val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("answer") data class Answer(val answer: String, val iceCandidates: String): WCallResponse()
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
@Serializable @SerialName("connected") class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
@Serializable @SerialName("ended") object Ended: WCallResponse()
@Serializable @SerialName("ok") object Ok: WCallResponse()
@Serializable @SerialName("error") data class Error(val message: String): WCallResponse()
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
}
@Serializable data class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
@Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String)
@Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
@Serializable data class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
@Serializable class RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
val callTypeText: String get() = generalGetString(when(callType.media) {
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
@@ -104,8 +103,8 @@ sealed class WCallResponse {
CallMediaType.Audio -> R.string.incoming_audio_call
})
}
@Serializable data class CallCapabilities(val encryption: Boolean)
@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
@Serializable class CallCapabilities(val encryption: Boolean)
@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
val text: String @Composable get() = when {
localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
stringResource(R.string.call_connection_peer_to_peer)
@@ -116,7 +115,7 @@ sealed class WCallResponse {
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
@Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
@@ -151,7 +150,7 @@ enum class VideoCamera {
}
@Serializable
data class ConnectionState(
class ConnectionState(
val connectionState: String,
val iceConnectionState: String,
val iceGatheringState: String,

View File

@@ -35,10 +35,9 @@ import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
@Composable
fun ChatInfoView(
@@ -47,65 +46,26 @@ fun ChatInfoView(
connStats: ConnectionStats?,
customUserProfile: Profile?,
localAlias: String,
connectionCode: String?,
close: () -> Unit,
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null) {
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
mutableStateOf(chatModel.contactNetworkStatus(contact))
}
ChatInfoLayout(
chat,
contact,
connStats,
contactNetworkStatus.value,
customUserProfile,
localAlias,
connectionCode,
developerTools,
onLocalAliasChanged = {
setContactAlias(chat.chatInfo.apiId, it, chatModel)
},
openPreferences = {
ModalManager.shared.showCustomModal { close ->
val user = chatModel.currentUser.value
if (user != null) {
ContactPreferencesView(chatModel, user, contact.contactId, close)
}
}
},
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
switchContactAddress = {
showSwitchContactAddressAlert(chatModel, contact.contactId)
},
verifyClicked = {
ModalManager.shared.showModalCloseable { close ->
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
VerifyCodeView(
ct.displayName,
connectionCode,
ct.verified,
verify = { code ->
chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r ->
val (verified, existingCode) = r
chatModel.updateContact(
ct.copy(
activeConn = ct.activeConn.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
)
r
}
},
close,
)
}
}
}
)
}
@@ -153,17 +113,13 @@ fun ChatInfoLayout(
chat: Chat,
contact: Contact,
connStats: ConnectionStats?,
contactNetworkStatus: NetworkStatus,
customUserProfile: Profile?,
localAlias: String,
connectionCode: String?,
developerTools: Boolean,
onLocalAliasChanged: (String) -> Unit,
openPreferences: () -> Unit,
deleteContact: () -> Unit,
clearChat: () -> Unit,
switchContactAddress: () -> Unit,
verifyClicked: () -> Unit,
) {
Column(
Modifier
@@ -187,27 +143,20 @@ fun ChatInfoLayout(
}
}
SectionSpacer()
SectionView {
if (connectionCode != null) {
VerifyCodeButton(contact.verified, verifyClicked)
SectionDivider()
}
ContactPreferencesButton(openPreferences)
}
SectionSpacer()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SwitchAddressButton(switchContactAddress)
SectionDivider()
if (developerTools) {
SwitchAddressButton(switchContactAddress)
SectionDivider()
}
if (connStats != null) {
SectionItemView({
AlertManager.shared.showAlertMsg(
generalGetString(R.string.network_status),
contactNetworkStatus.statusExplanation
chat.serverInfo.networkStatus.statusExplanation
)}) {
NetworkStatusRow(contactNetworkStatus)
NetworkStatusRow(chat.serverInfo.networkStatus)
}
val rcvServers = connStats.rcvServers
if (rcvServers != null && rcvServers.isNotEmpty()) {
@@ -247,17 +196,13 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
if (contact.verified) {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
}
Text(
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 8.dp)
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
@@ -319,7 +264,7 @@ fun LocalAliasEditor(
}
@Composable
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
Row(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -351,14 +296,14 @@ private fun NetworkStatusRow(networkStatus: NetworkStatus) {
}
@Composable
private fun ServerImage(networkStatus: NetworkStatus) {
fun ServerImage(networkStatus: Chat.NetworkStatus) {
Box(Modifier.size(18.dp)) {
when (networkStatus) {
is NetworkStatus.Connected ->
is Chat.NetworkStatus.Connected ->
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
is NetworkStatus.Disconnected ->
is Chat.NetworkStatus.Disconnected ->
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
is NetworkStatus.Error ->
is Chat.NetworkStatus.Error ->
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
}
@@ -382,25 +327,6 @@ fun SwitchAddressButton(onClick: () -> Unit) {
}
}
@Composable
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
SettingsActionItem(
if (contactVerified) Icons.Outlined.VerifiedUser else Icons.Outlined.Shield,
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
click = onClick,
iconColor = HighOrLowlight,
)
}
@Composable
private fun ContactPreferencesButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.contact_preferences),
click = onClick
)
}
@Composable
fun ClearChatButton(onClick: () -> Unit) {
SettingsActionItem(
@@ -413,7 +339,7 @@ fun ClearChatButton(onClick: () -> Unit) {
}
@Composable
private fun DeleteContactButton(onClick: () -> Unit) {
fun DeleteContactButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Delete,
stringResource(R.string.button_delete_contact),
@@ -451,21 +377,18 @@ fun PreviewChatInfoLayout() {
ChatInfoLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = arrayListOf()
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
Contact.sampleData,
localAlias = "",
connectionCode = "123",
developerTools = false,
connStats = null,
contactNetworkStatus = NetworkStatus.Connected(),
onLocalAliasChanged = {},
customUserProfile = null,
openPreferences = {},
deleteContact = {},
clearChat = {},
switchContactAddress = {},
verifyClicked = {},
)
}
}

View File

@@ -3,7 +3,6 @@ package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
@@ -13,7 +12,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
@@ -56,13 +56,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = rememberSaveable(saver = ComposeState.saver()) {
mutableStateOf(
if (chatModel.draftChatId.value == chatId && chatModel.draft.value != null) {
chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
} else {
ComposeState(useLinkPreviews = useLinkPreviews)
}
)
mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews))
}
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
@@ -83,23 +77,16 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
}
}
launch {
snapshotFlow {
/**
* It's possible that in some cases concurrent modification can happen on [ChatModel.chats] list.
* In this case only error log will be printed here (no crash).
* TODO: Re-write [ChatModel.chats] logic to a new list assignment instead of changing content of mutableList to prevent that
* */
try {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
} catch (e: ConcurrentModificationException) {
Log.e(TAG, e.stackTraceToString())
null
}
}
// .toList() is important for making observation working
snapshotFlow { chatModel.chats.toList() }
.distinctUntilChanged()
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
.filter { it?.chatInfo != activeChat.value?.chatInfo && it != null }
.collect { activeChat.value = it }
.collect { chats ->
chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }.let {
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
if (it?.chatInfo != activeChat.value?.chatInfo) {
activeChat.value = it
}}
}
}
}
val view = LocalView.current
@@ -132,7 +119,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModel.chatItems,
searchText,
useLinkPreviews = useLinkPreviews,
linkMode = chatModel.simplexLinkMode.value,
chatModelIncognito = chatModel.incognito.value,
back = {
hideKeyboard(view)
@@ -142,19 +128,16 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
info = {
hideKeyboard(view)
withApi {
if (chat.chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
ModalManager.shared.showModalCloseable(true) { close ->
remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
}
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
}
} else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
var groupLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
} else if (cInfo is ChatInfo.Group) {
setGroupMembers(cInfo.groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, groupLink, { groupLink = it }, close)
GroupChatInfoView(chatModel, close)
}
}
}
@@ -163,21 +146,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
hideKeyboard(view)
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
val (_, code) = if (member.memberActive) {
try {
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
member to null
}
} else {
member to null
}
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
}
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
}
}
},
@@ -193,27 +163,17 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
deleteMessage = { itemId, mode ->
withApi {
val cInfo = chat.chatInfo
val r = chatModel.controller.apiDeleteChatItem(
val toItem = chatModel.controller.apiDeleteChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = itemId,
mode = mode
)
if (r != null) {
val toChatItem = r.toChatItem
if (toChatItem == null) {
chatModel.removeChatItem(cInfo, r.deletedChatItem.chatItem)
} else {
chatModel.upsertChatItem(cInfo, toChatItem.chatItem)
}
}
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
}
},
receiveFile = { fileId ->
val user = chatModel.currentUser.value
if (user != null) {
withApi { chatModel.controller.receiveFile(user, fileId) }
}
withApi { chatModel.controller.receiveFile(fileId) }
},
joinGroup = { groupId ->
withApi { chatModel.controller.apiJoinGroup(groupId) }
@@ -235,24 +195,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
},
acceptFeature = { contact, feature, param ->
withApi {
chatModel.controller.allowFeatureToContact(contact, feature, param)
}
},
addMembers = { groupInfo ->
hideKeyboard(view)
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, false, chatModel, close)
AddGroupMembersView(groupInfo, chatModel, close)
}
}
},
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withBGApi {
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
@@ -285,7 +240,6 @@ fun ChatLayout(
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
chatModelIncognito: Boolean,
back: () -> Unit,
info: () -> Unit,
@@ -296,7 +250,6 @@ fun ChatLayout(
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
addMembers: (GroupInfo) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
@@ -336,8 +289,8 @@ fun ChatLayout(
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
)
}
}
@@ -460,15 +413,10 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) {
ContactVerifiedShield()
}
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
Text(
cInfo.fullName,
@@ -479,11 +427,6 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
}
}
@Composable
private fun ContactVerifiedShield() {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
}
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
val CIListStateSaver = run {
@@ -504,7 +447,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
chatModelIncognito: Boolean,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
@@ -512,14 +454,13 @@ fun BoxWithConstraintsScope.ChatItemsList(
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: () -> Unit,
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
ScrollToBottom(chat.id, listState, chatItems)
ScrollToBottom(chat.id, listState)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
LaunchedEffect(searchValue.value.isEmpty()) {
@@ -538,7 +479,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } }
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
val scrollToItem: (Long) -> Unit = { itemId: Long ->
val index = reversedChatItems.indexOfFirst { it.id == itemId }
@@ -557,7 +498,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
itemsIndexed(reversedChatItems) { i, cItem ->
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
@@ -577,7 +518,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
scope.launch {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
}
@@ -618,11 +559,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
} else {
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
} else { // direct message
@@ -633,7 +574,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
end = if (sent) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
@@ -652,7 +593,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
@Composable
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List<ChatItem>) {
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
val scope = rememberCoroutineScope()
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
@@ -664,23 +605,6 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
// Don't autoscroll next time until it will be needed
shouldAutoScroll = false to chatId
}
val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() }
/*
* Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves.
* When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise
* */
LaunchedEffect(Unit) {
snapshotFlow { chatItems.lastOrNull()?.id }
.distinctUntilChanged()
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
.collect {
if (listState.firstVisibleItemIndex == 0) {
listState.animateScrollToItem(0)
} else {
listState.animateScrollBy(scrollDistance)
}
}
}
}
@Composable
@@ -1024,7 +948,6 @@ fun PreviewChatLayout() {
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
chatModelIncognito = false,
back = {},
info = {},
@@ -1035,7 +958,6 @@ fun PreviewChatLayout() {
joinGroup = {},
startCall = {},
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
@@ -1083,7 +1005,6 @@ fun PreviewGroupChatLayout() {
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
chatModelIncognito = false,
back = {},
info = {},
@@ -1094,7 +1015,6 @@ fun PreviewGroupChatLayout() {
joinGroup = {},
startCall = {},
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },

View File

@@ -1,10 +1,8 @@
@file:UseSerializers(UriSerializer::class)
package chat.simplex.app.views.chat
import ComposeFileView
import ComposeVoiceView
import ComposeFileView
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.pm.PackageManager
import android.graphics.Bitmap
@@ -34,25 +32,27 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toFile
import androidx.core.net.toUri
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import java.io.File
import java.nio.file.Files
@Serializable
sealed class ComposePreview {
@Serializable object NoPreview: ComposePreview()
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
@Serializable class ImagePreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
@Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
@Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview()
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
@Serializable class FilePreview(val fileName: String): ComposePreview()
}
@Serializable
@@ -62,26 +62,16 @@ sealed class ComposeContextItem {
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
}
@Serializable
data class LiveMessage(
val chatItem: ChatItem,
val typedMsg: String,
val sentMsg: String,
val sent: Boolean
)
@Serializable
data class ComposeState(
val message: String = "",
val liveMessage: LiveMessage? = null,
val preview: ComposePreview = ComposePreview.NoPreview,
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
val inProgress: Boolean = false,
val useLinkPreviews: Boolean
) {
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this(
editingItem.content.text,
liveMessage,
chatItemPreview(editingItem),
ComposeContextItem.EditingItem(editingItem),
useLinkPreviews = useLinkPreviews
@@ -99,13 +89,10 @@ data class ComposeState(
is ComposePreview.ImagePreview -> true
is ComposePreview.VoicePreview -> true
is ComposePreview.FilePreview -> true
else -> message.isNotEmpty() || liveMessage != null
else -> message.isNotEmpty()
}
hasContent && !inProgress
}
val endLiveDisabled: Boolean
get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
val linkPreviewAllowed: Boolean
get() =
when (preview) {
@@ -121,19 +108,6 @@ data class ComposeState(
else -> null
}
val attachmentDisabled: Boolean
get() {
if (editing || liveMessage != null) return true
return when (preview) {
ComposePreview.NoPreview -> false
is ComposePreview.CLinkPreview -> false
else -> true
}
}
val empty: Boolean
get() = message.isEmpty() && preview is ComposePreview.NoPreview
companion object {
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
save = { json.encodeToString(serializer(), it.value) },
@@ -144,24 +118,16 @@ data class ComposeState(
}
}
sealed class RecordingState {
object NotStarted: RecordingState()
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
class Finished(val filePath: String, val durationMs: Int): RecordingState()
val filePathNullable: String?
get() = (this as? Started)?.filePath
}
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
val fileName = chatItem.file?.fileName ?: ""
return when (val mc = chatItem.content.msgContent) {
is MsgContent.MCText -> ComposePreview.NoPreview
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
// TODO: include correct type
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true)
is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName))
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true)
is MsgContent.MCFile -> {
val fileName = chatItem.file?.fileName ?: ""
ComposePreview.FilePreview(fileName)
}
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
}
}
@@ -182,17 +148,26 @@ fun ComposeView(
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
// attachments
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>> (
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
)
val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) }
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
chosenContent.value = listOf(UploadContent.SimpleImage(uri))
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview)))
}
}
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launchWithFallback()
cameraLauncher.launch(null)
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
@@ -234,7 +209,8 @@ fun ComposeView(
}
if (imagesPreview.isNotEmpty()) {
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview, content))
chosenContent.value = content
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview))
}
}
val processPickedFile = { uri: Uri?, text: String? ->
@@ -243,7 +219,8 @@ fun ComposeView(
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
val fileName = getFileName(SimplexApp.context, uri)
if (fileName != null) {
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri))
chosenFile.value = uri
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName))
}
} else {
AlertManager.shared.showAlertMsg(
@@ -256,14 +233,13 @@ fun ComposeView(
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
AttachmentOption.TakePhoto -> {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launchWithFallback()
cameraLauncher.launch(null)
}
else -> {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
@@ -334,160 +310,128 @@ fun ComposeView(
cancelledLinks.clear()
}
fun clearState(live: Boolean = false) {
if (live) {
composeState.value = composeState.value.copy(inProgress = false)
} else {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
resetLinkPreview()
}
recState.value = RecordingState.NotStarted
textStyle.value = smallFont
chatModel.removeLiveDummy()
}
fun deleteUnusedFiles() {
chatModel.filesToDelete.forEach { it.delete() }
chatModel.filesToDelete.clear()
}
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
file = file,
quotedItemId = quoted,
mc = mc,
live = live
)
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
return aChatItem?.chatItem
}
suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? {
val cInfo = chat.chatInfo
fun checkLinkPreview(): MsgContent {
val cs = composeState.value
var sent: ChatItem?
val msgText = text ?: cs.message
fun sending() {
composeState.value = composeState.value.copy(inProgress = true)
}
fun checkLinkPreview(): MsgContent {
return when (val composePreview = cs.preview) {
is ComposePreview.CLinkPreview -> {
val url = parseMessage(msgText)
val lp = composePreview.linkPreview
if (lp != null && url == lp.uri) {
MsgContent.MCLink(msgText, preview = lp)
} else {
MsgContent.MCText(msgText)
}
}
else -> MsgContent.MCText(msgText)
}
}
fun updateMsgContent(msgContent: MsgContent): MsgContent {
return when (msgContent) {
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
is MsgContent.MCFile -> MsgContent.MCFile(msgText)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
}
}
suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? {
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
mc = updateMsgContent(oldMsgContent),
live = live
)
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
return updatedItem?.chatItem
}
return null
}
val liveMessage = cs.liveMessage
if (!live) {
if (liveMessage != null) composeState.value = cs.copy(liveMessage = null)
sending()
}
if (cs.contextItem is ComposeContextItem.EditingItem) {
val ei = cs.contextItem.chatItem
sent = updateMessage(ei, cInfo, live)
} else if (liveMessage != null && liveMessage.sent) {
sent = updateMessage(liveMessage.chatItem, cInfo, live)
} else {
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<String> = ArrayList()
when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.ImagePreview -> {
preview.content.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(context, it.uri)
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
}
if (file != null) {
files.add(file)
msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index]))
}
}
}
is ComposePreview.VoicePreview -> {
val tmpFile = File(preview.voice)
AudioPlayer.stop(tmpFile.absolutePath)
val actualFile = File(getAppFilePath(SimplexApp.context, tmpFile.name.replaceAfter(RecorderNative.extension, "")))
withContext(Dispatchers.IO) {
Files.move(tmpFile.toPath(), actualFile.toPath())
}
files.add(actualFile.name)
deleteUnusedFiles()
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
}
is ComposePreview.FilePreview -> {
val file = saveFileFromUri(context, preview.uri)
if (file != null) {
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
}
return when (val composePreview = cs.preview) {
is ComposePreview.CLinkPreview -> {
val url = parseMessage(cs.message)
val lp = composePreview.linkPreview
if (lp != null && url == lp.uri) {
MsgContent.MCLink(cs.message, preview = lp)
} else {
MsgContent.MCText(cs.message)
}
}
val quotedItemId: Long? = when (cs.contextItem) {
is ComposeContextItem.QuotedItem -> cs.contextItem.chatItem.id
else -> null
}
sent = null
msgs.forEachIndexed { index, content ->
if (index > 0) delay(100)
sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index),
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
)
}
if (sent == null && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview)) {
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
}
else -> MsgContent.MCText(cs.message)
}
clearState(live)
return sent
}
fun updateMsgContent(msgContent: MsgContent): MsgContent {
val cs = composeState.value
return when (msgContent) {
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
}
}
fun clearState() {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
textStyle.value = smallFont
chosenContent.value = emptyList()
chosenAudio.value = null
chosenFile.value = null
linkUrl.value = null
prevLinkUrl.value = null
pendingLinkUrl.value = null
cancelledLinks.clear()
}
fun sendMessage() {
withBGApi {
sendMessageAsync(null, false)
composeState.value = composeState.value.copy(inProgress = true)
val cInfo = chat.chatInfo
val cs = composeState.value
when (val contextItem = cs.contextItem) {
is ComposeContextItem.EditingItem -> {
val ei = contextItem.chatItem
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
withApi {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
mc = updateMsgContent(oldMsgContent)
)
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
clearState()
}
}
}
else -> {
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<String> = ArrayList()
when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.ImagePreview -> {
chosenContent.value.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(context, it.uri)
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
}
if (file != null) {
files.add(file)
msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index]))
}
}
}
is ComposePreview.VoicePreview -> {
val chosenAudioVal = chosenAudio.value
if (chosenAudioVal != null) {
val file = chosenAudioVal.first.toFile().name
files.add((file))
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
val file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else ""))
}
}
}
}
val quotedItemId: Long? = when (contextItem) {
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
else -> null
}
if (msgs.isNotEmpty()) {
withApi {
msgs.forEachIndexed { index, content ->
if (index > 0) delay(100)
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
file = files.getOrNull(index),
quotedItemId = if (index == 0) quotedItemId else null,
mc = content
)
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
}
clearState()
}
} else {
clearState()
}
}
}
}
@@ -506,17 +450,11 @@ fun ComposeView(
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
val file = File(filePath)
chosenAudio.value = file.toUri() to durationMs
chatModel.filesToDelete.add(file)
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
}
fun allowVoiceToContact() {
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
withApi {
chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice)
}
}
fun cancelLinkPreview() {
val uri = composeState.value.linkPreview?.uri
if (uri != null) {
@@ -528,70 +466,17 @@ fun ComposeView(
fun cancelImages() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenContent.value = emptyList()
}
fun cancelVoice() {
val filePath = recState.value.filePathNullable
recState.value = RecordingState.NotStarted
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
withBGApi {
RecorderNative.stopRecording?.invoke()
AudioPlayer.stop(filePath)
filePath?.let { File(it).delete() }
}
chosenContent.value = emptyList()
}
fun cancelFile() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
}
fun truncateToWords(s: String): String {
var acc = ""
val word = StringBuilder()
for (c in s) {
if (c.isLetter() || c.isDigit()) {
word.append(c)
} else {
acc = acc + word.toString() + c
word.clear()
}
}
return acc
}
suspend fun sendLiveMessage() {
val cs = composeState.value
val typedMsg = cs.message
if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) {
val ci = sendMessageAsync(typedMsg, live = true)
if (ci != null) {
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
}
} else if (cs.liveMessage == null) {
val cItem = chatModel.addLiveDummy(chat.chatInfo)
composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = typedMsg, sent = false))
}
}
fun liveMessageToSend(lm: LiveMessage, t: String): String? {
val s = if (t != lm.typedMsg) truncateToWords(t) else t
return if (s != lm.sentMsg) s else null
}
suspend fun updateLiveMessage() {
val typedMsg = composeState.value.message
val liveMessage = composeState.value.liveMessage
if (liveMessage != null) {
val sentMsg = liveMessageToSend(liveMessage, typedMsg)
if (sentMsg != null) {
val ci = sendMessageAsync(sentMsg, live = true)
if (ci != null) {
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true))
}
} else if (liveMessage.typedMsg != typedMsg) {
composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg))
}
}
chosenFile.value = null
}
@Composable
@@ -633,9 +518,6 @@ fun ComposeView(
}
LaunchedEffect(chatModel.sharedContent.value) {
// Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
if (chatModel.chatId.value == null) return@LaunchedEffect
when (val shared = chatModel.sharedContent.value) {
is SharedContent.Text -> onMessageChange(shared.text)
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
@@ -656,95 +538,27 @@ fun ComposeView(
modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.Bottom,
) {
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) {
val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
IconButton(showChooseAttachment, enabled = attachEnabled) {
Icon(
Icons.Filled.AttachFile,
contentDescription = stringResource(R.string.attach),
tint = if (!composeState.value.attachmentDisabled) MaterialTheme.colors.primary else HighOrLowlight,
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
)
}
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
LaunchedEffect(Unit) {
snapshotFlow { recState.value }
.distinctUntilChanged()
.collect {
when(it) {
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true)
is RecordingState.NotStarted -> {}
}
}
}
fun clearCurrentDraft() {
if (chatModel.draftChatId.value == chat.id) {
chatModel.draft.value = null
chatModel.draftChatId.value = null
}
}
val activity = LocalContext.current as Activity
DisposableEffect(Unit) {
val orientation = activity.resources.configuration.orientation
onDispose {
if (orientation == activity.resources.configuration.orientation) {
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage()
resetLinkPreview()
clearCurrentDraft()
deleteUnusedFiles()
} else if (!composeState.value.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
}
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
} else {
clearCurrentDraft()
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
}
}
}
SendMsgView(
composeState,
showVoiceRecordIcon = true,
recState,
chat.chatInfo is ChatInfo.Direct,
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
needToAllowVoiceToContact,
allowedVoiceByPrefs,
allowVoiceToContact = ::allowVoiceToContact,
allowVoiceRecord = true,
sendMessage = {
sendMessage()
resetLinkPreview()
},
sendLiveMessage = ::sendLiveMessage,
updateLiveMessage = ::updateLiveMessage,
cancelLiveMessage = {
composeState.value = composeState.value.copy(liveMessage = null)
chatModel.removeLiveDummy()
},
onMessageChange = ::onMessageChange,
textStyle = textStyle
::onMessageChange,
::onAudioAdded,
textStyle
)
}
}

View File

@@ -14,49 +14,42 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.durationText
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.AudioInfoUpdater
import chat.simplex.app.views.chat.item.SentColorLight
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun ComposeVoiceView(
filePath: String,
recordedDurationMs: Int,
finishedRecording: Boolean,
cancelEnabled: Boolean,
cancelVoice: () -> Unit
) {
fun ComposeVoiceView(filePath: String, durationMs: Int, finished: Boolean, cancelEnabled: Boolean, cancelVoice: () -> Unit) {
BoxWithConstraints(Modifier
.fillMaxWidth()
) {
val audioPlaying = rememberSaveable { mutableStateOf(false) }
val progress = rememberSaveable { mutableStateOf(0) }
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
val audioInfo = rememberSaveable(saver = ProgressAndDuration.Saver) {
mutableStateOf(ProgressAndDuration(durationMs = durationMs))
}
LaunchedEffect(durationMs) {
audioInfo.value = audioInfo.value.copy(durationMs = durationMs)
}
val progressBarWidth = remember { Animatable(0f) }
LaunchedEffect(recordedDurationMs, finishedRecording) {
snapshotFlow { progress.value }
LaunchedEffect(durationMs, finished) {
snapshotFlow { audioInfo.value }
.distinctUntilChanged()
.collect {
val startTime = when {
finishedRecording -> progress.value
else -> recordedDurationMs
}
val endTime = when {
finishedRecording -> duration.value
audioPlaying.value -> recordedDurationMs
else -> MAX_VOICE_MILLIS_FOR_SENDING
}
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
val number = if (audioPlaying.value) audioInfo.value.progressMs else if (!finished) durationMs else 0
val new = if (audioPlaying.value || finished)
((number.toDouble() / durationMs) * maxWidth.value).dp
else
(((number.toDouble()) / MAX_VOICE_MILLIS_FOR_SENDING) * maxWidth.value).dp
progressBarWidth.animateTo(new.value, audioProgressBarAnimationSpec())
}
}
Spacer(
Modifier
.requiredWidth(progressBarWidth.value.dp)
.padding(top = 58.dp)
.height(3.dp)
.height(2.dp)
.background(MaterialTheme.colors.primary)
)
Row(
@@ -67,35 +60,33 @@ fun ComposeVoiceView(
.background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
if (!audioPlaying.value) {
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
} else {
AudioPlayer.pause(audioPlaying, progress)
}
},
enabled = finishedRecording) {
val play = play@{
audioPlaying.value = AudioPlayer.start(filePath, audioInfo.value.progressMs) {
audioPlaying.value = false
}
}
val pause = {
audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs)
audioPlaying.value = false
}
AudioInfoUpdater(filePath, audioPlaying, audioInfo)
IconButton({ if (!audioPlaying.value) play() else pause() }, enabled = finished) {
Icon(
if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
stringResource(R.string.icon_descr_file),
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
tint = if (finishedRecording) MaterialTheme.colors.primary else HighOrLowlight
tint = if (finished) MaterialTheme.colors.primary else HighOrLowlight
)
}
val numberInText = remember(recordedDurationMs, progress.value) {
derivedStateOf {
when {
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
finishedRecording -> progress.value / 1000
else -> recordedDurationMs / 1000
}
}
val numberInText = remember(durationMs, audioInfo.value) {
derivedStateOf { if (audioPlaying.value) audioInfo.value.progressMs / 1000 else durationMs / 1000 }
}
val text = "%02d:%02d".format(numberInText.value / 60, numberInText.value % 60)
Text(
durationText(numberInText.value),
text,
fontSize = 18.sp,
color = HighOrLowlight,
)

View File

@@ -1,232 +0,0 @@
package chat.simplex.app.views.chat
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.PreferenceToggle
@Composable
fun ContactPreferencesView(
m: ChatModel,
user: User,
contactId: Long,
close: () -> Unit,
) {
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
val ct = contact.value ?: return
var featuresAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
var currentFeaturesAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(featuresAllowed) }
fun savePrefs(afterSave: () -> Unit = {}) {
withApi {
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
if (toContact != null) {
m.updateContact(toContact)
currentFeaturesAllowed = featuresAllowed
}
afterSave()
}
}
ModalView(
close = {
if (featuresAllowed == currentFeaturesAllowed) close()
else showUnsavedChangesAlert({ savePrefs(close) }, close)
},
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
ContactPreferencesLayout(
featuresAllowed,
currentFeaturesAllowed,
user,
ct,
applyPrefs = { prefs ->
featuresAllowed = prefs
},
reset = {
featuresAllowed = currentFeaturesAllowed
},
savePrefs = ::savePrefs,
)
}
}
@Composable
private fun ContactPreferencesLayout(
featuresAllowed: ContactFeaturesAllowed,
currentFeaturesAllowed: ContactFeaturesAllowed,
user: User,
contact: Contact,
applyPrefs: (ContactFeaturesAllowed) -> Unit,
reset: () -> Unit,
savePrefs: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.contact_preferences))
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
val onTTLUpdated = { ttl: Int? ->
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl ?: 86400))
}
TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl ->
applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL))
}
SectionSpacer()
val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
applyPrefs(featuresAllowed.copy(fullDelete = it))
}
SectionSpacer()
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
applyPrefs(featuresAllowed.copy(voice = it))
}
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = featuresAllowed == currentFeaturesAllowed
)
}
}
@Composable
private fun FeatureSection(
feature: ChatFeature,
userDefault: FeatureAllowed,
pref: ContactUserPreference,
allowFeature: State<ContactFeatureAllowed>,
onSelected: (ContactFeatureAllowed) -> Unit
) {
val enabled = FeatureEnabled.enabled(
feature.asymmetric,
user = SimpleChatPreference(allow = allowFeature.value.allowed),
contact = pref.contactPreference
)
SectionView(
feature.text.uppercase(),
icon = feature.iconFilled,
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
leadingIcon = true,
) {
SectionItemView {
ExposedDropDownSettingRow(
generalGetString(R.string.chat_preferences_you_allow),
ContactFeatureAllowed.values(userDefault).map { it to it.text },
allowFeature,
icon = null,
onSelected = onSelected
)
}
SectionDivider()
InfoRow(
generalGetString(R.string.chat_preferences_contact_allows),
pref.contactPreference.allow.text
)
}
SectionTextFooter(feature.enabledDescription(enabled))
}
@Composable
private fun TimedMessagesFeatureSection(
featuresAllowed: ContactFeaturesAllowed,
pref: ContactUserPreferenceTimed,
allowFeature: State<Boolean>,
onTTLUpdated: (Int?) -> Unit,
onSelected: (Boolean, Int?) -> Unit
) {
val enabled = FeatureEnabled.enabled(
ChatFeature.TimedMessages.asymmetric,
user = TimedMessagesPreference(allow = if (allowFeature.value) FeatureAllowed.YES else FeatureAllowed.NO),
contact = pref.contactPreference
)
SectionView(
ChatFeature.TimedMessages.text.uppercase(),
icon = ChatFeature.TimedMessages.iconFilled,
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
leadingIcon = true,
) {
SectionItemView {
PreferenceToggle(
generalGetString(R.string.chat_preferences_you_allow),
checked = allowFeature.value,
) { allow ->
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
}
}
SectionDivider()
InfoRow(
generalGetString(R.string.chat_preferences_contact_allows),
pref.contactPreference.allow.text
)
SectionDivider()
if (featuresAllowed.timedMessagesAllowed) {
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
TimedMessagesTTLPicker(ttl, onTTLUpdated)
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
}
}
SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
SectionDivider()
SectionItemView(save, disabled = disabled) {
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
}
@Composable
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
val ttlValues = TimedMessagesPreference.ttlValues
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
SectionItemView {
ExposedDropDownSettingRow(
generalGetString(R.string.delete_after),
values.map { it to TimedMessagesPreference.ttlText(it) },
selection,
onSelected = onSelected
)
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(R.string.save_preferences_question),
confirmText = generalGetString(R.string.save_and_notify_contact),
dismissText = generalGetString(R.string.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}

View File

@@ -14,7 +14,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.*
@@ -52,7 +53,6 @@ fun ContextItemView(
MarkdownText(
contextItem.text, contextItem.formattedText,
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
linkMode = SimplexLinkMode.DESCRIPTION,
modifier = Modifier.fillMaxWidth(),
)
}

View File

@@ -1,53 +0,0 @@
package chat.simplex.app.views.chat
import android.Manifest
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCodeScanner
import com.google.accompanist.permissions.rememberPermissionState
@Composable
fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
ScanCodeLayout(verifyCode, close)
}
@Composable
private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.scan_code), false)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
.padding(bottom = DEFAULT_PADDING)
) {
QRCodeScanner { text ->
verifyCode(text) {
if (it) {
close()
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.incorrect_code)
)
}
}
}
}
Text(stringResource(R.string.scan_code_from_contacts_app))
}
}

View File

@@ -6,15 +6,11 @@ import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.os.Build
import android.text.InputType
import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.*
import android.widget.EditText
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
@@ -25,17 +21,15 @@ import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.inputmethod.EditorInfoCompat
@@ -43,124 +37,154 @@ import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.*
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.*
import java.io.*
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
showVoiceRecordIcon: Boolean,
recState: MutableState<RecordingState>,
isDirectChat: Boolean,
liveMessageAlertShown: SharedPreference<Boolean>,
needToAllowVoiceToContact: Boolean,
allowedVoiceByPrefs: Boolean,
allowVoiceToContact: () -> Unit,
allowVoiceRecord: Boolean,
sendMessage: () -> Unit,
sendLiveMessage: (suspend () -> Unit)? = null,
updateLiveMessage: (suspend () -> Unit)? = null,
cancelLiveMessage: (() -> Unit)? = null,
onMessageChange: (String) -> Unit,
onAudioAdded: (String, Int, Boolean) -> Unit,
textStyle: MutableState<TextStyle>
) {
Box(Modifier.padding(vertical = 8.dp)) {
val cs = composeState.value
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
NativeKeyboard(composeState, textStyle, showDeleteTextButton, onMessageChange)
// Disable clicks on text field
if (cs.preview is ComposePreview.VoicePreview) {
Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { }))
}
if (showDeleteTextButton.value) {
DeleteTextButton(composeState)
}
Box(Modifier.align(Alignment.BottomEnd)) {
val sendButtonSize = remember { Animatable(36f) }
val sendButtonAlpha = remember { Animatable(1f) }
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Making LiveMessage alive when screen orientation was changed
if (cs.liveMessage != null && sendLiveMessage != null && updateLiveMessage != null) {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
}
Column(Modifier.padding(vertical = 8.dp)) {
Box {
val cs = composeState.value
val attachEnabled = !composeState.value.editing
val filePath = rememberSaveable { mutableStateOf(null as String?) }
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && allowVoiceRecord && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
Box(if (recordingTimeRange.first == 0L)
Modifier
else
Modifier.clickable(false, onClick = {})
) {
NativeKeyboard(composeState, textStyle, onMessageChange)
}
when {
showProgress -> ProgressIndicator()
showVoiceButton -> {
Row(verticalAlignment = Alignment.CenterVertically) {
val stopRecOnNextClick = remember { mutableStateOf(false) }
Box(Modifier.align(Alignment.BottomEnd)) {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
} else if (!showVoiceButton) {
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
)
}
} else {
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.RECORD_AUDIO,
)
)
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
var now by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (isActive) {
now = System.currentTimeMillis()
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
delay(100)
}
}
val stopRecordingAndAddAudio: () -> Unit = {
rec.stop()
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
}
val startStopRecording: () -> Unit = {
when {
needToAllowVoiceToContact || !allowedVoiceByPrefs -> {
DisallowedVoiceButton {
if (needToAllowVoiceToContact) {
showNeedToAllowVoiceAlert(allowVoiceToContact)
} else {
showDisabledVoiceAlert(isDirectChat)
}
}
}
!permissionsState.allPermissionsGranted ->
VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
else ->
RecordVoiceView(recState, stopRecOnNextClick)
}
if (sendLiveMessage != null
&& updateLiveMessage != null
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
&& cs.contextItem is ComposeContextItem.NoContextItem) {
Spacer(Modifier.width(10.dp))
StartLiveMessageButton {
if (composeState.value.preview is ComposePreview.NoPreview) {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
}
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
recordingInProgress.value -> stopRecordingAndAddAudio()
filePath.value == null -> {
recordingTimeRange = System.currentTimeMillis()..0L
filePath.value = rec.start(stopRecordingAndAddAudio)
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
}
}
}
cs.liveMessage?.sent == false && cs.message.isEmpty() -> {
CancelLiveMessageButton {
cancelLiveMessage?.invoke()
}
}
else -> {
val cs = composeState.value
val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val disabled = !cs.sendEnabled() ||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
cs.endLiveDisabled
if (cs.liveMessage == null &&
cs.preview !is ComposePreview.VoicePreview && !cs.editing &&
cs.contextItem is ComposeContextItem.NoContextItem &&
sendLiveMessage != null && updateLiveMessage != null
) {
var showDropdown by rememberSaveable { mutableStateOf(false) }
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown = true }
DropdownMenu(
expanded = showDropdown,
onDismissRequest = { showDropdown = false },
Modifier.width(220.dp),
) {
ItemAction(
generalGetString(R.string.send_live_message),
Icons.Filled.Bolt,
onClick = {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
showDropdown = false
}
)
var stopRecOnNextClick by remember { mutableStateOf(false) }
val context = LocalContext.current
DisposableEffect(stopRecOnNextClick) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
if (stopRecOnNextClick) {
// Lock orientation to current orientation because screen rotation will break the recording
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
val cleanUp = { remove: Boolean ->
rec.stop()
if (remove) filePath.value?.let { File(it).delete() }
filePath.value = null
stopRecOnNextClick = false
recordingTimeRange = 0L..0L
}
LaunchedEffect(cs.preview) {
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
// Pressed on X icon in preview
cleanUp(true)
}
}
val interactionSource = interactionSourceWithTapDetection(
onPress = {
if (filePath.value == null) startStopRecording()
},
onClick = {
if (!recordingInProgress.value && filePath.value != null) {
sendMessage()
cleanUp(false)
} else if (stopRecOnNextClick) {
stopRecordingAndAddAudio()
stopRecOnNextClick = false
} else {
// tapped and didn't hold a finger
stopRecOnNextClick = true
}
},
onCancel = startStopRecording,
onRelease = startStopRecording
)
val sendButtonModifier = if (recordingTimeRange.last != 0L)
Modifier.clip(CircleShape).background(color)
else
Modifier
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
Icon(
if (recordingTimeRange.last != 0L) Icons.Outlined.ArrowUpward else if (stopRecOnNextClick) Icons.Default.Stop else Icons.Default.Mic,
stringResource(R.string.icon_descr_record_voice_message),
tint = if (recordingTimeRange.last != 0L) Color.White else if (!cs.inProgress) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.then(sendButtonModifier)
)
}
DisposableEffect(Unit) {
onDispose {
rec.stop()
}
} else {
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage)
}
}
}
@@ -172,7 +196,6 @@ fun SendMsgView(
private fun NativeKeyboard(
composeState: MutableState<ComposeState>,
textStyle: MutableState<TextStyle>,
showDeleteTextButton: MutableState<Boolean>,
onMessageChange: (String) -> Unit
) {
val cs = composeState.value
@@ -183,15 +206,19 @@ private fun NativeKeyboard(
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
var showKeyboard by remember { mutableStateOf(false) }
LaunchedEffect(cs.contextItem) {
if (cs.contextItem is ComposeContextItem.QuotedItem) {
delay(100)
showKeyboard = true
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
// Keyboard will not show up if we try to show it too fast
delay(300)
showKeyboard = true
when (cs.contextItem) {
is ComposeContextItem.QuotedItem -> {
delay(100)
showKeyboard = true
}
is ComposeContextItem.EditingItem -> {
// Keyboard will not show up if we try to show it too fast
delay(300)
showKeyboard = true
}
}
}
@@ -203,7 +230,6 @@ private fun NativeKeyboard(
) {
super.setOnReceiveContentListener(mimeTypes, listener)
}
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo)
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
@@ -250,7 +276,6 @@ private fun NativeKeyboard(
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
showKeyboard = false
}
showDeleteTextButton.value = it.lineCount >= 4
}
if (composeState.value.preview is ComposePreview.VoicePreview) {
Text(
@@ -262,296 +287,6 @@ private fun NativeKeyboard(
}
}
@Composable
private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>) {
IconButton(
{ composeState.value = composeState.value.copy(message = "") },
Modifier.align(Alignment.TopEnd).size(36.dp)
) {
Icon(Icons.Filled.Close, null, Modifier.padding(7.dp).size(36.dp), tint = HighOrLowlight)
}
}
@Composable
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
DisposableEffect(Unit) { onDispose { rec.stop() } }
val stopRecordingAndAddAudio: () -> Unit = {
recState.value.filePathNullable?.let {
recState.value = RecordingState.Finished(it, rec.stop())
}
}
if (stopRecOnNextClick.value) {
LaunchedEffect(recState.value) {
if (recState.value is RecordingState.NotStarted) {
stopRecOnNextClick.value = false
}
}
// Lock orientation to current orientation because screen rotation will break the recording
LockToCurrentOrientationUntilDispose()
StopRecordButton(stopRecordingAndAddAudio)
} else {
val startRecording: () -> Unit = {
recState.value = RecordingState.Started(
filePath = rec.start { progress: Int?, finished: Boolean ->
val state = recState.value
if (state is RecordingState.Started && progress != null) {
recState.value = if (!finished)
RecordingState.Started(state.filePath, progress)
else
RecordingState.Finished(state.filePath, progress)
}
},
)
}
val interactionSource = interactionSourceWithTapDetection(
onPress = { if (recState.value is RecordingState.NotStarted) startRecording() },
onClick = {
if (stopRecOnNextClick.value) {
stopRecordingAndAddAudio()
} else {
// tapped and didn't hold a finger
stopRecOnNextClick.value = true
}
},
onCancel = stopRecordingAndAddAudio,
onRelease = stopRecordingAndAddAudio
)
RecordVoiceButton(interactionSource)
}
}
@Composable
private fun DisallowedVoiceButton(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Outlined.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = HighOrLowlight,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Filled.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(34.dp)
.padding(4.dp)
)
}
}
@Composable
private fun LockToCurrentOrientationUntilDispose() {
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context as Activity
val manager = context.getSystemService(Activity.WINDOW_SERVICE) as WindowManager
val rotation = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) manager.defaultDisplay.rotation else activity.display?.rotation
activity.requestedOrientation = when (rotation) {
android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
}
@Composable
private fun StopRecordButton(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Filled.Stop,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
Icon(
Icons.Filled.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(34.dp)
.padding(4.dp)
)
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
}
@Composable
private fun CancelLiveMessageButton(
onClick: () -> Unit
) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Filled.Close,
stringResource(R.string.icon_descr_cancel_live_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun SendMsgButton(
icon: ImageVector,
sizeDp: Animatable<Float, AnimationVector1D>,
alpha: Animatable<Float, AnimationVector1D>,
enabled: Boolean,
sendMessage: () -> Unit,
onLongClick: (() -> Unit)? = null
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier.requiredSize(36.dp)
.combinedClickable(
onClick = sendMessage,
onLongClick = onLongClick,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(sizeDp.value.dp)
.padding(4.dp)
.alpha(alpha.value)
.clip(CircleShape)
.background(if (enabled) MaterialTheme.colors.primary else HighOrLowlight)
.padding(3.dp)
)
}
}
@Composable
private fun StartLiveMessageButton(onClick: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier.requiredSize(36.dp)
.clickable(
onClick = onClick,
enabled = true,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.Bolt,
stringResource(R.string.icon_descr_send_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
private fun startLiveMessage(
scope: CoroutineScope,
send: suspend () -> Unit,
update: suspend () -> Unit,
sendButtonSize: Animatable<Float, AnimationVector1D>,
sendButtonAlpha: Animatable<Float, AnimationVector1D>,
composeState: MutableState<ComposeState>,
liveMessageAlertShown: SharedPreference<Boolean>
) {
fun run() {
scope.launch {
while (composeState.value.liveMessage != null) {
sendButtonSize.animateTo(if (sendButtonSize.value == 36f) 32f else 36f, tween(700, 50))
}
sendButtonSize.snapTo(36f)
}
scope.launch {
while (composeState.value.liveMessage != null) {
sendButtonAlpha.animateTo(if (sendButtonAlpha.value == 1f) 0.75f else 1f, tween(700, 50))
}
sendButtonAlpha.snapTo(1f)
}
scope.launch {
delay(3000)
while (composeState.value.liveMessage != null) {
update()
delay(3000)
}
}
}
fun start() = withBGApi {
if (composeState.value.liveMessage == null) {
send()
}
run()
}
if (liveMessageAlertShown.state.value) {
start()
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.live_message),
text = generalGetString(R.string.send_live_message_desc),
confirmText = generalGetString(R.string.send_verb),
onConfirm = {
liveMessageAlertShown.set(true)
start()
})
}
}
private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.allow_voice_messages_question),
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
confirmText = generalGetString(R.string.allow_verb),
dismissText = generalGetString(R.string.cancel_verb),
onConfirm = onConfirm,
)
}
private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.voice_messages_prohibited),
text = generalGetString(
if (isDirectChat)
R.string.ask_your_contact_to_enable_voice
else
R.string.only_group_owners_can_enable_voice
)
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -565,15 +300,10 @@ fun PreviewSendMsgView() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
showVoiceRecordIcon = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
allowVoiceRecord = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle
)
}
@@ -593,15 +323,10 @@ fun PreviewSendMsgViewEditing() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateEditing) },
showVoiceRecordIcon = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
allowVoiceRecord = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle
)
}
@@ -617,19 +342,14 @@ fun PreviewSendMsgViewEditing() {
fun PreviewSendMsgViewInProgress() {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt", getAppFileUri("test.txt")), inProgress = true, useLinkPreviews = true)
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true, useLinkPreviews = true)
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) },
showVoiceRecordIcon = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
allowVoiceRecord = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle
)
}

View File

@@ -1,137 +0,0 @@
package chat.simplex.app.views.chat
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun VerifyCodeView(
displayName: String,
connectionCode: String?,
connectionVerified: Boolean,
verify: suspend (String?) -> Pair<Boolean, String>?,
close: () -> Unit,
) {
if (connectionCode != null) {
VerifyCodeLayout(
displayName,
connectionCode,
connectionVerified,
verifyCode = { newCode, cb ->
withBGApi {
val res = verify(newCode)
if (res != null) {
val (verified) = res
cb(verified)
if (verified) close()
}
}
}
)
}
}
@Composable
private fun VerifyCodeLayout(
displayName: String,
connectionCode: String,
connectionVerified: Boolean,
verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit,
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.security_code), false)
val splitCode = splitToParts(connectionCode, 24)
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
if (connectionVerified) {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 4.dp).size(22.dp), tint = HighOrLowlight)
Text(String.format(stringResource(R.string.is_verified), displayName))
} else {
Text(String.format(stringResource(R.string.is_not_verified), displayName))
}
}
SectionView {
QRCode(connectionCode, Modifier.aspectRatio(1f))
}
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.weight(2f))
SelectionContainer(Modifier.padding(vertical = DEFAULT_PADDING_HALF, horizontal = DEFAULT_PADDING_HALF)) {
Text(
splitCode,
fontFamily = FontFamily.Monospace,
fontSize = 18.sp,
maxLines = 20
)
}
val context = LocalContext.current
Box(Modifier.weight(1f)) {
IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
Icon(Icons.Filled.Share, null, tint = MaterialTheme.colors.primary)
}
}
Spacer(Modifier.weight(1f))
}
Text(
generalGetString(R.string.to_verify_compare),
Modifier.padding(bottom = DEFAULT_PADDING)
)
Row(
Modifier.padding(bottom = DEFAULT_PADDING).align(Alignment.CenterHorizontally),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (connectionVerified) {
SimpleButton(generalGetString(R.string.clear_verification), Icons.Outlined.Shield) {
verifyCode(null) {}
}
} else {
SimpleButton(generalGetString(R.string.scan_code), Icons.Outlined.QrCode) {
ModalManager.shared.showModal {
ScanCodeView(verifyCode) { }
}
}
SimpleButton(generalGetString(R.string.mark_code_verified), Icons.Outlined.VerifiedUser) {
verifyCode(connectionCode) { verified ->
if (!verified) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.incorrect_code)
)
}
}
}
}
}
}
}
private fun splitToParts(s: String, length: Int): String {
if (length >= s.length) return s
return (0..(s.length - 1) / length)
.map { s.drop(it * length).take(length) }
.joinToString(separator = "\n")
}

View File

@@ -31,23 +31,17 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
@Composable
fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) {
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
val selectedContacts = remember { mutableStateListOf<Long>() }
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
var allowModifyMembers by remember { mutableStateOf(true) }
BackHandler(onBack = close)
AddGroupMembersLayout(
groupInfo = groupInfo,
creatingGroup = creatingGroup,
contactsToAdd = getContactsToAdd(chatModel),
selectedContacts = selectedContacts,
selectedRole = selectedRole,
allowModifyMembers = allowModifyMembers,
openPreferences = {
ModalManager.shared.showCustomModal { close ->
GroupPreferencesView(chatModel, groupInfo.id, close)
}
},
inviteMembers = {
allowModifyMembers = false
withApi {
@@ -65,7 +59,6 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
clearSelection = { selectedContacts.clear() },
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
close = close,
)
}
@@ -86,17 +79,14 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
@Composable
fun AddGroupMembersLayout(
groupInfo: GroupInfo,
creatingGroup: Boolean,
contactsToAdd: List<Contact>,
selectedContacts: List<Long>,
selectedRole: MutableState<GroupMemberRole>,
allowModifyMembers: Boolean,
openPreferences: () -> Unit,
inviteMembers: () -> Unit,
clearSelection: () -> Unit,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit,
close: () -> Unit,
) {
Column(
Modifier
@@ -130,28 +120,18 @@ fun AddGroupMembersLayout(
}
} else {
SectionView {
if (creatingGroup) {
SectionItemView(openPreferences) {
Text(stringResource(R.string.set_group_preferences))
}
SectionDivider()
}
SectionItemView {
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
}
SectionDivider()
if (creatingGroup && selectedContacts.isEmpty()) {
SkipInvitingButton(close)
} else {
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
}
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
}
SectionCustomFooter {
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
}
SectionSpacer()
SectionView(stringResource(R.string.select_contacts)) {
SectionView {
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
}
SectionSpacer()
@@ -190,17 +170,6 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
)
}
@Composable
fun SkipInvitingButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Check,
stringResource(R.string.skip_inviting_button),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
)
}
@Composable
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
Row(
@@ -319,17 +288,14 @@ fun PreviewAddGroupMembersLayout() {
SimpleXTheme {
AddGroupMembersLayout(
groupInfo = GroupInfo.sampleData,
creatingGroup = false,
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
selectedContacts = remember { mutableStateListOf() },
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
allowModifyMembers = true,
openPreferences = {},
inviteMembers = {},
clearSelection = {},
addContact = {},
removeContact = {},
close = {},
removeContact = {}
)
}
}

View File

@@ -4,9 +4,7 @@ import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -24,17 +22,15 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
import chat.simplex.app.views.chatlist.setGroupMembers
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
@Composable
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdated: (String?) -> Unit, close: () -> Unit) {
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
@@ -47,56 +43,32 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdat
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedBy { it.displayName.lowercase() },
developerTools,
groupLink,
addMembers = {
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, false, chatModel, close)
AddGroupMembersView(groupInfo, chatModel, close)
}
}
},
showMemberInfo = { member ->
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
val (_, code) = if (member.memberActive) {
try {
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
member to null
}
} else {
member to null
}
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
closeCurrent()
close()
}
}
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
}
}
},
editGroupProfile = {
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
openPreferences = {
ModalManager.shared.showCustomModal { close ->
GroupPreferencesView(
chatModel,
chat.id,
close
)
}
},
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
manageGroupLink = {
withApi {
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, onGroupLinkUpdated) }
val groupLink = chatModel.controller.apiGetGroupLink(groupInfo.groupId)
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink) }
}
}
)
@@ -145,11 +117,9 @@ fun GroupChatInfoLayout(
groupInfo: GroupInfo,
members: List<GroupMember>,
developerTools: Boolean,
groupLink: String?,
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
openPreferences: () -> Unit,
deleteGroup: () -> Unit,
clearChat: () -> Unit,
leaveGroup: () -> Unit,
@@ -169,25 +139,9 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
SectionDivider()
}
GroupPreferencesButton(openPreferences)
}
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
SectionSpacer()
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
SectionItemView(manageGroupLink) {
if (groupLink == null) {
CreateGroupLinkButton()
} else {
GroupLinkButton()
}
}
SectionItemView(manageGroupLink) { GroupLinkButton() }
SectionDivider()
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
SectionItemView(onAddMembersClick) {
@@ -206,6 +160,10 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
SectionDivider()
}
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
SectionDivider()
@@ -230,7 +188,7 @@ fun GroupChatInfoLayout(
}
@Composable
private fun GroupChatInfoHeader(cInfo: ChatInfo) {
fun GroupChatInfoHeader(cInfo: ChatInfo) {
Column(
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
@@ -254,16 +212,7 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) {
}
@Composable
private fun GroupPreferencesButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.group_preferences),
click = onClick
)
}
@Composable
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
Row(
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
@@ -279,7 +228,7 @@ private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
}
@Composable
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
Column {
members.forEachIndexed { index, member ->
SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
@@ -293,7 +242,7 @@ private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember
}
@Composable
private fun MemberRow(member: GroupMember, user: Boolean = false) {
fun MemberRow(member: GroupMember, user: Boolean = false) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -305,15 +254,10 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) {
) {
ProfileImage(size = 46.dp, member.image)
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.verified) {
MemberVerifiedShield()
}
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
}
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
val s = member.memberStatus.shortText
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
Text(
@@ -333,12 +277,7 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) {
}
@Composable
private fun MemberVerifiedShield() {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 3.dp).size(16.dp), tint = HighOrLowlight)
}
@Composable
private fun GroupLinkButton() {
fun GroupLinkButton() {
Row(
Modifier
.fillMaxSize(),
@@ -347,27 +286,10 @@ private fun GroupLinkButton() {
Icon(
Icons.Outlined.Link,
stringResource(R.string.group_link),
tint = HighOrLowlight
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.group_link))
}
}
@Composable
private fun CreateGroupLinkButton() {
Row(
Modifier
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.AddLink,
stringResource(R.string.create_group_link),
tint = HighOrLowlight
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.create_group_link))
Text(stringResource(R.string.group_link), color = MaterialTheme.colors.primary)
}
}
@@ -381,15 +303,15 @@ fun EditGroupProfileButton() {
Icon(
Icons.Outlined.Edit,
stringResource(R.string.button_edit_group_profile),
tint = HighOrLowlight
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_edit_group_profile))
Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
}
}
@Composable
private fun LeaveGroupButton() {
fun LeaveGroupButton() {
Row(
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
@@ -405,7 +327,7 @@ private fun LeaveGroupButton() {
}
@Composable
private fun DeleteGroupButton() {
fun DeleteGroupButton() {
Row(
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
@@ -427,13 +349,13 @@ fun PreviewGroupChatInfoLayout() {
GroupChatInfoLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = arrayListOf()
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
groupInfo = GroupInfo.sampleData,
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
groupLink = null,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
)
}
}

View File

@@ -1,12 +1,10 @@
package chat.simplex.app.views.chat.group
import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -17,32 +15,22 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.GroupInfo
import chat.simplex.app.ui.theme.*
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, onGroupLinkUpdated: (String?) -> Unit) {
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
var creatingLink by rememberSaveable { mutableStateOf(false) }
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?) {
var groupLink by remember { mutableStateOf(connReqContact) }
val cxt = LocalContext.current
fun createLink() {
creatingLink = true
withApi {
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
onGroupLinkUpdated(groupLink)
creatingLink = false
}
}
LaunchedEffect(Unit) {
if (groupLink == null && !creatingLink) {
createLink()
}
}
GroupLinkLayout(
groupLink = groupLink,
creatingLink,
createLink = ::createLink,
createLink = {
withApi {
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
}
},
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
deleteLink = {
AlertManager.shared.showAlertMsg(
@@ -54,22 +42,17 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
if (r) {
groupLink = null
onGroupLinkUpdated(null)
}
}
}
)
}
)
if (creatingLink) {
ProgressIndicator()
}
}
@Composable
fun GroupLinkLayout(
groupLink: String?,
creatingLink: Boolean,
createLink: () -> Unit,
share: () -> Unit,
deleteLink: () -> Unit
@@ -91,7 +74,7 @@ fun GroupLinkLayout(
verticalArrangement = Arrangement.SpaceEvenly
) {
if (groupLink == null) {
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink)
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, click = createLink)
} else {
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f))
Row(
@@ -116,18 +99,3 @@ fun GroupLinkLayout(
}
}
@Composable
fun ProgressIndicator() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}

View File

@@ -5,7 +5,6 @@ import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -13,7 +12,6 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -23,20 +21,19 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chat.SimplexServers
import chat.simplex.app.views.chat.SwitchAddressButton
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import kotlinx.datetime.Clock
@Composable
fun GroupMemberInfoView(
groupInfo: GroupInfo,
member: GroupMember,
connStats: ConnectionStats?,
connectionCode: String?,
chatModel: ChatModel,
close: () -> Unit,
closeAll: () -> Unit, // Close all open windows up to ChatView
@@ -52,25 +49,20 @@ fun GroupMemberInfoView(
connStats,
newRole,
developerTools,
connectionCode,
getContactChat = { chatModel.getContactChat(it) },
knownDirectChat = {
openDirectChat = {
withApi {
chatModel.chatItems.clear()
chatModel.chatItems.addAll(it.chatItems)
chatModel.chatId.value = it.chatInfo.id
closeAll()
}
},
newDirectChat = {
withApi {
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
if (c != null) {
chatModel.addChat(c)
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
if (oldChat != null) {
openChat(oldChat.chatInfo, chatModel)
} else {
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
newChat = newChat.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
chatModel.addChat(newChat)
chatModel.chatItems.clear()
chatModel.chatId.value = c.id
closeAll()
chatModel.chatId.value = newChat.id
}
closeAll()
}
},
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
@@ -93,32 +85,6 @@ fun GroupMemberInfoView(
},
switchMemberAddress = {
switchMemberAddress(chatModel, groupInfo, member)
},
verifyClicked = {
ModalManager.shared.showModalCloseable { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
connectionCode,
mem.verified,
verify = { code ->
chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r ->
val (verified, existingCode) = r
chatModel.upsertGroupMember(
groupInfo,
mem.copy(
activeConn = mem.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
)
r
}
},
close,
)
}
}
}
)
}
@@ -148,14 +114,10 @@ fun GroupMemberInfoLayout(
connStats: ConnectionStats?,
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
connectionCode: String?,
getContactChat: (Long) -> Chat?,
knownDirectChat: (Chat) -> Unit,
newDirectChat: (Long) -> Unit,
openDirectChat: () -> Unit,
removeMember: () -> Unit,
onRoleSelected: (GroupMemberRole) -> Unit,
switchMemberAddress: () -> Unit,
verifyClicked: () -> Unit,
) {
Column(
Modifier
@@ -171,29 +133,10 @@ fun GroupMemberInfoLayout(
}
SectionSpacer()
if (member.memberActive) {
val contactId = member.memberContactId
if (contactId != null) {
SectionView {
val chat = getContactChat(contactId)
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
OpenChatButton(onClick = { knownDirectChat(chat) })
if (connectionCode != null) {
SectionDivider()
}
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
OpenChatButton(onClick = { newDirectChat(contactId) })
if (connectionCode != null) {
SectionDivider()
}
}
if (connectionCode != null) {
VerifyCodeButton(member.verified, verifyClicked)
}
}
SectionSpacer()
}
SectionView {
OpenChatButton(openDirectChat)
}
SectionSpacer()
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
@@ -216,10 +159,12 @@ fun GroupMemberInfoLayout(
}
}
SectionSpacer()
if (connStats != null) {
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SwitchAddressButton(switchMemberAddress)
SectionDivider()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
if (developerTools) {
SwitchAddressButton(switchMemberAddress)
SectionDivider()
}
if (connStats != null) {
val rcvServers = connStats.rcvServers
val sndServers = connStats.sndServers
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
@@ -234,8 +179,8 @@ fun GroupMemberInfoLayout(
}
}
}
SectionSpacer()
}
SectionSpacer()
if (member.canBeRemoved(groupInfo)) {
SectionView {
@@ -262,17 +207,12 @@ fun GroupMemberInfoHeader(member: GroupMember) {
horizontalAlignment = Alignment.CenterHorizontally
) {
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.verified) {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
}
Text(
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (member.fullName != "" && member.fullName != member.displayName) {
Text(
member.fullName, style = MaterialTheme.typography.h2,
@@ -362,14 +302,10 @@ fun PreviewGroupMemberInfoLayout() {
connStats = null,
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
connectionCode = "123",
getContactChat = { Chat.sampleData },
knownDirectChat = {},
newDirectChat = {},
openDirectChat = {},
removeMember = {},
onRoleSelected = {},
switchMemberAddress = {},
verifyClicked = {},
)
}
}

View File

@@ -1,183 +0,0 @@
package chat.simplex.app.views.chat.group
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.TimedMessagesTTLPicker
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
@Composable
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } }
val gInfo = groupInfo.value ?: return
var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) }
fun savePrefs(afterSave: () -> Unit = {}) {
withApi {
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
if (gInfo != null) {
m.updateGroup(gInfo)
currentPreferences = preferences
}
afterSave()
}
}
ModalView(
close = {
if (preferences == currentPreferences) close()
else showUnsavedChangesAlert({ savePrefs(close) }, close)
},
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupPreferencesLayout(
preferences,
currentPreferences,
gInfo,
applyPrefs = { prefs ->
preferences = prefs
},
reset = {
preferences = currentPreferences
},
savePrefs = ::savePrefs,
)
}
}
@Composable
private fun GroupPreferencesLayout(
preferences: FullGroupPreferences,
currentPreferences: FullGroupPreferences,
groupInfo: GroupInfo,
applyPrefs: (FullGroupPreferences) -> Unit,
reset: () -> Unit,
savePrefs: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.group_preferences))
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
val onTTLUpdated = { ttl: Int? ->
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl ?: 86400)))
}
FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
if (enable == GroupFeatureEnabled.ON) {
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400)))
} else {
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl)))
}
}
SectionSpacer()
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
}
SectionSpacer()
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
}
SectionSpacer()
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
}
if (groupInfo.canEdit) {
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = preferences == currentPreferences
)
}
}
}
@Composable
private fun FeatureSection(
feature: GroupFeature,
enableFeature: State<GroupFeatureEnabled>,
groupInfo: GroupInfo,
preferences: FullGroupPreferences,
onTTLUpdated: (Int?) -> Unit,
onSelected: (GroupFeatureEnabled) -> Unit
) {
SectionView {
val on = enableFeature.value == GroupFeatureEnabled.ON
val icon = if (on) feature.iconFilled else feature.icon
val iconTint = if (on) SimplexGreen else HighOrLowlight
val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON
if (groupInfo.canEdit) {
SectionItemView {
PreferenceToggleWithIcon(
feature.text,
icon,
iconTint,
enableFeature.value == GroupFeatureEnabled.ON,
) { checked ->
onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF)
}
}
if (timedOn) {
SectionDivider()
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
TimedMessagesTTLPicker(ttl, onTTLUpdated)
}
} else {
InfoRow(
feature.text,
enableFeature.value.text,
icon = icon,
iconTint = iconTint,
)
if (timedOn) {
SectionDivider()
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
}
}
}
SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
SectionDivider()
SectionItemView(save, disabled = disabled) {
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(R.string.save_preferences_question),
confirmText = generalGetString(R.string.save_and_notify_group_members),
dismissText = generalGetString(R.string.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}

View File

@@ -136,13 +136,7 @@ fun GroupProfileLayout(
if (enabled) {
Text(
stringResource(R.string.save_group_profile),
modifier = Modifier.clickable {
saveProfile(groupProfile.copy(
displayName = displayName.value,
fullName = fullName.value,
image = profileImage.value
))
},
modifier = Modifier.clickable { saveProfile(GroupProfile(displayName.value, fullName.value, profileImage.value)) },
color = MaterialTheme.colors.primary
)
} else {

View File

@@ -24,7 +24,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
Modifier
.padding(horizontal = 4.dp)
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green)
when (status) {
CICallStatus.Pending -> if (sent) {
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
@@ -38,7 +38,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
CICallStatus.Ended -> Row {
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
Text(durationText(duration), color = HighOrLowlight)
Text(status.duration(duration), color = HighOrLowlight)
}
CICallStatus.Error -> {}
}

View File

@@ -1,34 +0,0 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
@Composable
fun CIChatFeatureView(
chatItem: ChatItem,
feature: Feature,
iconColor: Color,
icon: ImageVector? = null
) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(icon ?: feature.iconFilled, feature.text, Modifier.size(18.dp), tint = iconColor)
Text(
chatEventText(chatItem),
Modifier,
// this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp
fontSize = 12.sp
)
}
}

View File

@@ -18,38 +18,32 @@ import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun CIEventView(ci: ChatItem) {
@Composable
fun chatEventTextView(text: AnnotatedString) {
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
}
Surface {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.Bottom
) {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
chatEventTextView(
buildAnnotatedString {
withStyle(chatEventStyle) { append(memberDisplayName) }
Text(
buildAnnotatedString {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
withGroupEventStyle(this, memberDisplayName)
append(" ")
}.plus(chatEventText(ci))
)
} else {
chatEventTextView(chatEventText(ci))
}
}
withGroupEventStyle(this, ci.content.text)
append(" ")
withGroupEventStyle(this, ci.timestampText)
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
)
}
}
}
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
fun chatEventText(ci: ChatItem): AnnotatedString =
buildAnnotatedString {
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,

View File

@@ -1,54 +0,0 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun CIFeaturePreferenceView(
chatItem: ChatItem,
contact: Contact?,
feature: ChatFeature,
allowed: FeatureAllowed,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = HighOrLowlight)
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
val acceptTextId = if (setParam) R.string.accept_feature_set_1_day else R.string.accept_feature
val param = if (setParam) 86400 else null
val annotatedText = buildAnnotatedString {
withStyle(chatEventStyle) { append(chatItem.content.text + " ") }
withAnnotation(tag = "Accept", annotation = "Accept") {
withStyle(acceptStyle) { append(generalGetString(acceptTextId) + " ") }
}
withStyle(chatEventStyle) { append(chatItem.timestampText) }
}
fun accept(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Accept", start = offset, end = offset).isNotEmpty()
ClickableText(
annotatedText,
onClick = { if (accept(it)) { acceptFeature(contact, feature, param) } },
shouldConsumeEvent = ::accept
)
} else {
Text(chatItem.content.text + " " + chatItem.timestampText,
fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
}
}
}

View File

@@ -205,6 +205,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
FramedItemView(ChatInfo.Direct.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
}
}

View File

@@ -1,46 +0,0 @@
package chat.simplex.app.views.chat.item
import SectionSpacer
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
@Composable
fun CIInvalidJSONView(json: String) {
Row(Modifier
.clickable { ModalManager.shared.showModal(true) { InvalidJSONView(json) } }
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Text(stringResource(R.string.invalid_data), color = Color.Red, fontStyle = FontStyle.Italic)
}
}
@Composable
fun InvalidJSONView(json: String) {
Column {
Spacer(Modifier.height(DEFAULT_PADDING))
SectionView {
val context = LocalContext.current
SettingsActionItem(Icons.Outlined.Share, generalGetString(R.string.share_verb), click = {
shareText(context, json)
})
}
Column(Modifier.padding(DEFAULT_PADDING).fillMaxWidth().verticalScroll(rememberScrollState())) {
Text(json)
}
}
}

View File

@@ -3,85 +3,61 @@ package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import kotlinx.datetime.Clock
@Composable
fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = HighOrLowlight) {
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
if (chatItem.isDeletedContent) {
Text(
chatItem.timestampText,
color = metaColor,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
} else {
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor)
fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!chatItem.isDeletedContent) {
if (chatItem.meta.itemEdited) {
Icon(
Icons.Filled.Edit,
modifier = Modifier.height(12.dp).padding(end = 1.dp),
contentDescription = stringResource(R.string.icon_descr_edited),
tint = metaColor,
)
}
CIStatusView(chatItem.meta.itemStatus, metaColor)
}
Text(
chatItem.timestampText,
color = metaColor,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
}
}
@Composable
// changing this function requires updating reserveSpaceForMeta
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
if (meta.itemEdited) {
StatusIconText(Icons.Outlined.Edit, color)
Spacer(Modifier.width(3.dp))
}
if (meta.disappearing) {
StatusIconText(Icons.Outlined.Timer, color)
val ttl = meta.itemTimed?.ttl
if (ttl != chatTTL) {
Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
when (status) {
is CIStatus.SndSent -> {
Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
}
Spacer(Modifier.width(4.dp))
}
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
if (statusIcon != null) {
val (icon, statusColor) = statusIcon
StatusIconText(icon, statusColor)
Spacer(Modifier.width(4.dp))
} else if (!meta.disappearing) {
StatusIconText(Icons.Filled.Circle, Color.Transparent)
Spacer(Modifier.width(4.dp))
}
Text(meta.timestampText, color = color, fontSize = 13.sp)
}
// the conditions in this function should match CIMetaText
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
val iconSpace = " "
var res = ""
if (meta.itemEdited) res += iconSpace
if (meta.itemTimed != null) {
res += iconSpace
val ttl = meta.itemTimed.ttl
if (ttl != chatTTL) {
res += TimedMessagesPreference.shortTtlText(ttl)
is CIStatus.SndErrorAuth -> {
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
}
is CIStatus.SndError -> {
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
}
is CIStatus.RcvNew -> {
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
}
else -> {}
}
if (meta.statusIcon(HighOrLowlight) != null || !meta.disappearing) {
res += iconSpace
}
return res + meta.timestampText
}
@Composable
private fun StatusIconText(icon: ImageVector, color: Color) {
Icon(icon, null, Modifier.height(12.dp), tint = color)
}
@Preview
@@ -90,8 +66,7 @@ fun PreviewCIMetaView() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
null
)
)
}
@@ -102,8 +77,7 @@ fun PreviewCIMetaViewUnread() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.RcvNew()
),
null
)
)
}
@@ -113,9 +87,8 @@ fun PreviewCIMetaViewSendFailed() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.SndError("CMD SYNTAX")
),
null
status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX()))
)
)
}
@@ -125,8 +98,7 @@ fun PreviewCIMetaViewSendNoAuth() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
),
null
)
)
}
@@ -136,8 +108,7 @@ fun PreviewCIMetaViewSendSent() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
),
null
)
)
}
@@ -148,8 +119,7 @@ fun PreviewCIMetaViewEdited() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true
),
null
)
)
}
@@ -161,8 +131,7 @@ fun PreviewCIMetaViewEditedUnread() {
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
itemEdited = true,
status=CIStatus.RcvNew()
),
null
)
)
}
@@ -174,8 +143,7 @@ fun PreviewCIMetaViewEditedSent() {
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true,
status=CIStatus.SndSent()
),
null
)
)
}
@@ -183,7 +151,6 @@ fun PreviewCIMetaViewEditedSent() {
@Composable
fun PreviewCIMetaViewDeletedContent() {
CIMetaView(
chatItem = ChatItem.getDeletedContentSampleData(),
null
chatItem = ChatItem.getDeletedContentSampleData()
)
}

View File

@@ -1,6 +1,5 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -19,26 +18,26 @@ import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@Composable
fun CIVoiceView(
providedDurationSec: Int,
durationSec: Int,
file: CIFile?,
edited: Boolean,
sent: Boolean,
hasText: Boolean,
ci: ChatItem,
timedMessagesTTL: Int?,
longClick: () -> Unit,
metaColor: Color
) {
Row(
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = 6.dp, end = 6.dp),
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (file != null) {
@@ -46,27 +45,86 @@ fun CIVoiceView(
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
val play = {
AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
val audioInfo = remember(file.filePath) {
file.audioInfo.value = file.audioInfo.value.copy(durationMs = durationSec * 1000)
file.audioInfo
}
val play = play@{
audioPlaying.value = AudioPlayer.start(filePath ?: return@play, audioInfo.value.progressMs) {
// If you want to preserve the position after switching a track, remove this line
audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs)
audioPlaying.value = false
}
brokenAudio = !audioPlaying.value
}
val pause = {
AudioPlayer.pause(audioPlaying, progress)
audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs)
audioPlaying.value = false
}
val text = remember {
derivedStateOf {
val time = when {
audioPlaying.value || progress.value != 0 -> progress.value
else -> duration.value
AudioInfoUpdater(filePath, audioPlaying, audioInfo)
val time = if (audioPlaying.value) audioInfo.value.progressMs else audioInfo.value.durationMs
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
val text = String.format("%02d:%02d", time / 1000 / 60, time / 1000 % 60)
if (hasText) {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Text(
text,
Modifier
.padding(start = 12.dp, end = 5.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
textAlign = TextAlign.Start,
maxLines = 1
)
} else {
if (sent) {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
Text(
text,
Modifier
.padding(end = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
}
} else {
Row {
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text,
Modifier
.padding(start = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
Spacer(Modifier.height(56.dp))
}
}
durationText(time / 1000)
}
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick)
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
VoiceMsgIndicator(null, false, sent, hasText, null, false, {}, {})
val metaReserve = if (edited)
" "
else
@@ -76,73 +134,6 @@ fun CIVoiceView(
}
}
@Composable
private fun VoiceLayout(
file: CIFile,
ci: ChatItem,
text: State<String>,
audioPlaying: State<Boolean>,
progress: State<Int>,
duration: State<Int>,
brokenAudio: Boolean,
sent: Boolean,
hasText: Boolean,
timedMessagesTTL: Int?,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
) {
when {
hasText -> {
Spacer(Modifier.width(6.dp))
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
DurationText(text, PaddingValues(start = 12.dp))
}
sent -> {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
DurationText(text, PaddingValues(end = 12.dp))
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
}
}
}
}
else -> {
Row {
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
DurationText(text, PaddingValues(start = 12.dp))
Spacer(Modifier.height(56.dp))
}
}
}
}
}
@Composable
private fun DurationText(text: State<String>, padding: PaddingValues) {
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
Text(
text.value,
Modifier
.padding(padding)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
}
@Composable
private fun PlayPauseButton(
audioPlaying: Boolean,
@@ -153,21 +144,17 @@ private fun PlayPauseButton(
enabled: Boolean,
error: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
pause: () -> Unit
) {
Surface(
onClick = { if (!audioPlaying) play() else pause() },
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
color = if (sent) SentColorLight else ReceivedColorLight,
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
) {
Box(
Modifier
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp)
.combinedClickable(
onClick = { if (!audioPlaying) play() else pause() },
onLongClick = longClick
),
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp),
contentAlignment = Alignment.Center
) {
Icon(
@@ -186,19 +173,17 @@ private fun VoiceMsgIndicator(
audioPlaying: Boolean,
sent: Boolean,
hasText: Boolean,
progress: State<Int>?,
duration: State<Int>?,
audioInfo: State<ProgressAndDuration>?,
error: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
pause: () -> Unit
) {
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
if (file != null && file.loaded && progress != null && duration != null) {
val angle = 360f * (progress.value.toDouble() / duration.value).toFloat()
if (file != null && file.loaded && audioInfo != null) {
val angle = 360f * (audioInfo.value.progressMs.toDouble() / audioInfo.value.durationMs).toFloat()
if (hasText) {
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) {
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) {
Icon(
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = null,
@@ -207,13 +192,12 @@ private fun VoiceMsgIndicator(
)
}
} else {
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause)
}
} else {
if (file?.fileStatus == CIFileStatus.RcvInvitation
|| file?.fileStatus == CIFileStatus.RcvTransfer
|| file?.fileStatus == CIFileStatus.RcvAccepted
) {
|| file?.fileStatus == CIFileStatus.RcvAccepted) {
Box(
Modifier
.size(56.dp)
@@ -223,7 +207,7 @@ private fun VoiceMsgIndicator(
ProgressIndicator()
}
} else {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {}, longClick)
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {})
}
}
}
@@ -258,3 +242,26 @@ private fun ProgressIndicator() {
strokeWidth = 4.dp
)
}
@Composable
fun AudioInfoUpdater(
filePath: String?,
audioPlaying: MutableState<Boolean>,
audioInfo: MutableState<ProgressAndDuration>
) {
LaunchedEffect(filePath) {
if (filePath != null && audioInfo.value.durationMs == 0) {
audioInfo.value = ProgressAndDuration(audioInfo.value.progressMs, AudioPlayer.duration(filePath))
}
}
LaunchedEffect(audioPlaying.value) {
while (isActive && audioPlaying.value) {
audioInfo.value = AudioPlayer.progressAndDurationOrEnded()
if (audioInfo.value.progressMs == audioInfo.value.durationMs) {
audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs)
audioPlaying.value = false
}
delay(50)
}
}
}

View File

@@ -20,15 +20,12 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ComposeContextItem
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
@Composable
fun ChatItemView(
cInfo: ChatInfo,
@@ -37,25 +34,18 @@ fun ChatItemView(
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
scrollToItem: (Long) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
val revealed = remember { mutableStateOf(false) }
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
val onLinkLongClick = { _: String -> showMenu.value = true }
val live = composeState.value.liveMessage != null
Box(
modifier = Modifier
.padding(bottom = 4.dp)
@@ -68,7 +58,7 @@ fun ChatItemView(
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
}
is CIStatus.SndError -> {
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError.string}")
}
else -> {}
}
@@ -78,36 +68,26 @@ fun ChatItemView(
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
fun deleteMessageQuestionText(): String {
return if (fullDeleteAllowed) {
generalGetString(R.string.delete_message_cannot_be_undone_warning)
@Composable fun ContentItem() {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
generalGetString(R.string.delete_message_mark_deleted_warning)
val onLinkLongClick = { _: String -> showMenu.value = true }
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
}
@Composable
fun MsgContentItemDropdownMenu() {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
if (!cItem.meta.itemDeleted && !live) {
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
@@ -134,78 +114,40 @@ fun ChatItemView(
})
}
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false
})
}
if (cItem.meta.itemDeleted && revealed.value) {
ItemAction(
stringResource(R.string.hide_verb),
Icons.Outlined.VisibilityOff,
onClick = {
revealed.value = false
showMenu.value = false
}
)
}
if (!(live && cItem.meta.isLive)) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
}
@Composable
fun MarkedDeletedItemDropdownMenu() {
@Composable fun DeletedItem() {
DeletedItemView(cItem, showMember = showMember)
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(
stringResource(R.string.reveal_verb),
Icons.Outlined.Visibility,
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
revealed.value = true
showMenu.value = false
}
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted && !revealed.value) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemDropdownMenu()
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted && !cItem.meta.isLive) {
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem, cInfo.timedMessagesTTL)
MsgContentItemDropdownMenu()
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
MsgContentItemDropdownMenu()
} else {
framedItemView()
MsgContentItemDropdownMenu()
}
} else {
framedItemView()
MsgContentItemDropdownMenu()
}
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@@ -220,48 +162,18 @@ fun ChatItemView(
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
is CIContent.SndGroupEventContent -> CIEventView(cItem)
is CIContent.RcvConnEventContent -> CIEventView(cItem)
is CIContent.SndConnEventContent -> CIEventView(cItem)
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvChatPreference -> {
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
}
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, HighOrLowlight, icon = c.feature.icon,)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
}
}
}
}
@Composable
fun DeleteItemAction(
cItem: ChatItem,
showMenu: MutableState<Boolean>,
questionText: String,
deleteMessage: (Long, CIDeleteMode) -> Unit
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
@Composable
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
DropdownMenuItem(onClick) {
@@ -279,10 +191,10 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
}
}
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(R.string.delete_message__question),
text = questionText,
text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
buttons = {
Row(
Modifier
@@ -323,14 +235,12 @@ fun PreviewChatItemView() {
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> }
)
}
}
@@ -343,14 +253,12 @@ fun PreviewChatItemViewDeletedContent() {
ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(),
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> }
)
}
}

View File

@@ -18,7 +18,7 @@ import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
Surface(
shape = RoundedCornerShape(18.dp),
color = ReceivedColorLight,
@@ -35,7 +35,7 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean =
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci, timedMessagesTTL)
CIMetaView(ci)
}
}
}
@@ -49,8 +49,7 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean =
fun PreviewDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getDeletedContentSampleData(),
null
ChatItem.getDeletedContentSampleData()
)
}
}

View File

@@ -15,13 +15,13 @@ val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
@Composable
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
fun EmojiItemView(chatItem: ChatItem) {
Column(
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(chatItem.content.text)
CIMetaView(chatItem, timedMessagesTTL)
CIMetaView(chatItem)
}
}

View File

@@ -5,8 +5,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -14,19 +14,18 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.fastMap
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.helpers.ChatItemLinkView
import chat.simplex.app.views.helpers.base64ToBitmap
import kotlinx.datetime.Clock
val SentColorLight = Color(0x1E45B8FF)
@@ -41,14 +40,12 @@ fun FramedItemView(
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
linkMode: SimplexLinkMode,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
onLinkLongClick: (link: String) -> Unit = {},
scrollToItem: (Long) -> Unit = {},
) {
val sent = ci.chatDir.sent
val chatTTL = chatInfo.timedMessagesTTL
fun membership(): GroupMember? {
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
@@ -60,41 +57,13 @@ fun FramedItemView(
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopStart
) {
val text = if (qi.content is MsgContent.MCVoice && qi.text.isEmpty())
qi.content.toTextWithDuration(true)
else
qi.text
MarkdownText(
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
linkMode = linkMode
)
}
}
@Composable
fun FramedItemHeader(caption: String, italic: Boolean, icon: ImageVector? = null) {
Row(
Modifier
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
.fillMaxWidth()
.padding(start = 8.dp)
.padding(end = 12.dp)
.padding(top = 6.dp)
.padding(bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (icon != null) {
Icon(
icon,
caption,
Modifier.size(18.dp),
tint = if (isInDarkTheme()) FileDark else FileLight
)
}
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = HighOrLowlight)) {
append(caption)
}
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
)
}
}
@@ -128,7 +97,7 @@ fun FramedItemView(
ciQuotedMsgView(qi)
}
Icon(
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.Mic,
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.PlayArrow,
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
Modifier
.padding(top = 6.dp, end = 4.dp)
@@ -141,16 +110,7 @@ fun FramedItemView(
}
}
@Composable
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (text != "" || ci.meta.isLive) {
CIMarkdownText(ci, chatTTL, showMember, linkMode = linkMode, uriHandler)
}
}
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVoice) && ci.content.text.isEmpty() && ci.quotedItem == null
Box(Modifier
.clip(RoundedCornerShape(18.dp))
.background(
@@ -164,13 +124,8 @@ fun FramedItemView(
Box(contentAlignment = Alignment.BottomEnd) {
Column(Modifier.width(IntrinsicSize.Max)) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
if (ci.meta.itemDeleted) {
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete)
} else if (ci.meta.isLive) {
FramedItemHeader(stringResource(R.string.live), false)
}
ci.quotedItem?.let { ciQuoteView(it) }
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
Column(
Modifier
@@ -186,36 +141,37 @@ fun FramedItemView(
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
if (mc.text == "") {
metaColor = Color.White
} else {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
CIMarkdownText(ci, showMember, uriHandler)
}
}
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, mc.text != "" || ci.quotedItem != null, ci, metaColor)
if (mc.text != "") {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
CIMarkdownText(ci, showMember, uriHandler)
}
}
is MsgContent.MCFile -> ciFileView(ci, mc.text)
is MsgContent.MCUnknown ->
if (ci.file == null) {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
} else {
ciFileView(ci, mc.text)
is MsgContent.MCFile -> {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (mc.text != "") {
CIMarkdownText(ci, showMember, uriHandler)
}
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
}
else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
}
}
}
}
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, chatTTL, metaColor)
if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
}
}
}
}
@@ -224,17 +180,14 @@ fun FramedItemView(
@Composable
fun CIMarkdownText(
ci: ChatItem,
chatTTL: Int?,
showMember: Boolean,
linkMode: SimplexLinkMode,
uriHandler: UriHandler?,
onLinkLongClick: (link: String) -> Unit = {}
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
MarkdownText(
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null,
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
metaText = ci.timestampText, edited = ci.meta.itemEdited,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
)
}
@@ -285,7 +238,6 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -302,7 +254,6 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -323,7 +274,6 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -345,7 +295,6 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -367,7 +316,6 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -396,7 +344,6 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -425,7 +372,6 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -453,7 +399,6 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)

View File

@@ -77,14 +77,12 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
val image = provider.getImage(index)
if (image == null) {
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
SideEffect {
scope.launch {
when (settledCurrentPage) {
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
index + 1 -> {
provider.scrollToStart()
pagerState.scrollToPage(0)
}
scope.launch {
when (settledCurrentPage) {
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
index + 1 -> {
provider.scrollToStart()
pagerState.scrollToPage(0)
}
}
}

View File

@@ -22,7 +22,7 @@ import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
Surface(
Modifier.clickable(onClick = {
AlertManager.shared.showAlertMsg(
@@ -45,7 +45,7 @@ fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boo
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci, timedMessagesTTL)
CIMetaView(ci)
}
}
}
@@ -59,8 +59,7 @@ fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boo
fun IntegrityErrorItemViewPreview() {
SimpleXTheme {
IntegrityErrorItemView(
ChatItem.getDeletedContentSampleData(),
null
ChatItem.getDeletedContentSampleData()
)
}
}

View File

@@ -1,58 +0,0 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
Surface(
shape = RoundedCornerShape(18.dp),
color = if (ci.chatDir.sent) SentColorLight else ReceivedColorLight,
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
buildAnnotatedString {
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci, timedMessagesTTL)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun PreviewMarkedDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getSampleData(itemDeleted = true),
null
)
}
}

View File

@@ -1,30 +1,20 @@
package chat.simplex.app.views.chat.item
import android.app.Activity
import android.content.ActivityNotFoundException
import android.util.Log
import androidx.annotation.IntRange
import androidx.compose.foundation.text.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import androidx.core.text.BidiFormatter
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.detectGesture
import kotlinx.coroutines.*
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
@@ -46,101 +36,42 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea
}
}
private val noTyping: AnnotatedString = AnnotatedString(" ")
private val typingIndicators: List<AnnotatedString> = listOf(
typing(FontWeight.Black) + typing() + typing(),
typing(FontWeight.Bold) + typing(FontWeight.Black) + typing(),
typing() + typing(FontWeight.Bold) + typing(FontWeight.Black),
typing() + typing() + typing(FontWeight.Bold)
)
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
pushStyle(SpanStyle(color = HighOrLowlight, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
append(if (recent) typingIndicators[typingIdx] else noTyping)
}
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
AnnotatedString(".", SpanStyle(fontWeight = w))
@Composable
fun MarkdownText (
text: CharSequence,
text: String,
formattedText: List<FormattedText>? = null,
sender: String? = null,
meta: CIMeta? = null,
chatTTL: Int? = null,
metaText: String? = null,
edited: Boolean = false,
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
uriHandler: UriHandler? = null,
senderBold: Boolean = false,
modifier: Modifier = Modifier,
linkMode: SimplexLinkMode,
inlineContent: Map<String, InlineTextContent>? = null,
onLinkLongClick: (link: String) -> Unit = {}
) {
val textLayoutDirection = remember (text) {
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
}
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
"\n"
} else if (meta != null) {
reserveSpaceForMeta(meta, chatTTL)
} else {
" "
val reserve = when {
textLayoutDirection != LocalLayoutDirection.current && metaText != null -> "\n"
edited -> " "
else -> " "
}
val scope = rememberCoroutineScope()
CompositionLocalProvider(
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current)
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
else
LocalLayoutDirection.current
) {
var timer: Job? by remember { mutableStateOf(null) }
var typingIdx by rememberSaveable { mutableStateOf(0) }
fun stopTyping() {
timer?.cancel()
timer = null
}
fun switchTyping() {
if (meta != null && meta.isLive && meta.recent) {
timer = timer ?: scope.launch {
while (isActive) {
typingIdx = (typingIdx + 1) % typingIndicators.size
delay(250)
}
}
} else {
stopTyping()
}
}
if (meta?.isLive == true) {
val activity = LocalContext.current as Activity
LaunchedEffect(meta.recent, meta.isLive) {
switchTyping()
}
DisposableEffect(Unit) {
val orientation = activity.resources.configuration.orientation
onDispose {
if (orientation == activity.resources.configuration.orientation) {
stopTyping()
}
}
}
}
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
if (text is String) append(text)
else if (text is AnnotatedString) append(text)
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
append(text)
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent ?: mapOf())
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
} else {
var hasLinks = false
val annotatedText = buildAnnotatedString {
@@ -148,29 +79,22 @@ fun MarkdownText (
for (ft in formattedText) {
if (ft.format == null) append(ft.text)
else {
val link = ft.link(linkMode)
val link = ft.link
if (link != null) {
hasLinks = true
val ftStyle = if (ft.format is Format.SimplexLink && !ft.format.trustedUri && linkMode == SimplexLinkMode.BROWSER) {
SpanStyle(color = Color.Red, textDecoration = TextDecoration.Underline)
} else {
ft.format.style
}
val ftStyle = ft.format.style
withAnnotation(tag = "URL", annotation = link) {
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
withStyle(ftStyle) { append(ft.text) }
}
} else {
withStyle(ft.format.style) { append(ft.text) }
}
}
}
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
// With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good
/*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl)
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
else */if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
@@ -180,15 +104,7 @@ fun MarkdownText (
},
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
try {
uriHandler.openUri(annotation.item)
} catch (e: ActivityNotFoundException) {
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
}
}
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()

View File

@@ -19,7 +19,6 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.annotatedStringResource
import chat.simplex.app.views.usersettings.MarkdownHelpView
import chat.simplex.app.views.usersettings.simplexTeamUri
val bold = SpanStyle(fontWeight = FontWeight.Bold)
@@ -77,15 +76,6 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
}
Column(
Modifier.padding(vertical = 24.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(stringResource(R.string.markdown_in_messages), style = MaterialTheme.typography.h2)
MarkdownHelpView()
}
}
}

View File

@@ -11,20 +11,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chat.group.deleteGroupDialog
import chat.simplex.app.views.chat.group.leaveGroupDialog
import chat.simplex.app.views.chat.item.InvalidJSONView
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ContactConnectionInfoView
@@ -38,25 +33,22 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
}
val stopped = chatModel.chatRunning.value == false
val linkMode by remember { chatModel.controller.appPrefs.simplexLinkMode.state }
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)
}
when (chat.chatInfo) {
is ChatInfo.Direct -> {
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
is ChatInfo.Direct ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
click = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
stopped
)
}
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -82,18 +74,6 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
showMenu,
stopped
)
is ChatInfo.InvalidJSON ->
ChatListNavLinkLayout(
chatLinkPreview = {
InvalidDataView()
},
click = {
ModalManager.shared.showModal(true) { InvalidJSONView(chat.chatInfo.json) }
},
dropdownMenuItems = null,
showMenu,
stopped
)
}
}
@@ -301,7 +281,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo
if (chatModel.incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.Check,
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
onClick = {
acceptContactRequest(chatInfo.apiId, chatInfo, true, chatModel)
acceptContactRequest(chatInfo, chatModel)
showMenu.value = false
}
)
@@ -339,29 +319,6 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
)
}
@Composable
private fun InvalidDataView() {
Row {
ProfileImage(72.dp, null, Icons.Filled.AccountCircle, HighOrLowlight)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
stringResource(R.string.invalid_data),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = Color.Red
)
val height = with(LocalDensity.current) { 46.sp.toDp() }
Spacer(Modifier.height(height))
}
}
}
fun markChatRead(c: Chat, chatModel: ChatModel) {
var chat = c
withApi {
@@ -409,16 +366,16 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel
title = generalGetString(R.string.accept_connection_request__question),
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button),
onConfirm = { acceptContactRequest(contactRequest.apiId, contactRequest, true, chatModel) },
onConfirm = { acceptContactRequest(contactRequest, chatModel) },
dismissText = generalGetString(R.string.reject_contact_button),
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
)
}
fun acceptContactRequest(apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
fun acceptContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
withApi {
val contact = chatModel.controller.apiAcceptContactRequest(apiId)
if (contact != null && isCurrentUser && contactRequest != null) {
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
if (contact != null) {
val chat = Chat(ChatInfo.Direct(contact), listOf())
chatModel.replaceChat(contactRequest.id, chat)
}
@@ -627,13 +584,9 @@ fun PreviewChatListNavLinkDirect() {
),
chatStats = Chat.ChatStats()
),
null,
null,
false,
null,
null,
stopped = false,
linkMode = SimplexLinkMode.DESCRIPTION
stopped = false
)
},
click = {},
@@ -668,13 +621,9 @@ fun PreviewChatListNavLinkGroup() {
),
chatStats = Chat.ChatStats()
),
null,
null,
false,
null,
null,
stopped = false,
linkMode = SimplexLinkMode.DESCRIPTION
stopped = false
)
},
click = {},

View File

@@ -4,7 +4,6 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -14,60 +13,41 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.*
import chat.simplex.app.*
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.onboarding.WhatsNewView
import chat.simplex.app.views.onboarding.shouldShowWhatsNew
import chat.simplex.app.views.usersettings.SettingsView
import chat.simplex.app.views.usersettings.simplexTeamUri
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@Composable
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val newChatSheetState by rememberSaveable(stateSaver = NewChatSheetState.saver()) { mutableStateOf(MutableStateFlow(NewChatSheetState.GONE)) }
val showNewChatSheet = {
newChatSheetState.value = AnimatedViewState.VISIBLE
newChatSheetState.value = NewChatSheetState.VISIBLE
}
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
if (animated) newChatSheetState.value = AnimatedViewState.HIDING
else newChatSheetState.value = AnimatedViewState.GONE
}
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
ModalManager.shared.showCustomModal { close -> WhatsNewView(close = close) }
}
if (animated) newChatSheetState.value = NewChatSheetState.HIDING
else newChatSheetState.value = NewChatSheetState.GONE
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
}
LaunchedEffect(chatModel.appOpenUrl.value) {
val url = chatModel.appOpenUrl.value
if (url != null) {
chatModel.appOpenUrl.value = null
connectIfOpenedViaUri(url, chatModel)
}
}
var searchInList by rememberSaveable { mutableStateOf("") }
val scaffoldState = rememberScaffoldState()
val scope = rememberCoroutineScope()
val switchingUsers = rememberSaveable { mutableStateOf(false) }
Scaffold(
topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } },
topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, stopped) { searchInList = it.trim() } },
scaffoldState = scaffoldState,
drawerContent = { SettingsView(chatModel, setPerformLA) },
floatingActionButton = {
@@ -100,7 +80,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
) {
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel, search = searchInList)
} else if (!switchingUsers.value) {
} else {
Box(Modifier.fillMaxSize()) {
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
OnboardingButtons(showNewChatSheet)
@@ -114,17 +94,6 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
if (searchInList.isEmpty()) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
UserPicker(chatModel, userPickerState, switchingUsers) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
}
if (switchingUsers.value) {
Box(
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
ProgressIndicator()
}
}
}
@Composable
@@ -170,7 +139,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
}
@Composable
private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
var showSearch by rememberSaveable { mutableStateOf(false) }
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
if (showSearch) {
@@ -203,23 +172,10 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
val scope = rememberCoroutineScope()
DefaultTopAppBar(
navigationButton = {
if (showSearch) {
if (showSearch)
NavigationButtonBack(hideSearchOnBack)
} else if (chatModel.users.isEmpty()) {
else
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} else {
val users by remember { derivedStateOf { chatModel.users.toList() } }
val allRead = users
.filter { !it.user.activeUser }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
if (users.size == 1) {
scope.launch { drawerState.open() }
} else {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -246,47 +202,6 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
Divider(Modifier.padding(top = AppBarHeight))
}
@Composable
private fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
IconButton(onClick = onButtonClicked) {
Box {
ProfileImage(
image = image,
size = 37.dp
)
if (!allRead) {
unreadBadge()
}
}
}
}
@Composable
private fun BoxScope.unreadBadge(text: String? = "") {
Text(
text ?: "",
color = MaterialTheme.colors.onPrimary,
fontSize = 6.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
.align(Alignment.TopEnd)
)
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
private var lazyListState = 0 to 0
@Composable

View File

@@ -4,20 +4,17 @@ import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -25,22 +22,11 @@ import androidx.compose.ui.unit.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.ComposePreview
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.*
@Composable
fun ChatPreviewView(
chat: Chat,
chatModelDraft: ComposeState?,
chatModelDraftChatId: ChatId?,
chatModelIncognito: Boolean,
currentUserProfileDisplayName: String?,
contactNetworkStatus: NetworkStatus?,
stopped: Boolean,
linkMode: SimplexLinkMode
) {
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) {
val cInfo = chat.chatInfo
@Composable
@@ -77,58 +63,11 @@ fun ChatPreviewView(
)
}
@Composable
fun VerifiedIcon() {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
}
fun messageDraft(draft: ComposeState): Pair<AnnotatedString, Map<String, InlineTextContent>> {
fun attachment(): Pair<ImageVector, String?>? =
when (draft.preview) {
is ComposePreview.FilePreview -> Icons.Filled.InsertDriveFile to draft.preview.fileName
is ComposePreview.ImagePreview -> Icons.Outlined.Image to null
is ComposePreview.VoicePreview -> Icons.Filled.PlayArrow to durationText(draft.preview.durationMs / 1000)
else -> null
}
val attachment = attachment()
val text = buildAnnotatedString {
appendInlineContent(id = "editIcon")
append(" ")
if (attachment != null) {
appendInlineContent(id = "attachmentIcon")
if (attachment.second != null) {
append(attachment.second as String)
}
append(" ")
}
append(draft.message)
}
val inlineContent: Map<String, InlineTextContent> = mapOf(
"editIcon" to InlineTextContent(
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(Icons.Outlined.EditNote, null, tint = MaterialTheme.colors.primary)
},
"attachmentIcon" to InlineTextContent(
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(attachment?.first ?: Icons.Outlined.EditNote, null, tint = HighOrLowlight)
}
)
return text to inlineContent
}
@Composable
fun chatPreviewTitle() {
when (cInfo) {
is ChatInfo.Direct ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (cInfo.contact.verified) {
VerifiedIcon()
}
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
}
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
@@ -143,30 +82,15 @@ fun ChatPreviewView(
fun chatPreviewText(chatModelIncognito: Boolean) {
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
!ci.meta.itemDeleted -> ci.text to null
else -> generalGetString(R.string.marked_deleted_description) to null
}
val formattedText = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
!ci.meta.itemDeleted -> ci.formattedText
else -> null
}
MarkdownText(
text,
formattedText,
sender = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName
else -> null
},
linkMode = linkMode,
ci.text,
ci.formattedText,
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
senderBold = true,
metaText = null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
)
} else {
@@ -254,7 +178,7 @@ fun ChatPreviewView(
Modifier.padding(top = 52.dp),
contentAlignment = Alignment.Center
) {
ChatStatusImage(contactNetworkStatus)
ChatStatusImage(chat)
}
}
}
@@ -277,9 +201,10 @@ fun unreadCountStr(n: Int): String {
}
@Composable
fun ChatStatusImage(s: NetworkStatus?) {
val descr = s?.statusString
if (s is NetworkStatus.Error) {
fun ChatStatusImage(chat: Chat) {
val s = chat.serverInfo.networkStatus
val descr = s.statusString
if (s is Chat.NetworkStatus.Error) {
Icon(
Icons.Outlined.ErrorOutline,
contentDescription = descr,
@@ -287,7 +212,7 @@ fun ChatStatusImage(s: NetworkStatus?) {
modifier = Modifier
.size(19.dp)
)
} else if (s !is NetworkStatus.Connected) {
} else if (s !is Chat.NetworkStatus.Connected) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
@@ -307,6 +232,6 @@ fun ChatStatusImage(s: NetworkStatus?) {
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData, null, null, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
}
}

View File

@@ -29,7 +29,7 @@ fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) {
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
stopped
)
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection, is ChatInfo.InvalidJSON -> {}
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection -> {}
}
}

View File

@@ -1,197 +0,0 @@
package chat.simplex.app.views.chatlist
import SectionItemViewSpaceBetween
import android.util.Log
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, switchingUsers: MutableState<Boolean>, openSettings: () -> Unit) {
val scope = rememberCoroutineScope()
var newChat by remember { mutableStateOf(userPickerState.value) }
val users by remember { derivedStateOf { chatModel.users.sortedByDescending { it.user.activeUser } } }
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
LaunchedEffect(Unit) {
launch {
userPickerState.collect {
newChat = it
launch {
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE
}
}
}
}
LaunchedEffect(Unit) {
snapshotFlow { newChat.isVisible() }
.distinctUntilChanged()
.filter { it }
.collect {
try {
val updatedUsers = chatModel.controller.listUsers().sortedByDescending { it.user.activeUser }
var same = users.size == updatedUsers.size
if (same) {
for (i in 0 until minOf(users.size, updatedUsers.size)) {
val prev = updatedUsers[i].user
val next = users[i].user
if (prev.userId != next.userId || prev.activeUser != next.activeUser || prev.chatViewName != next.chatViewName || prev.image != next.image) {
same = false
break
}
}
}
if (!same) {
chatModel.users.clear()
chatModel.users.addAll(updatedUsers)
}
} catch (e: Exception) {
Log.e(TAG, "Error updating users ${e.stackTraceToString()}")
}
}
}
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density }
Box(Modifier
.fillMaxSize()
.offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else xOffset, 0) }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
.padding(bottom = 10.dp, top = 10.dp)
.graphicsLayer {
alpha = animatedFloat.value
translationY = (animatedFloat.value - 1) * xOffset
}
) {
Column(
Modifier
.widthIn(min = 220.dp)
.width(IntrinsicSize.Min)
.height(IntrinsicSize.Min)
.shadow(8.dp, MaterialTheme.shapes.medium, clip = false)
.background(MaterialTheme.colors.background, MaterialTheme.shapes.medium)
) {
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
users.forEachIndexed { i, u ->
UserProfilePickerItem(u.user, u.unreadCount) {
userPickerState.value = AnimatedViewState.HIDING
if (!u.user.activeUser) {
chatModel.chats.clear()
scope.launch {
val job = launch {
delay(500)
switchingUsers.value = true
}
chatModel.controller.changeActiveUser(u.user.userId)
job.cancel()
switchingUsers.value = false
}
}
}
if (i != users.lastIndex) {
Divider(Modifier.requiredHeight(1.dp))
}
}
}
Divider()
SettingsPickerItem {
openSettings()
userPickerState.value = AnimatedViewState.GONE
}
}
}
}
@Composable
fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit = {}, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.sizeIn(minHeight = 46.dp)
.combinedClickable(
onClick = if (!u.activeUser) onClick else { {} },
onLongClick = onLongClick,
interactionSource = remember { MutableInteractionSource() },
indication = if (!u.activeUser) LocalIndication.current else null
)
.padding(PaddingValues(start = 8.dp, end = DEFAULT_PADDING)),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
Modifier
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ProfileImage(
image = u.image,
size = 54.dp
)
Text(
u.displayName,
modifier = Modifier
.padding(start = 8.dp, end = 8.dp),
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
)
}
if (u.activeUser) {
Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
} else if (unreadCount > 0) {
Row {
Text(
unreadCountStr(unreadCount),
color = MaterialTheme.colors.onPrimary,
fontSize = 11.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp),
textAlign = TextAlign.Center,
maxLines = 1
)
Spacer(Modifier.width(2.dp))
}
} else {
Box(Modifier.size(20.dp))
}
}
}
@Composable
private fun SettingsPickerItem(onClick: () -> Unit) {
SectionItemViewSpaceBetween(onClick, minHeight = 68.dp) {
val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current)
Text(
text,
color = MaterialTheme.colors.onBackground,
)
Icon(Icons.Outlined.Settings, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
}
}

View File

@@ -27,8 +27,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
@@ -86,11 +84,8 @@ fun DatabaseView(
chatArchiveTime,
chatLastStart,
chatDbDeleted.value,
m.controller.appPrefs.privacyFullBackup,
appFilesCountAndSize,
chatItemTTL,
m.currentUser.value,
m.users,
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
stopChatAlert = { stopChatAlert(m, runChat, context) },
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
@@ -131,17 +126,14 @@ fun DatabaseLayout(
chatDbChanged: Boolean,
useKeyChain: Boolean,
chatDbEncrypted: Boolean?,
initialRandomDBPassphrase: SharedPreference<Boolean>,
initialRandomDBPassphrase: Preference<Boolean>,
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
chatDbDeleted: Boolean,
privacyFullBackup: SharedPreference<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
chatItemTTL: MutableState<ChatItemTTL>,
currentUser: User?,
users: List<UserInfo>,
startChat: () -> Unit,
stopChatAlert: () -> Unit,
exportArchive: () -> Unit,
@@ -154,27 +146,10 @@ fun DatabaseLayout(
val operationsDisabled = !stopped || progressIndicator
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 48.dp),
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.your_chat_database))
SectionView(stringResource(R.string.messages_section_title).uppercase()) {
SectionItemView { TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!progressIndicator && !chatDbChanged), onChatItemTTLSelected) }
}
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(R.string.messages_section_description) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
SectionSpacer()
SectionView(stringResource(R.string.run_chat_section)) {
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
}
@@ -190,8 +165,6 @@ fun DatabaseLayout(
disabled = operationsDisabled
)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.Backup, stringResource(R.string.full_backup), privacyFullBackup)
SectionDivider()
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.export_database),
@@ -247,14 +220,16 @@ fun DatabaseLayout(
)
SectionSpacer()
SectionView(stringResource(R.string.files_and_media_section).uppercase()) {
SectionView(stringResource(R.string.data_section)) {
SectionItemView { TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!progressIndicator && !chatDbChanged), onChatItemTTLSelected) }
SectionDivider()
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
SectionItemView(
deleteAppFilesAndMedia,
disabled = deleteFilesDisabled
) {
Text(
stringResource(if (users.size > 1) R.string.delete_files_and_media_for_all_users else R.string.delete_files_and_media_all),
stringResource(R.string.delete_files_and_media),
color = if (deleteFilesDisabled) HighOrLowlight else Color.Red
)
}
@@ -708,17 +683,14 @@ fun PreviewDatabaseLayout() {
chatDbChanged = false,
useKeyChain = false,
chatDbEncrypted = false,
initialRandomDBPassphrase = SharedPreference({ true }, {}),
initialRandomDBPassphrase = Preference({ true }, {}),
importArchiveLauncher = rememberGetContentLauncher {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
chatDbDeleted = false,
privacyFullBackup = SharedPreference({ true }, {}),
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
currentUser = User.sampleData,
users = listOf(UserInfo.sampleData),
startChat = {},
stopChatAlert = {},
exportArchive = {},

View File

@@ -1,20 +1,11 @@
package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Dialog
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.ui.theme.*
class AlertManager {
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
@@ -44,36 +35,6 @@ class AlertManager {
}
}
fun showAlertDialogButtonsColumn(
title: String,
text: AnnotatedString? = null,
buttons: @Composable () -> Unit,
) {
showAlert {
Dialog(onDismissRequest = this::hideAlert) {
Column(Modifier.background(MaterialTheme.colors.background, MaterialTheme.shapes.medium)) {
Text(title,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING, bottom = if (text == null) DEFAULT_PADDING else DEFAULT_PADDING_HALF),
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold
)
if (text != null) {
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
Text(
text,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
fontSize = 14.sp,
)
}
}
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
buttons()
}
}
}
}
}
fun showAlertDialog(
title: String,
text: String? = null,
@@ -106,41 +67,6 @@ class AlertManager {
}
}
fun showAlertDialogStacked(
title: String,
text: String? = null,
confirmText: String = generalGetString(R.string.ok),
onConfirm: (() -> Unit)? = null,
dismissText: String = generalGetString(R.string.cancel_verb),
onDismiss: (() -> Unit)? = null,
onDismissRequest: (() -> Unit)? = null,
destructive: Boolean = false
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
title = { Text(title) },
text = alertText,
buttons = {
Column(
Modifier.fillMaxWidth().padding(horizontal = 8.dp).padding(top = 16.dp, bottom = 2.dp),
horizontalAlignment = Alignment.End
) {
TextButton(onClick = {
onDismiss?.invoke()
hideAlert()
}) { Text(dismissText) }
TextButton(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
}
},
)
}
}
fun showAlertMsg(
title: String, text: String? = null,
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
@@ -176,4 +102,4 @@ class AlertManager {
companion object {
val shared = AlertManager()
}
}
}

View File

@@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.ui.theme.*
@Composable
fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit = {}) {
fun CloseSheetBar(close: () -> Unit) {
Column(
Modifier
.fillMaxWidth()
@@ -20,15 +20,9 @@ fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit
) {
Row(
Modifier
.width(TitleInsetWithIcon - AppBarHorizontalPadding)
.padding(top = 4.dp), // Like in DefaultAppBar
content = {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
NavigationButtonBack(close)
Row {
endButtons()
}
}
}
content = { NavigationButtonBack(close) }
)
}
}
@@ -36,7 +30,7 @@ fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit
@Composable
fun AppBarTitle(title: String, withPadding: Boolean = true) {
val padding = if (withPadding)
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING )
else
PaddingValues(bottom = DEFAULT_PADDING)
Text(

View File

@@ -53,15 +53,6 @@ fun NavigationButtonBack(onButtonClicked: () -> Unit) {
}
}
@Composable
fun ShareButton(onButtonClicked: () -> Unit) {
IconButton(onButtonClicked) {
Icon(
Icons.Outlined.Share, stringResource(R.string.share_verb), tint = MaterialTheme.colors.primary
)
}
}
@Composable
fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
IconButton(onClick = onButtonClicked) {

View File

@@ -1,13 +1,9 @@
@file:UseSerializers(UriSerializer::class)
package chat.simplex.app.views.helpers
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.runtime.saveable.Saver
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
sealed class SharedContent {
data class Text(val text: String): SharedContent()
@@ -15,7 +11,7 @@ sealed class SharedContent {
data class File(val text: String, val uri: Uri): SharedContent()
}
enum class AnimatedViewState {
enum class NewChatSheetState {
VISIBLE, HIDING, GONE;
fun isVisible(): Boolean {
return this == VISIBLE
@@ -27,7 +23,7 @@ enum class AnimatedViewState {
return this == GONE
}
companion object {
fun saver(): Saver<MutableStateFlow<AnimatedViewState>, *> = Saver(
fun saver(): Saver<MutableStateFlow<NewChatSheetState>, *> = Saver(
save = { it.value.toString() },
restore = {
MutableStateFlow(valueOf(it))
@@ -36,16 +32,7 @@ enum class AnimatedViewState {
}
}
@Serializer(forClass = Uri::class)
object UriSerializer : KSerializer<Uri> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Uri) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Uri = Uri.parse(decoder.decodeString())
}
@Serializable
sealed class UploadContent {
@Serializable data class SimpleImage(val uri: Uri): UploadContent()
@Serializable data class AnimatedImage(val uri: Uri): UploadContent()
data class SimpleImage(val uri: Uri): UploadContent()
data class AnimatedImage(val uri: Uri): UploadContent()
}

View File

@@ -10,12 +10,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.HighOrLowlight
@Composable
@@ -30,7 +27,7 @@ fun <T> ExposedDropDownSettingRow(
onSelected: (T) -> Unit
) {
Row(
Modifier.fillMaxWidth().padding(vertical = 10.dp),
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
var expanded by remember { mutableStateOf(false) }
@@ -43,7 +40,9 @@ fun <T> ExposedDropDownSettingRow(
tint = iconTint
)
}
Text(title, Modifier.weight(1f), color = if (enabled.value) Color.Unspecified else HighOrLowlight)
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
Spacer(Modifier.fillMaxWidth().weight(1f))
ExposedDropdownMenuBox(
expanded = expanded,
@@ -56,10 +55,8 @@ fun <T> ExposedDropDownSettingRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
val maxWidth = with(LocalDensity.current){ 180.sp.toDp() }
Text(
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
Modifier.widthIn(max = maxWidth),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight

View File

@@ -3,7 +3,6 @@ package chat.simplex.app.views.helpers
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.graphics.*
import android.net.Uri
@@ -33,9 +32,11 @@ import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.json
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.chat.PickFromGallery
import chat.simplex.app.views.newchat.ActionButton
import kotlinx.serialization.builtins.*
import kotlinx.serialization.decodeFromString
import java.io.ByteArrayOutputStream
import java.io.File
import kotlin.math.min
@@ -176,25 +177,6 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
try {
launch(null)
} catch (e: ActivityNotFoundException) {
// No Activity found to handle Intent android.media.action.IMAGE_CAPTURE
// Means, no system camera app (Android 11+ requirement)
// https://developer.android.com/about/versions/11/behavior-changes-11#media-capture
Log.e(TAG, "Camera launcher: " + e.stackTraceToString())
try {
// Try to open any camera just to capture an image, will not be returned like with previous intent
SimplexApp.context.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) })
} catch (e: ActivityNotFoundException) {
// No camera apps available at all
Log.e(TAG, "Camera launcher2: " + e.stackTraceToString())
}
}
}
@Composable
fun GetImageBottomSheet(
imageBitmap: MutableState<Uri?>,
@@ -222,7 +204,7 @@ fun GetImageBottomSheet(
}
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launchWithFallback()
cameraLauncher.launch(null)
hideBottomSheet()
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
@@ -246,7 +228,7 @@ fun GetImageBottomSheet(
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launchWithFallback()
cameraLauncher.launch(null)
hideBottomSheet()
}
else -> {

View File

@@ -20,13 +20,12 @@ fun ModalView(
close: () -> Unit,
background: Color = MaterialTheme.colors.background,
modifier: Modifier = Modifier,
endButtons: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit,
) {
BackHandler(onBack = close)
Surface(Modifier.fillMaxSize()) {
Column(Modifier.background(background)) {
CloseSheetBar(close, endButtons)
CloseSheetBar(close)
Box(modifier) { content() }
}
}
@@ -38,9 +37,9 @@ class ModalManager {
private val toRemove = mutableSetOf<Int>()
private var oldViewChanging = AtomicBoolean(false)
fun showModal(settings: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit) {
fun showModal(settings: Boolean = false, content: @Composable () -> Unit) {
showCustomModal { close ->
ModalView(close, if (!settings || isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight, endButtons = endButtons, content = content)
ModalView(close, if (!settings || isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight, content = content)
}
}

View File

@@ -1,36 +1,45 @@
package chat.simplex.app.views.helpers
import android.app.Application
import android.content.Context
import android.media.*
import android.media.AudioManager.AudioPlaybackCallback
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
import android.os.Build
import android.util.Log
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.views.helpers.AudioPlayer.duration
import kotlinx.coroutines.*
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
interface Recorder {
fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String
fun stop(): Int
val recordingInProgress: MutableState<Boolean>
fun start(onStop: () -> Unit): String
fun stop()
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
}
data class ProgressAndDuration(
val progressMs: Int = 0,
val durationMs: Int = 0
) {
companion object {
val Saver
get() = Saver<MutableState<ProgressAndDuration>, Pair<Int, Int>>(
save = { it.value.progressMs to it.value.durationMs },
restore = { mutableStateOf(ProgressAndDuration(it.first, it.second)) }
)
}
}
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
companion object {
// Allows to stop the recorder from outside without having the recorder in a variable
var stopRecording: (() -> Unit)? = null
const val extension = "m4a"
}
override val recordingInProgress = mutableStateOf(false)
private var recorder: MediaRecorder? = null
private var progressJob: Job? = null
private var filePath: String? = null
private var recStartedAt: Long? = null
private fun initRecorder() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(SimplexApp.context)
@@ -38,8 +47,9 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
MediaRecorder()
}
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
override fun start(onStop: () -> Unit): String {
AudioPlayer.stop()
recordingInProgress.value = true
val rec: MediaRecorder
recorder = initRecorder().also { rec = it }
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
@@ -48,39 +58,28 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
rec.setAudioChannels(1)
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(16000)
rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING)
rec.setMaxDuration(-1)
rec.setMaxFileSize(recordedBytesLimit)
val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE)
val fileToSave = File.createTempFile(generateNewFileName(SimplexApp.context, "voice", "${extension}_"), ".tmp", tmpDir)
fileToSave.deleteOnExit()
val path = fileToSave.absolutePath
filePath = path
rec.setOutputFile(path)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
rec.setOutputFile(filePath)
rec.prepare()
rec.start()
recStartedAt = System.currentTimeMillis()
progressJob = CoroutineScope(Dispatchers.Default).launch {
while(isActive) {
onProgressUpdate(progress(), false)
delay(50)
}
}.apply {
invokeOnCompletion {
onProgressUpdate(realDuration(path), true)
}
}
rec.setOnInfoListener { _, what, _ ->
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED || what == MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) {
rec.setOnInfoListener { mr, what, extra ->
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
stop()
onStop()
}
}
stopRecording = { stop() }
return path
stopRecording = { stop(); onStop() }
return filePath
}
override fun stop(): Int {
val path = filePath ?: return 0
override fun stop() {
if (!recordingInProgress.value) return
stopRecording = null
recordingInProgress.value = false
recorder?.metrics?.
runCatching {
recorder?.stop()
}
@@ -88,25 +87,16 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
recorder?.reset()
}
runCatching {
// release all resources
recorder?.release()
}
// Await coroutine finishes in order to send real duration to it's listener
runBlocking {
progressJob?.cancelAndJoin()
}
progressJob = null
filePath = null
recorder = null
return (realDuration(path) ?: 0).also { recStartedAt = null }
}
private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() }
/**
* Return real duration from [AudioPlayer] if it's possible (should always be possible).
* As a fallback, return internally counted duration
* */
private fun realDuration(path: String): Int? = duration(path) ?: progress()
override fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>) {
stop()
runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") }
}
}
object AudioPlayer {
@@ -117,17 +107,6 @@ object AudioPlayer {
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
(SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
.registerAudioPlaybackCallback(object: AudioPlaybackCallback() {
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
if (configs?.any { it.audioAttributes.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION } == true) {
// In a process of making a call
RecorderNative.stopRecording?.invoke()
stop()
}
super.onPlaybackConfigChanged(configs)
}
}, null)
}
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
setAudioAttributes(
@@ -137,79 +116,55 @@ object AudioPlayer {
.build()
)
}
// Filepath: String, onProgressUpdate
private val currentlyPlaying: MutableState<Pair<String, (position: Int?, state: TrackState) -> Unit>?> = mutableStateOf(null)
private var progressJob: Job? = null
// Filepath: String, onStop: () -> Unit
private val currentlyPlaying: MutableState<Pair<String, () -> Unit>?> = mutableStateOf(null)
enum class TrackState {
PLAYING, PAUSED, REPLACED
}
// Returns real duration of the track
private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
fun start(filePath: String, seek: Int? = null, onStop: () -> Unit): Boolean {
if (!File(filePath).exists()) {
Log.e(TAG, "No such file: $filePath")
return null
return false
}
RecorderNative.stopRecording?.invoke()
val current = currentlyPlaying.value
if (current == null || current.first != filePath) {
stopListener()
player.reset()
// Notify prev audio listener about stop
current?.second?.invoke()
runCatching {
player.setDataSource(filePath)
}.onFailure {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
return null
return false
}
runCatching { player.prepare() }.onFailure {
// Can happen when audio file is broken
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
return null
return false
}
}
if (seek != null) player.seekTo(seek)
player.start()
currentlyPlaying.value = filePath to onProgressUpdate
progressJob = CoroutineScope(Dispatchers.Default).launch {
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
while(isActive && player.isPlaying) {
// Even when current position is equal to duration, the player has isPlaying == true for some time,
// so help to make the playback stopped in UI immediately
if (player.currentPosition == player.duration) {
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
break
}
delay(50)
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
}
/*
* Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases
* the player can show position != duration even if they actually equal.
* Let's say to a listener that the position == duration in case of coroutine finished without cancel
* */
if (isActive) {
onProgressUpdate(player.duration, TrackState.PAUSED)
}
onProgressUpdate(null, TrackState.PAUSED)
// Repeated calls to play/pause on the same track will not recompose all dependent views
if (currentlyPlaying.value?.first != filePath) {
currentlyPlaying.value = filePath to onStop
}
return player.duration
return true
}
private fun pause(): Int {
progressJob?.cancel()
progressJob = null
fun pause(): Int {
player.pause()
return player.currentPosition
}
fun stop() {
if (currentlyPlaying.value == null) return
if (!player.isPlaying) return
// Notify prev audio listener about stop
currentlyPlaying.value?.second?.invoke()
currentlyPlaying.value = null
player.stop()
stopListener()
}
fun stop(item: ChatItem) = stop(item.file?.fileName)
@@ -221,59 +176,16 @@ object AudioPlayer {
}
}
private fun stopListener() {
val afterCoroutineCancel: CompletionHandler = {
// Notify prev audio listener about stop
currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED)
currentlyPlaying.value = null
}
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.REPLACED] (in this order)
* */
if (progressJob != null) {
progressJob?.invokeOnCompletion(afterCoroutineCancel)
} else {
afterCoroutineCancel(null)
}
progressJob?.cancel()
progressJob = null
}
/**
* If player starts playing at 2637 ms in a track 2816 ms long (these numbers are just an example),
* it will stop immediately after start but will not change currentPosition, so it will not be equal to duration.
* However, it sets isPlaying to false. Let's do it ourselves in order to prevent endless waiting loop
* */
fun progressAndDurationOrEnded(): ProgressAndDuration =
ProgressAndDuration(if (player.isPlaying) player.currentPosition else player.duration, player.duration)
fun play(
filePath: String?,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
resetOnEnd: Boolean,
) {
if (progress.value == duration.value) {
progress.value = 0
}
val realDuration = start(filePath ?: return, progress.value) { pro, state ->
if (pro != null) {
progress.value = pro
}
if (pro == null || pro == duration.value) {
audioPlaying.value = false
if (pro == duration.value) {
progress.value = if (resetOnEnd) 0 else duration.value
} else if (state == TrackState.REPLACED) {
progress.value = 0
}
}
}
audioPlaying.value = realDuration != null
// Update to real duration instead of what was received in ChatInfo
realDuration?.let { duration.value = it }
}
fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
pro.value = pause()
audioPlaying.value = false
}
fun duration(filePath: String): Int? {
var res: Int? = null
fun duration(filePath: String): Int {
var res = 0
kotlin.runCatching {
helperPlayer.setDataSource(filePath)
helperPlayer.prepare()

View File

@@ -1,5 +1,4 @@
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
@@ -11,8 +10,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.app.ui.theme.*
@@ -34,28 +31,6 @@ fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(),
}
}
@Composable
fun SectionView(
title: String,
icon: ImageVector,
iconTint: Color = HighOrLowlight,
leadingIcon: Boolean = false,
padding: PaddingValues = PaddingValues(),
content: (@Composable ColumnScope.() -> Unit)
) {
Column {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
if (leadingIcon) Icon(icon, null, Modifier.padding(end = 4.dp).size(iconSize), tint = iconTint)
Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, fontSize = 12.sp)
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = 4.dp).size(iconSize), tint = iconTint)
}
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
}
}
}
@Composable
fun <T> SectionViewSelectable(
title: String?,
@@ -81,12 +56,7 @@ fun <T> SectionViewSelectable(
}
@Composable
fun SectionItemView(
click: (() -> Unit)? = null,
minHeight: Dp = 46.dp,
disabled: Boolean = false,
content: (@Composable RowScope.() -> Unit)
) {
fun SectionItemView(click: (() -> Unit)? = null, minHeight: Dp = 46.dp, disabled: Boolean = false, content: (@Composable RowScope.() -> Unit)) {
val modifier = Modifier
.fillMaxWidth()
.sizeIn(minHeight = minHeight)
@@ -101,7 +71,6 @@ fun SectionItemView(
@Composable
fun SectionItemViewSpaceBetween(
click: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
minHeight: Dp = 46.dp,
padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING),
disabled: Boolean = false,
@@ -111,7 +80,7 @@ fun SectionItemViewSpaceBetween(
.fillMaxWidth()
.sizeIn(minHeight = minHeight)
Row(
if (click == null || disabled) modifier.padding(padding) else modifier.combinedClickable(onClick = click, onLongClick = onLongClick).padding(padding),
if (click == null || disabled) modifier.padding(padding) else modifier.clickable(onClick = click).padding(padding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -160,11 +129,6 @@ fun <T> SectionItemWithValue(
@Composable
fun SectionTextFooter(text: String) {
SectionTextFooter(AnnotatedString(text))
}
@Composable
fun SectionTextFooter(text: AnnotatedString) {
Text(
text,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
@@ -194,13 +158,9 @@ fun SectionSpacer() {
}
@Composable
fun InfoRow(title: String, value: String, icon: ImageVector? = null, iconTint: Color? = null) {
fun InfoRow(title: String, value: String) {
SectionItemViewSpaceBetween {
Row {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: HighOrLowlight)
Text(title)
}
Text(title)
Text(value, color = HighOrLowlight)
}
}

View File

@@ -28,22 +28,6 @@ fun SimpleButton(text: String, icon: ImageVector,
}
}
@Composable
fun SimpleButton(
text: String, icon: ImageVector,
color: Color = MaterialTheme.colors.primary,
disabled: Boolean,
click: () -> Unit
) {
SimpleButtonFrame(click, disabled = disabled) {
Icon(
icon, text, tint = if (disabled) HighOrLowlight else color,
modifier = Modifier.padding(end = 8.dp)
)
Text(text, style = MaterialTheme.typography.caption, color = if (disabled) HighOrLowlight else color)
}
}
@Composable
fun SimpleButtonIconEnded(
text: String,

View File

@@ -12,28 +12,21 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.*
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.ui.theme.HighOrLowlight
@Composable
fun TextEditor(
modifier: Modifier,
text: MutableState<String>,
border: Boolean = true,
fontSize: TextUnit = 14.sp,
background: Color = MaterialTheme.colors.background,
onChange: ((String) -> Unit)? = null
) {
fun TextEditor(modifier: Modifier, text: MutableState<String>) {
BasicTextField(
value = text.value,
onValueChange = { text.value = it; onChange?.invoke(it) },
onValueChange = { text.value = it },
textStyle = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = fontSize,
fontFamily = FontFamily.Monospace, fontSize = 14.sp,
color = MaterialTheme.colors.onBackground
),
keyboardOptions = KeyboardOptions.Default.copy(
@@ -44,17 +37,17 @@ fun TextEditor(
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
shape = if (border) RoundedCornerShape(10.dp) else RectangleShape,
border = if (border) BorderStroke(1.dp, MaterialTheme.colors.secondary) else null
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
Row(
Modifier.background(background),
Modifier.background(MaterialTheme.colors.background),
verticalAlignment = Alignment.Top
) {
Box(
Modifier
.weight(1f)
.padding(vertical = 5.dp, horizontal = if (border) 7.dp else DEFAULT_PADDING)
.padding(vertical = 5.dp, horizontal = 7.dp)
) {
innerTextField()
}

View File

@@ -1,5 +1,7 @@
package chat.simplex.app.views.helpers
import android.R.attr.factor
import android.R.color
import android.content.Context
import android.content.res.Resources
import android.graphics.*
@@ -26,13 +28,11 @@ import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.*
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.text.HtmlCompat
import chat.simplex.app.*
import chat.simplex.app.model.CIFile
import chat.simplex.app.model.json
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
@@ -43,9 +43,6 @@ fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalSc
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
scope.launch { withContext(Dispatchers.Main, action) }
fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job =
CoroutineScope(Dispatchers.Default).launch(block = action)
enum class KeyboardState {
Opened, Closed
}
@@ -228,7 +225,7 @@ const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 43_000
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
const val MAX_FILE_SIZE: Long = 8000000
@@ -244,11 +241,6 @@ fun getAppFilePath(context: Context, fileName: String): String {
return "${getAppFilesDirectory(context)}/$fileName"
}
fun getAppFileUri(fileName: String): Uri {
return Uri.parse("${getAppFilesDirectory(SimplexApp.context)}/$fileName")
}
fun getLoadedFilePath(context: Context, file: CIFile?): String? {
return if (file?.filePath != null && file.loaded) {
val filePath = getAppFilePath(context, file.filePath)
@@ -336,7 +328,8 @@ fun saveImage(context: Context, image: Bitmap): String? {
return try {
val ext = if (image.hasAlpha()) "png" else "jpg"
val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE)
val fileToSave = generateNewFileName(context, "IMG", ext)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext")
val file = File(getAppFilePath(context, fileToSave))
val output = FileOutputStream(file)
dataResized.writeTo(output)
@@ -359,7 +352,8 @@ fun saveAnimImage(context: Context, uri: Uri): String? {
}
// Just in case the image has a strange extension
if (ext.length < 3 || ext.length > 4) ext = "gif"
val fileToSave = generateNewFileName(context, "IMG", ext)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext")
val file = File(getAppFilePath(context, fileToSave))
val output = FileOutputStream(file)
context.contentResolver.openInputStream(uri)!!.use { input ->
@@ -393,23 +387,15 @@ fun saveFileFromUri(context: Context, uri: Uri): String? {
}
}
fun generateNewFileName(context: Context, prefix: String, ext: String): String {
val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("GMT")
val timestamp = sdf.format(Date())
return uniqueCombine(context, "${prefix}_$timestamp.$ext")
}
fun uniqueCombine(context: Context, fileName: String): String {
val orig = File(fileName)
val name = orig.nameWithoutExtension
val ext = orig.extension
fun tryCombine(n: Int): String {
fun tryCombine(fileName: String, n: Int): String {
val name = File(fileName).nameWithoutExtension
val ext = File(fileName).extension
val suffix = if (n == 0) "" else "_$n"
val f = "$name$suffix.$ext"
return if (File(getAppFilePath(context, f)).exists()) tryCombine(n + 1) else f
return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f
}
return tryCombine(0)
return tryCombine(fileName, 0)
}
fun formatBytes(bytes: Long): String {
@@ -476,9 +462,3 @@ val LongRange.Companion.saver
save = { it.value.first to it.value.last },
restore = { mutableStateOf(it.first..it.second) }
)
/* Make sure that T class has @Serializable annotation */
inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
save = { json.encodeToString(it) },
restore = { json.decodeFromString(it) }
)

View File

@@ -47,7 +47,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
setGroupMembers(groupInfo, chatModel)
close.invoke()
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, true, chatModel, close)
AddGroupMembersView(groupInfo, chatModel, close)
}
}
}
@@ -134,13 +134,7 @@ fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> U
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
if (enabled) {
CreateGroupButton(MaterialTheme.colors.primary, Modifier
.clickable {
createGroup(GroupProfile(
displayName = displayName.value,
fullName = fullName.value,
image = profileImage.value
))
}
.clickable { createGroup(GroupProfile(displayName.value, fullName.value, profileImage.value)) }
.padding(8.dp))
} else {
CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp))

View File

@@ -37,7 +37,7 @@ import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedViewState>, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) {
fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<NewChatSheetState>, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) {
if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) }
NewChatSheetLayout(
newChatSheetState,
@@ -63,7 +63,7 @@ private val icons = listOf(Icons.Outlined.AddLink, Icons.Outlined.QrCode, Icons.
@Composable
private fun NewChatSheetLayout(
newChatSheetState: StateFlow<AnimatedViewState>,
newChatSheetState: StateFlow<NewChatSheetState>,
stopped: Boolean,
addContact: () -> Unit,
connectViaLink: () -> Unit,
@@ -216,7 +216,7 @@ fun ActionButton(
private fun PreviewNewChatSheet() {
SimpleXTheme {
NewChatSheetLayout(
MutableStateFlow(AnimatedViewState.VISIBLE),
MutableStateFlow(NewChatSheetState.VISIBLE),
stopped = false,
addContact = {},
connectViaLink = {},

View File

@@ -16,12 +16,11 @@ import kotlinx.coroutines.launch
enum class OnboardingStage {
Step1_SimpleXInfo,
Step2_CreateProfile,
Step3_SetNotificationsMode,
OnboardingComplete
}
@Composable
fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
fun CreateProfile(chatModel: ChatModel) {
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
@@ -34,10 +33,7 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
.background(color = MaterialTheme.colors.background)
.padding(20.dp)
) {
CreateProfilePanel(chatModel, close)
LaunchedEffect(Unit) {
setLastVersionDefault(chatModel)
}
CreateProfilePanel(chatModel)
if (savedKeyboardState != keyboardState) {
LaunchedEffect(keyboardState) {
scope.launch {

View File

@@ -1,68 +0,0 @@
package chat.simplex.app.views.onboarding
import androidx.annotation.StringRes
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.NotificationsMode
import chat.simplex.app.views.usersettings.changeNotificationsMode
@Composable
fun SetNotificationsMode(m: ChatModel) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp)
) {
AppBarTitle(stringResource(R.string.onboarding_notifications_mode_title), false)
val currentMode = rememberSaveable { mutableStateOf(NotificationsMode.default) }
Text(stringResource(R.string.onboarding_notifications_mode_subtitle))
Spacer(Modifier.padding(DEFAULT_PADDING_HALF))
NotificationButton(currentMode, NotificationsMode.OFF, R.string.onboarding_notifications_mode_off, R.string.onboarding_notifications_mode_off_desc)
NotificationButton(currentMode, NotificationsMode.PERIODIC, R.string.onboarding_notifications_mode_periodic, R.string.onboarding_notifications_mode_periodic_desc)
NotificationButton(currentMode, NotificationsMode.SERVICE, R.string.onboarding_notifications_mode_service, R.string.onboarding_notifications_mode_service_desc)
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
OnboardingActionButton(R.string.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage) {
changeNotificationsMode(currentMode.value, m)
}
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}
}
@Composable
private fun NotificationButton(currentMode: MutableState<NotificationsMode>, mode: NotificationsMode, @StringRes title: Int, @StringRes description: Int) {
TextButton(
onClick = { currentMode.value = mode },
border = BorderStroke(1.dp, color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight.copy(alpha = 0.5f)),
shape = RoundedCornerShape(15.dp),
) {
Column(Modifier.padding(bottom = 6.dp).padding(horizontal = 8.dp)) {
Text(
stringResource(title),
style = MaterialTheme.typography.h2,
fontWeight = FontWeight.Medium,
color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(annotatedStringResource(description), color = MaterialTheme.colors.onBackground, lineHeight = 24.sp)
}
}
Spacer(Modifier.height(DEFAULT_PADDING))
}

View File

@@ -25,6 +25,7 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
@@ -104,14 +105,14 @@ private fun InfoRow(icon: Painter, @StringRes titleId: Int, @StringRes textId: I
@Composable
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
if (user == null) {
OnboardingActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
ActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
} else {
OnboardingActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
ActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
}
}
@Composable
fun OnboardingActionButton(
private fun ActionButton(
@StringRes labelId: Int,
onboarding: OnboardingStage?,
onboardingStage: MutableState<OnboardingStage?>,

View File

@@ -1,301 +0,0 @@
package chat.simplex.app.views.onboarding
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) }
@Composable
fun featureDescription(icon: ImageVector, titleId: Int, descrId: Int, link: String?) {
@Composable
fun linkButton(link: String) {
val uriHandler = LocalUriHandler.current
Icon(
Icons.Outlined.OpenInNew, stringResource(titleId), tint = MaterialTheme.colors.primary,
modifier = Modifier
.clickable { uriHandler.openUri(link) }
)
}
Column(
horizontalAlignment = Alignment.Start
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(icon, stringResource(titleId), tint = HighOrLowlight)
Text(
generalGetString(titleId),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Medium
)
if (link != null) {
linkButton(link)
}
}
Text(generalGetString(descrId))
}
}
@Composable
fun pagination() {
Row(
Modifier
.padding(bottom = 16.dp)
) {
if (currentVersion.value > 0) {
val prev = currentVersion.value - 1
Surface(shape = RoundedCornerShape(20.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.clickable { currentVersion.value = prev }
.padding(8.dp)
) {
Icon(Icons.Outlined.ArrowBackIosNew, "previous", tint = MaterialTheme.colors.primary)
Text(versionDescriptions[prev].version, color = MaterialTheme.colors.primary)
}
}
}
Spacer(Modifier.fillMaxWidth().weight(1f))
if (currentVersion.value < versionDescriptions.lastIndex) {
val next = currentVersion.value + 1
Surface(shape = RoundedCornerShape(20.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.clickable { currentVersion.value = next }
.padding(8.dp)
) {
Text(versionDescriptions[next].version, color = MaterialTheme.colors.primary)
Icon(Icons.Outlined.ArrowForwardIos, "next", tint = MaterialTheme.colors.primary)
}
}
}
}
}
val v = versionDescriptions[currentVersion.value]
ModalView(close = close) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
String.format(generalGetString(R.string.new_in_version), v.version),
Modifier
.fillMaxWidth()
.padding(DEFAULT_PADDING),
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Normal,
color = HighOrLowlight
)
v.features.forEach { feature ->
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link)
}
if (!viaSettings) {
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(
Modifier.fillMaxWidth(), contentAlignment = Alignment.Center
) {
Text(
generalGetString(R.string.ok),
modifier = Modifier.clickable(onClick = close),
style = MaterialTheme.typography.h3,
color = MaterialTheme.colors.primary
)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}
Spacer(Modifier.fillMaxHeight().weight(1f))
pagination()
}
}
}
private data class FeatureDescription(
val icon: ImageVector,
val titleId: Int,
val descrId: Int,
val link: String? = null
)
private data class VersionDescription(
val version: String,
val features: List<FeatureDescription>
)
private val versionDescriptions: List<VersionDescription> = listOf(
VersionDescription(
version = "v4.2",
features = listOf(
FeatureDescription(
icon = Icons.Outlined.VerifiedUser,
titleId = R.string.v4_2_security_assessment,
descrId = R.string.v4_2_security_assessment_desc,
link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"
),
FeatureDescription(
icon = Icons.Outlined.Group,
titleId = R.string.v4_2_group_links,
descrId = R.string.v4_2_group_links_desc
),
FeatureDescription(
icon = Icons.Outlined.Check,
titleId = R.string.v4_2_auto_accept_contact_requests,
descrId = R.string.v4_2_auto_accept_contact_requests_desc
),
)
),
VersionDescription(
version = "v4.3",
features = listOf(
FeatureDescription(
icon = Icons.Outlined.Mic,
titleId = R.string.v4_3_voice_messages,
descrId = R.string.v4_3_voice_messages_desc
),
FeatureDescription(
icon = Icons.Outlined.DeleteForever,
titleId = R.string.v4_3_irreversible_message_deletion,
descrId = R.string.v4_3_irreversible_message_deletion_desc
),
FeatureDescription(
icon = Icons.Outlined.WifiTethering,
titleId = R.string.v4_3_improved_server_configuration,
descrId = R.string.v4_3_improved_server_configuration_desc
),
FeatureDescription(
icon = Icons.Outlined.VisibilityOff,
titleId = R.string.v4_3_improved_privacy_and_security,
descrId = R.string.v4_3_improved_privacy_and_security_desc
),
)
),
VersionDescription(
version = "v4.4",
features = listOf(
FeatureDescription(
icon = Icons.Outlined.Timer,
titleId = R.string.v4_4_disappearing_messages,
descrId = R.string.v4_4_disappearing_messages_desc
),
FeatureDescription(
icon = Icons.Outlined.Pending,
titleId = R.string.v4_4_live_messages,
descrId = R.string.v4_4_live_messages_desc
),
FeatureDescription(
icon = Icons.Outlined.VerifiedUser,
titleId = R.string.v4_4_verify_connection_security,
descrId = R.string.v4_4_verify_connection_security_desc
),
FeatureDescription(
icon = Icons.Outlined.Translate,
titleId = R.string.v4_4_french_interface,
descrId = R.string.v4_4_french_interface_descr
)
)
),
VersionDescription(
version = "v4.5",
features = listOf(
FeatureDescription(
icon = Icons.Outlined.ManageAccounts,
titleId = R.string.v4_5_multiple_chat_profiles,
descrId = R.string.v4_5_multiple_chat_profiles_descr
),
FeatureDescription(
icon = Icons.Outlined.EditNote,
titleId = R.string.v4_5_message_draft,
descrId = R.string.v4_5_message_draft_descr
),
FeatureDescription(
icon = Icons.Outlined.SafetyDivider,
titleId = R.string.v4_5_transport_isolation,
descrId = R.string.v4_5_transport_isolation_descr,
link = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation"
),
FeatureDescription(
icon = Icons.Outlined.Task,
titleId = R.string.v4_5_private_filenames,
descrId = R.string.v4_5_private_filenames_descr
),
FeatureDescription(
icon = Icons.Outlined.Battery2Bar,
titleId = R.string.v4_5_reduced_battery_usage,
descrId = R.string.v4_5_reduced_battery_usage_descr
),
FeatureDescription(
icon = Icons.Outlined.Translate,
titleId = R.string.v4_5_italian_interface,
descrId = R.string.v4_5_italian_interface_descr,
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#translate-the-apps"
)
)
)
)
private val lastVersion = versionDescriptions.last().version
fun setLastVersionDefault(m: ChatModel) {
m.controller.appPrefs.whatsNewVersion.set(lastVersion)
}
fun shouldShowWhatsNew(m: ChatModel): Boolean {
val v = m.controller.appPrefs.whatsNewVersion.get()
setLastVersionDefault(m)
return v != lastVersion
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewWhatsNewView() {
SimpleXTheme {
WhatsNewView(
viaSettings = true,
close = {}
)
}
}

View File

@@ -33,7 +33,6 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
val networkTCPConnectTimeout = remember { mutableStateOf(currentCfgVal.tcpConnectTimeout) }
val networkTCPTimeout = remember { mutableStateOf(currentCfgVal.tcpTimeout) }
val networkSMPPingInterval = remember { mutableStateOf(currentCfgVal.smpPingInterval) }
val networkSMPPingCount = remember { mutableStateOf(currentCfgVal.smpPingCount) }
val networkEnableKeepAlive = remember { mutableStateOf(currentCfgVal.enableKeepAlive) }
val networkTCPKeepIdle: MutableState<Int>
val networkTCPKeepIntvl: MutableState<Int>
@@ -49,6 +48,10 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
}
fun buildCfg(): NetCfg {
val socksProxy = currentCfg.value.socksProxy
val tcpConnectTimeout = networkTCPConnectTimeout.value
val tcpTimeout = networkTCPTimeout.value
val smpPingInterval = networkSMPPingInterval.value
val enableKeepAlive = networkEnableKeepAlive.value
val tcpKeepAlive = if (enableKeepAlive) {
val keepIdle = networkTCPKeepIdle.value
@@ -59,15 +62,11 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
null
}
return NetCfg(
socksProxy = currentCfg.value.socksProxy,
hostMode = currentCfg.value.hostMode,
requiredHostMode = currentCfg.value.requiredHostMode,
sessionMode = currentCfg.value.sessionMode,
tcpConnectTimeout = networkTCPConnectTimeout.value,
tcpTimeout = networkTCPTimeout.value,
socksProxy = socksProxy,
tcpConnectTimeout = tcpConnectTimeout,
tcpTimeout = tcpTimeout,
tcpKeepAlive = tcpKeepAlive,
smpPingInterval = networkSMPPingInterval.value,
smpPingCount = networkSMPPingCount.value
smpPingInterval = smpPingInterval
)
}
@@ -75,7 +74,6 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
networkTCPConnectTimeout.value = cfg.tcpConnectTimeout
networkTCPTimeout.value = cfg.tcpTimeout
networkSMPPingInterval.value = cfg.smpPingInterval
networkSMPPingCount.value = cfg.smpPingCount
networkEnableKeepAlive.value = cfg.enableKeepAlive
if (cfg.tcpKeepAlive != null) {
networkTCPKeepIdle.value = cfg.tcpKeepAlive.keepIdle
@@ -115,7 +113,6 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
networkTCPConnectTimeout,
networkTCPTimeout,
networkSMPPingInterval,
networkSMPPingCount,
networkEnableKeepAlive,
networkTCPKeepIdle,
networkTCPKeepIntvl,
@@ -132,7 +129,6 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
networkTCPConnectTimeout: MutableState<Long>,
networkTCPTimeout: MutableState<Long>,
networkSMPPingInterval: MutableState<Long>,
networkSMPPingCount: MutableState<Int>,
networkEnableKeepAlive: MutableState<Boolean>,
networkTCPKeepIdle: MutableState<Int>,
networkTCPKeepIntvl: MutableState<Int>,
@@ -174,14 +170,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
SectionItemView {
TimeoutSettingRow(
stringResource(R.string.network_option_ping_interval), networkSMPPingInterval,
listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel
)
}
SectionDivider()
SectionItemView {
IntSettingRow(
stringResource(R.string.network_option_ping_count), networkSMPPingCount,
listOf(1, 2, 3, 5, 8), ""
listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000), secondsLabel
)
}
SectionDivider()
@@ -423,7 +412,6 @@ fun PreviewAdvancedNetworkSettingsLayout() {
networkTCPConnectTimeout = remember { mutableStateOf(10_000000) },
networkTCPTimeout = remember { mutableStateOf(10_000000) },
networkSMPPingInterval = remember { mutableStateOf(10_000000) },
networkSMPPingCount = remember { mutableStateOf(3) },
networkEnableKeepAlive = remember { mutableStateOf(true) },
networkTCPKeepIdle = remember { mutableStateOf(10) },
networkTCPKeepIntvl = remember { mutableStateOf(10) },

View File

@@ -24,6 +24,8 @@ import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -63,6 +65,11 @@ fun AppearanceView() {
AppearanceLayout(
appIcon,
changeIcon = ::setAppIcon,
showThemeSelector = {
ModalManager.shared.showModal(true) {
ThemeSelectorView()
}
},
editPrimaryColor = { primary ->
ModalManager.shared.showModalCloseable { close ->
ColorEditor(primary, close)
@@ -74,6 +81,7 @@ fun AppearanceView() {
@Composable fun AppearanceLayout(
icon: MutableState<AppIcon>,
changeIcon: (AppIcon) -> Unit,
showThemeSelector: () -> Unit,
editPrimaryColor: (Color) -> Unit,
) {
Column(
@@ -107,12 +115,8 @@ fun AppearanceView() {
SectionSpacer()
val currentTheme by CurrentColors.collectAsState()
SectionView(stringResource(R.string.settings_section_title_themes)) {
SectionItemViewSpaceBetween {
val darkTheme = isSystemInDarkTheme()
val state = remember { derivedStateOf { currentTheme.second } }
ThemeSelector(state) {
ThemeManager.applyTheme(it.name, darkTheme)
}
SectionItemViewSpaceBetween(showThemeSelector) {
Text(generalGetString(R.string.theme))
}
SectionDivider()
SectionItemViewSpaceBetween({ editPrimaryColor(currentTheme.first.primary) }) {
@@ -179,21 +183,6 @@ fun ColorPicker(initialColor: Color, onColorChanged: (Color) -> Unit) {
)
}
@Composable
private fun ThemeSelector(state: State<DefaultTheme>, onSelected: (DefaultTheme) -> Unit) {
val darkTheme = isSystemInDarkTheme()
val values by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second to it.third }) }
ExposedDropDownSettingRow(
generalGetString(R.string.theme),
values,
state,
icon = null,
enabled = remember { mutableStateOf(true) },
onSelected = onSelected
)
}
private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon ->
SimplexApp.context.packageManager.getComponentEnabledSetting(
ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}")
@@ -207,6 +196,7 @@ fun PreviewAppearanceSettings() {
AppearanceLayout(
icon = remember { mutableStateOf(AppIcon.DARK_BLUE) },
changeIcon = {},
showThemeSelector = {},
editPrimaryColor = {},
)
}

View File

@@ -30,8 +30,8 @@ fun CallSettingsView(m: ChatModel,
@Composable
fun CallSettingsLayout(
webrtcPolicyRelay: SharedPreference<Boolean>,
callOnLockScreen: SharedPreference<CallOnLockScreen>,
webrtcPolicyRelay: Preference<Boolean>,
callOnLockScreen: Preference<CallOnLockScreen>,
editIceServers: () -> Unit,
) {
Column(
@@ -79,10 +79,9 @@ private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: Sta
@Composable
fun SharedPreferenceToggle(
text: String,
preference: SharedPreference<Boolean>,
preferenceState: MutableState<Boolean>? = null,
onChange: ((Boolean) -> Unit)? = null,
) {
preference: Preference<Boolean>,
preferenceState: MutableState<Boolean>? = null
) {
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(text, Modifier.padding(end = 24.dp))
@@ -92,7 +91,6 @@ fun SharedPreferenceToggle(
onCheckedChange = {
preference.set(it)
prefState.value = it
onChange?.invoke(it)
},
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
@@ -108,7 +106,7 @@ fun SharedPreferenceToggleWithIcon(
icon: ImageVector,
stopped: Boolean = false,
onClickInfo: () -> Unit,
preference: SharedPreference<Boolean>,
preference: Preference<Boolean>,
preferenceState: MutableState<Boolean>? = null
) {
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
@@ -137,7 +135,7 @@ fun SharedPreferenceToggleWithIcon(
}
@Composable
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: SharedPreference<T>, value: T) {
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text)
val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary)

View File

@@ -3,9 +3,7 @@ package chat.simplex.app.views.usersettings
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
import java.security.KeyStore
@@ -33,7 +31,7 @@ internal class Cryptor {
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull()
return String(cipher.doFinal(data))
}
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {

View File

@@ -17,13 +17,18 @@ import chat.simplex.app.model.Format
import chat.simplex.app.model.FormatColor
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.AppBarTitle
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun MarkdownHelpView() {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.how_to_use_markdown), false)
Text(stringResource(R.string.you_can_use_markdown_to_format_messages__prompt))
Spacer(Modifier.height(DEFAULT_PADDING))
val bold = stringResource(R.string.bold)

View File

@@ -31,17 +31,11 @@ fun NetworkAndServersView(
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
val sessionMode = remember { mutableStateOf(netCfg.sessionMode) }
LaunchedEffect(Unit) {
chatModel.userSMPServersUnsaved.value = null
}
NetworkAndServersLayout(
developerTools = developerTools,
networkUseSocksProxy = networkUseSocksProxy,
onionHosts = onionHosts,
sessionMode = sessionMode,
showModal = showModal,
showSettingsModal = showSettingsModal,
toggleSocksProxy = { enable ->
@@ -84,13 +78,9 @@ fun NetworkAndServersView(
OnionHosts.PREFER -> generalGetString(R.string.network_use_onion_hosts_prefer_desc_in_alert)
OnionHosts.REQUIRED -> generalGetString(R.string.network_use_onion_hosts_required_desc_in_alert)
}
updateNetworkSettingsDialog(
title = generalGetString(R.string.update_onion_hosts_settings_question),
startsWith,
onDismiss = {
onionHosts.value = prevValue
}
) {
updateOnionHostsDialog(startsWith, onDismiss = {
onionHosts.value = prevValue
}) {
withApi {
val newCfg = chatModel.controller.getNetCfg().withOnionHosts(it)
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
@@ -102,31 +92,6 @@ fun NetworkAndServersView(
}
}
}
},
updateSessionMode = {
if (sessionMode.value == it) return@NetworkAndServersLayout
val prevValue = sessionMode.value
sessionMode.value = it
val startsWith = when (it) {
TransportSessionMode.User -> generalGetString(R.string.network_session_mode_user_description)
TransportSessionMode.Entity -> generalGetString(R.string.network_session_mode_entity_description)
}
updateNetworkSettingsDialog(
title = generalGetString(R.string.update_network_session_mode_question),
startsWith,
onDismiss = { sessionMode.value = prevValue }
) {
withApi {
val newCfg = chatModel.controller.getNetCfg().copy(sessionMode = it)
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
if (res) {
chatModel.controller.setNetCfg(newCfg)
sessionMode.value = it
} else {
sessionMode.value = prevValue
}
}
}
}
)
}
@@ -135,12 +100,10 @@ fun NetworkAndServersView(
developerTools: Boolean,
networkUseSocksProxy: MutableState<Boolean>,
onionHosts: MutableState<OnionHosts>,
sessionMode: MutableState<TransportSessionMode>,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
toggleSocksProxy: (Boolean) -> Unit,
useOnion: (OnionHosts) -> Unit,
updateSessionMode: (TransportSessionMode) -> Unit,
) {
Column(
Modifier.fillMaxWidth(),
@@ -149,19 +112,17 @@ fun NetworkAndServersView(
) {
AppBarTitle(stringResource(R.string.network_and_servers))
SectionView(generalGetString(R.string.settings_section_title_messages)) {
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showSettingsModal { SMPServersView(it) })
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
SectionDivider()
SectionItemView {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
}
SectionDivider()
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
SectionDivider()
if (developerTools) {
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
SectionDivider()
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
Spacer(Modifier.height(8.dp))
SectionView(generalGetString(R.string.settings_section_title_calls)) {
@@ -220,6 +181,7 @@ private fun UseOnionHosts(
}
}
val onSelected = showModal {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
@@ -239,47 +201,14 @@ private fun UseOnionHosts(
)
}
@Composable
private fun SessionModePicker(
sessionMode: MutableState<TransportSessionMode>,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
updateSessionMode: (TransportSessionMode) -> Unit,
) {
val values = remember {
TransportSessionMode.values().map {
when (it) {
TransportSessionMode.User -> ValueTitleDesc(TransportSessionMode.User, generalGetString(R.string.network_session_mode_user), generalGetString(R.string.network_session_mode_user_description))
TransportSessionMode.Entity -> ValueTitleDesc(TransportSessionMode.Entity, generalGetString(R.string.network_session_mode_entity), generalGetString(R.string.network_session_mode_entity_description))
}
}
}
SectionItemWithValue(
generalGetString(R.string.network_session_mode_transport_isolation),
sessionMode,
values,
icon = Icons.Outlined.SafetyDivider,
onSelected = showModal {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.network_session_mode_transport_isolation))
SectionViewSelectable(null, sessionMode, values, updateSessionMode)
}
}
)
}
private fun updateNetworkSettingsDialog(
title: String,
private fun updateOnionHostsDialog(
startsWith: String = "",
message: String = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertManager.shared.showAlertDialog(
title = title,
title = generalGetString(R.string.update_onion_hosts_settings_question),
text = startsWith + "\n\n" + message,
confirmText = generalGetString(R.string.update_network_settings_confirmation),
onDismiss = onDismiss,
@@ -299,9 +228,7 @@ fun PreviewNetworkAndServersLayout() {
showSettingsModal = { {} },
toggleSocksProxy = {},
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
sessionMode = remember { mutableStateOf(TransportSessionMode.User) },
useOnion = {},
updateSessionMode = {},
)
}
}

View File

@@ -46,6 +46,25 @@ enum class NotificationPreviewMode {
fun NotificationsSettingsView(
chatModel: ChatModel,
) {
val onNotificationsModeSelected = { mode: NotificationsMode ->
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
}
chatModel.notificationsMode.value = mode
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
CoroutineScope(Dispatchers.Default).launch {
if (mode == NotificationsMode.SERVICE)
SimplexService.start(SimplexApp.context)
else
SimplexService.safeStopService(SimplexApp.context)
}
if (mode != NotificationsMode.PERIODIC) {
MessagesFetcherWorker.cancelAll()
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
}
val onNotificationPreviewModeSelected = { mode: NotificationPreviewMode ->
chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name)
chatModel.notificationPreviewMode.value = mode
@@ -57,7 +76,7 @@ fun NotificationsSettingsView(
showPage = { page ->
ModalManager.shared.showModalCloseable(true) {
when (page) {
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode) { changeNotificationsMode(it, chatModel) }
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode, onNotificationsModeSelected)
CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected)
}
}
@@ -140,7 +159,7 @@ fun NotificationPreviewView(
}
// mode, name, description
private fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
val res = ArrayList<ValueTitleDesc<NotificationsMode>>()
res.add(
ValueTitleDesc(
@@ -192,23 +211,3 @@ fun notificationPreviewModes(): List<ValueTitleDesc<NotificationPreviewMode>> {
)
return res
}
fun changeNotificationsMode(mode: NotificationsMode, chatModel: ChatModel) {
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
}
chatModel.notificationsMode.value = mode
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
CoroutineScope(Dispatchers.Default).launch {
if (mode == NotificationsMode.SERVICE)
SimplexService.start(SimplexApp.context)
else
SimplexService.safeStopService(SimplexApp.context)
}
if (mode != NotificationsMode.PERIODIC) {
MessagesFetcherWorker.cancelAll()
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
}

View File

@@ -1,144 +0,0 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) {
var preferences by rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(user.fullPreferences) }
var currentPreferences by rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(preferences) }
fun savePrefs(afterSave: () -> Unit = {}) {
withApi {
val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences())
val updatedProfile = m.controller.apiUpdateProfile(newProfile)
if (updatedProfile != null) {
m.updateCurrentUser(updatedProfile, preferences)
currentPreferences = preferences
}
afterSave()
}
}
ModalView(
close = {
if (preferences == currentPreferences) close()
else showUnsavedChangesAlert({ savePrefs(close) }, close)
},
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
PreferencesLayout(
preferences,
currentPreferences,
applyPrefs = { preferences = it },
reset = { preferences = currentPreferences },
savePrefs = ::savePrefs,
)
}
}
@Composable
private fun PreferencesLayout(
preferences: FullChatPreferences,
currentPreferences: FullChatPreferences,
applyPrefs: (FullChatPreferences) -> Unit,
reset: () -> Unit,
savePrefs: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.your_preferences))
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.allow) }
TimedMessagesFeatureSection(timedMessages) {
applyPrefs(preferences.copy(timedMessages = TimedMessagesPreference(allow = if (it) FeatureAllowed.YES else FeatureAllowed.NO)))
}
SectionSpacer()
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) }
FeatureSection(ChatFeature.FullDelete, allowFullDeletion) {
applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it)))
}
SectionSpacer()
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
FeatureSection(ChatFeature.Voice, allowVoice) {
applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it)))
}
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = preferences == currentPreferences
)
}
}
@Composable
private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllowed>, onSelected: (FeatureAllowed) -> Unit) {
SectionView {
SectionItemView {
ExposedDropDownSettingRow(
feature.text,
FeatureAllowed.values().map { it to it.text },
allowFeature,
icon = feature.icon,
onSelected = onSelected
)
}
}
SectionTextFooter(feature.allowDescription(allowFeature.value))
}
@Composable
private fun TimedMessagesFeatureSection(allowFeature: State<FeatureAllowed>, onSelected: (Boolean) -> Unit) {
SectionView {
SectionItemView {
PreferenceToggleWithIcon(
ChatFeature.TimedMessages.text,
ChatFeature.TimedMessages.icon,
HighOrLowlight,
allowFeature.value == FeatureAllowed.ALWAYS || allowFeature.value == FeatureAllowed.YES,
onSelected
)
}
}
SectionTextFooter(ChatFeature.TimedMessages.allowDescription(allowFeature.value))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
SectionDivider()
SectionItemView(save, disabled = disabled) {
Text(stringResource(R.string.save_and_notify_contacts), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(R.string.save_preferences_question),
confirmText = generalGetString(R.string.save_and_notify_contacts),
dismissText = generalGetString(R.string.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}

View File

@@ -1,88 +1,39 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import android.view.WindowManager
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.AppBarTitle
@Composable
fun PrivacySettingsView(
chatModel: ChatModel,
setPerformLA: (Boolean) -> Unit
) {
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode
AppBarTitle(stringResource(R.string.your_privacy))
SectionView(stringResource(R.string.settings_section_title_device)) {
ChatLockItem(chatModel.performLA, setPerformLA)
SectionDivider()
val context = LocalContext.current
SettingsPreferenceItem(Icons.Outlined.VisibilityOff, stringResource(R.string.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen) { on ->
if (on) {
(context as? FragmentActivity)?.window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
} else {
(context as? FragmentActivity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_chats)) {
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
SectionDivider()
SectionItemView { SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = {
simplexLinkMode.set(it)
chatModel.simplexLinkMode.value = it
}) }
}
if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) {
SectionTextFooter(stringResource(R.string.simplex_link_mode_browser_warning))
}
}
}
@Composable
private fun SimpleXLinkOptions(simplexLinkModeState: State<SimplexLinkMode>, onSelected: (SimplexLinkMode) -> Unit) {
val values = remember {
SimplexLinkMode.values().map {
when (it) {
SimplexLinkMode.DESCRIPTION -> it to generalGetString(R.string.simplex_link_mode_description)
SimplexLinkMode.FULL -> it to generalGetString(R.string.simplex_link_mode_full)
SimplexLinkMode.BROWSER -> it to generalGetString(R.string.simplex_link_mode_browser)
if (chatModel.controller.appPrefs.developerTools.get()) {
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
SectionDivider()
}
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
}
}
ExposedDropDownSettingRow(
generalGetString(R.string.simplex_link_mode),
values,
simplexLinkModeState,
icon = null,
enabled = remember { mutableStateOf(true) },
onSelected = onSelected
)
}

View File

@@ -1,200 +0,0 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionItemViewSpaceBetween
import SectionSpacer
import SectionView
import android.util.Log
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@Composable
fun SMPServerView(m: ChatModel, server: ServerCfg, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
var testing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
SMPServerLayout(
testing,
server,
testServer = {
testing = true
scope.launch {
val res = testServerConnection(server, m)
if (isActive) {
onUpdate(res.first)
testing = false
}
}
},
onUpdate,
onDelete
)
if (testing) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
@Composable
private fun SMPServerLayout(
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(if (server.preset) R.string.smp_servers_preset_server else R.string.smp_servers_your_server))
if (server.preset) {
PresetServer(testing, server, testServer, onUpdate, onDelete)
} else {
CustomServer(testing, server, testServer, onUpdate, onDelete)
}
}
}
@Composable
private fun PresetServer(
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
SectionView(stringResource(R.string.smp_servers_preset_address).uppercase()) {
SelectionContainer {
Text(
server.server,
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
color = HighOrLowlight
)
)
}
}
SectionSpacer()
UseServerSection(true, testing, server, testServer, onUpdate, onDelete)
}
@Composable
private fun CustomServer(
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
val serverAddress = remember { mutableStateOf(server.server) }
val valid = remember { derivedStateOf { parseServerAddress(serverAddress.value)?.valid == true } }
SectionView(
stringResource(R.string.smp_servers_your_server_address).uppercase(),
icon = Icons.Outlined.ErrorOutline,
iconTint = if (!valid.value) MaterialTheme.colors.error else Color.Transparent,
) {
val testedPreviously = remember { mutableMapOf<String, Boolean?>() }
TextEditor(
Modifier.height(144.dp),
text = serverAddress,
border = false,
fontSize = 16.sp,
background = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background
) {
testedPreviously[server.server] = server.tested
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
}
}
SectionSpacer()
UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete)
SectionSpacer()
if (valid.value) {
SectionView(stringResource(R.string.smp_servers_add_to_another_device).uppercase()) {
QRCode(serverAddress.value, Modifier.aspectRatio(1f))
}
}
}
@Composable
private fun UseServerSection(
valid: Boolean,
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
SectionView(stringResource(R.string.smp_servers_use_server).uppercase()) {
SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) {
Text(stringResource(R.string.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else HighOrLowlight)
ShowTestStatus(server)
}
SectionDivider()
SectionItemView {
val enabled = rememberUpdatedState(server.enabled)
PreferenceToggle(stringResource(R.string.smp_servers_use_server_for_new_conn), enabled.value) { onUpdate(server.copy(enabled = it)) }
}
SectionDivider()
SectionItemView(onDelete, disabled = testing) {
Text(stringResource(R.string.smp_servers_delete_server), color = if (testing) HighOrLowlight else MaterialTheme.colors.error)
}
}
}
@Composable
fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) =
when (server.tested) {
true -> Icon(Icons.Outlined.Check, null, modifier, tint = SimplexGreen)
false -> Icon(Icons.Outlined.Clear, null, modifier, tint = MaterialTheme.colors.error)
else -> Icon(Icons.Outlined.Check, null, modifier, tint = Color.Transparent)
}
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, SMPTestFailure?> =
try {
val r = m.controller.testSMPServer(server.server)
server.copy(tested = r == null) to r
} catch (e: Exception) {
Log.e(TAG, "testServerConnection ${e.stackTraceToString()}")
server.copy(tested = false) to null
}
fun serverHostname(srv: String): String =
parseServerAddress(srv)?.hostnames?.firstOrNull() ?: srv

View File

@@ -0,0 +1,258 @@
package chat.simplex.app.views.usersettings
import SectionItemViewSpaceBetween
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun SMPServersView(chatModel: ChatModel) {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
fun saveSMPServers(smpServers: List<String>) {
withApi {
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
if (r) {
chatModel.userSMPServers.value = smpServers
if (smpServers.isEmpty()) {
isUserSMPServers = false
editSMPServers = true
} else {
editSMPServers = false
}
}
}
}
SMPServersLayout(
isUserSMPServers = isUserSMPServers,
editSMPServers = editSMPServers,
userSMPServersStr = userSMPServersStr,
isUserSMPServersOnOff = { switch ->
if (switch) {
isUserSMPServers = true
} else {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
if (userSMPServers.isNotEmpty()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.use_simplex_chat_servers__question),
text = generalGetString(R.string.saved_SMP_servers_will_be_removed),
confirmText = generalGetString(R.string.confirm_verb),
onConfirm = {
saveSMPServers(listOf())
isUserSMPServers = false
userSMPServersStr.value = ""
}
)
} else {
isUserSMPServers = false
userSMPServersStr.value = ""
}
}
}
},
cancelEdit = {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
isUserSMPServers = userSMPServers.isNotEmpty()
editSMPServers = !isUserSMPServers
userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
}
},
saveSMPServers = { saveSMPServers(it) },
editOn = { editSMPServers = true },
)
}
}
@Composable
fun SMPServersLayout(
isUserSMPServers: Boolean,
editSMPServers: Boolean,
userSMPServersStr: MutableState<String>,
isUserSMPServersOnOff: (Boolean) -> Unit,
cancelEdit: () -> Unit,
saveSMPServers: (List<String>) -> Unit,
editOn: () -> Unit,
) {
Column {
AppBarTitle(stringResource(R.string.your_SMP_servers))
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
SectionItemViewSpaceBetween(padding = PaddingValues()) {
Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
Switch(
checked = isUserSMPServers,
onCheckedChange = isUserSMPServersOnOff,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
if (!isUserSMPServers) {
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
} else {
Text(stringResource(R.string.enter_one_SMP_server_per_line))
if (editSMPServers) {
TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Row {
Text(
stringResource(R.string.cancel_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = cancelEdit)
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
stringResource(R.string.save_servers_button),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable(onClick = {
val servers = userSMPServersStr.value.split("\n")
saveSMPServers(servers)
})
)
}
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
} else {
Surface(
modifier = Modifier
.height(160.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
SelectionContainer(
Modifier.verticalScroll(rememberScrollState())
) {
Text(
userSMPServersStr.value,
Modifier
.padding(vertical = 5.dp, horizontal = 7.dp),
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
)
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Text(
stringResource(R.string.edit_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editOn)
)
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
}
}
}
}
}
@Composable
private fun howToButton() {
val uriHandler = LocalUriHandler.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
) {
Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary)
Icon(
Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(horizontal = 5.dp)
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutDefaultServers() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = false,
editSMPServers = true,
userSMPServersStr = remember { mutableStateOf("") },
isUserSMPServersOnOff = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutUserServersEditOn() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = true,
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
isUserSMPServersOnOff = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutUserServersEditOff() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = false,
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
isUserSMPServersOnOff = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}

View File

@@ -1,335 +0,0 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.launch
@Composable
fun SMPServersView(m: ChatModel) {
var servers by remember {
mutableStateOf(m.userSMPServersUnsaved.value ?: m.userSMPServers.value ?: emptyList())
}
val testing = rememberSaveable { mutableStateOf(false) }
val serversUnchanged = remember { derivedStateOf { servers == m.userSMPServers.value || testing.value } }
val allServersDisabled = remember { derivedStateOf { servers.all { !it.enabled } } }
val saveDisabled = remember {
derivedStateOf {
servers.isEmpty() ||
servers == m.userSMPServers.value ||
testing.value ||
!servers.all { srv ->
val address = parseServerAddress(srv.server)
address != null && uniqueAddress(srv, address, servers)
} ||
allServersDisabled.value
}
}
fun showServer(server: ServerCfg) {
ModalManager.shared.showModalCloseable(true) { close ->
var old by remember { mutableStateOf(server) }
val index = servers.indexOf(old)
SMPServerView(
m,
old,
onUpdate = { updated ->
val newServers = ArrayList(servers)
newServers.removeAt(index)
newServers.add(index, updated)
old = updated
servers = newServers
m.userSMPServersUnsaved.value = servers
},
onDelete = {
val newServers = ArrayList(servers)
newServers.removeAt(index)
servers = newServers
m.userSMPServersUnsaved.value = servers
close()
})
}
}
val scope = rememberCoroutineScope()
SMPServersLayout(
testing = testing.value,
servers = servers,
serversUnchanged = serversUnchanged.value,
saveDisabled = saveDisabled.value,
allServersDisabled = allServersDisabled.value,
m.currentUser.value,
addServer = {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(R.string.smp_servers_add),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
servers = servers + ServerCfg.empty
// No saving until something will be changed on the next screen to prevent blank servers on the list
showServer(servers.last())
}) {
Text(stringResource(R.string.smp_servers_enter_manually))
}
SectionItemView({
AlertManager.shared.hideAlert()
ModalManager.shared.showModalCloseable { close ->
ScanSMPServer {
close()
servers = servers + it
m.userSMPServersUnsaved.value = servers
}
}
}
) {
Text(stringResource(R.string.smp_servers_scan_qr))
}
val hasAllPresets = hasAllPresets(servers, m)
if (!hasAllPresets) {
SectionItemView({
AlertManager.shared.hideAlert()
servers = (servers + addAllPresets(servers, m)).sortedByDescending { it.preset }
}) {
Text(stringResource(R.string.smp_servers_preset_add), color = MaterialTheme.colors.onBackground)
}
}
}
}
)
},
testServers = {
scope.launch {
testServers(testing, servers, m) {
servers = it
m.userSMPServersUnsaved.value = servers
}
}
},
resetServers = {
servers = m.userSMPServers.value ?: emptyList()
m.userSMPServersUnsaved.value = null
},
saveSMPServers = {
saveSMPServers(servers, m)
},
showServer = ::showServer,
)
if (testing.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
@Composable
private fun SMPServersLayout(
testing: Boolean,
servers: List<ServerCfg>,
serversUnchanged: Boolean,
saveDisabled: Boolean,
allServersDisabled: Boolean,
currentUser: User?,
addServer: () -> Unit,
testServers: () -> Unit,
resetServers: () -> Unit,
saveSMPServers: () -> Unit,
showServer: (ServerCfg) -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING),
) {
AppBarTitle(stringResource(R.string.your_SMP_servers))
SectionView(stringResource(R.string.smp_servers).uppercase()) {
for (srv in servers) {
SectionItemView({ showServer(srv) }, disabled = testing) {
SmpServerView(srv, servers, testing)
}
SectionDivider()
}
SettingsActionItem(
Icons.Outlined.Add,
stringResource(R.string.smp_servers_add),
addServer,
disabled = testing,
textColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary,
iconColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary
)
}
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(R.string.smp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
SectionSpacer()
SectionView {
SectionItemView(resetServers, disabled = serversUnchanged) {
Text(stringResource(R.string.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else HighOrLowlight)
}
SectionDivider()
val testServersDisabled = testing || allServersDisabled
SectionItemView(testServers, disabled = testServersDisabled) {
Text(stringResource(R.string.smp_servers_test_servers), color = if (!testServersDisabled) MaterialTheme.colors.onBackground else HighOrLowlight)
}
SectionDivider()
SectionItemView(saveSMPServers, disabled = saveDisabled) {
Text(stringResource(R.string.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else HighOrLowlight)
}
}
SectionSpacer()
SectionView {
HowToButton()
}
}
}
@Composable
private fun SmpServerView(srv: ServerCfg, servers: List<ServerCfg>, disabled: Boolean) {
val address = parseServerAddress(srv.server)
when {
address == null || !address.valid || !uniqueAddress(srv, address, servers) -> InvalidServer()
!srv.enabled -> Icon(Icons.Outlined.DoNotDisturb, null, tint = HighOrLowlight)
else -> ShowTestStatus(srv)
}
Spacer(Modifier.padding(horizontal = 4.dp))
val text = address?.hostnames?.firstOrNull() ?: srv.server
if (srv.enabled) {
Text(text, color = if (disabled) HighOrLowlight else MaterialTheme.colors.onBackground, maxLines = 1)
} else {
Text(text, maxLines = 1, color = HighOrLowlight)
}
}
@Composable
private fun HowToButton() {
val uriHandler = LocalUriHandler.current
SettingsActionItem(
Icons.Outlined.OpenInNew,
stringResource(R.string.how_to_use_your_servers),
{ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SERVER.md") },
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary
)
}
@Composable
fun InvalidServer() {
Icon(Icons.Outlined.ErrorOutline, null, tint = MaterialTheme.colors.error)
}
private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List<ServerCfg>): Boolean = servers.all { srv ->
address.hostnames.all { host ->
srv.id == s.id || !srv.server.contains(host)
}
}
private fun hasAllPresets(servers: List<ServerCfg>, m: ChatModel): Boolean =
m.presetSMPServers.value?.all { hasPreset(it, servers) } ?: true
private fun addAllPresets(servers: List<ServerCfg>, m: ChatModel): List<ServerCfg> {
val toAdd = ArrayList<ServerCfg>()
for (srv in m.presetSMPServers.value ?: emptyList()) {
if (!hasPreset(srv, servers)) {
toAdd.add(ServerCfg(srv, preset = true, tested = null, enabled = true))
}
}
return toAdd
}
private fun hasPreset(srv: String, servers: List<ServerCfg>): Boolean =
servers.any { it.server == srv }
private suspend fun testServers(testing: MutableState<Boolean>, servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit) {
val resetStatus = resetTestStatus(servers)
onUpdated(resetStatus)
testing.value = true
val fs = runServersTest(resetStatus, m) { onUpdated(it) }
testing.value = false
if (fs.isNotEmpty()) {
val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n")
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.smp_servers_test_failed),
text = generalGetString(R.string.smp_servers_test_some_failed) + "\n" + msg
)
}
}
private fun resetTestStatus(servers: List<ServerCfg>): List<ServerCfg> {
val copy = ArrayList(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
copy.removeAt(index)
copy.add(index, server.copy(tested = null))
}
}
return copy
}
private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit): Map<String, SMPTestFailure> {
val fs: MutableMap<String, SMPTestFailure> = mutableMapOf()
val updatedServers = ArrayList<ServerCfg>(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
val (updatedServer, f) = testServerConnection(server, m)
updatedServers.removeAt(index)
updatedServers.add(index, updatedServer)
// toList() is important. Otherwise, Compose will not redraw the screen after first update
onUpdated(updatedServers.toList())
if (f != null) {
fs[serverHostname(updatedServer.server)] = f
}
}
}
return fs
}
private fun saveSMPServers(servers: List<ServerCfg>, m: ChatModel) {
withApi {
if (m.controller.setUserSMPServers(servers)) {
m.userSMPServers.value = servers
m.userSMPServersUnsaved.value = null
}
}
}

View File

@@ -1,55 +0,0 @@
package chat.simplex.app.views.usersettings
import android.Manifest
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.app.model.ServerCfg
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCodeScanner
import com.google.accompanist.permissions.rememberPermissionState
@Composable
fun ScanSMPServer(onNext: (ServerCfg) -> Unit) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
ScanSMPServerLayout(onNext)
}
@Composable
private fun ScanSMPServerLayout(onNext: (ServerCfg) -> Unit) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.smp_servers_scan_qr), false)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
.padding(bottom = 12.dp)
) {
QRCodeScanner { text ->
val res = parseServerAddress(text)
if (res != null) {
onNext(ServerCfg(text, false, null, true))
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.smp_servers_invalid_address),
text = generalGetString(R.string.smp_servers_check_address)
)
}
}
}
}
}

View File

@@ -4,7 +4,6 @@ import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import android.content.Context
import android.content.res.Configuration
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -17,14 +16,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
@@ -35,7 +34,6 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.CreateLinkTab
import chat.simplex.app.views.newchat.CreateLinkView
import chat.simplex.app.views.onboarding.SimpleXInfo
import chat.simplex.app.views.onboarding.WhatsNewView
@Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
@@ -45,8 +43,6 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
MaintainIncognitoState(chatModel)
if (user != null) {
val requireAuth = remember { chatModel.controller.appPrefs.performLA.state }
val context = LocalContext.current
SettingsLayout(
profile = user.profile,
stopped,
@@ -59,43 +55,8 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { modalView(chatModel) } } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showVersion = {
withApi {
val info = chatModel.controller.apiGetVersion()
if (info != null) {
ModalManager.shared.showModal { VersionInfoView(info) }
}
}
},
withAuth = { block ->
if (!requireAuth.value) {
block()
} else {
ModalManager.shared.showModalCloseable { close ->
val onFinishAuth = { success: Boolean ->
if (success) {
close()
block()
}
}
LaunchedEffect(Unit) {
runAuth(context, onFinishAuth)
}
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(R.string.auth_unlock),
icon = Icons.Outlined.Lock,
click = {
runAuth(context, onFinishAuth)
}
)
}
}
}
},
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
)
}
}
@@ -103,21 +64,31 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
val simplexTeamUri =
"simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
// TODO pass close
//fun showSectionedModal(chatModel: ChatModel, modalView: (@Composable (ChatModel) -> Unit)) {
// ModalManager.shared.showCustomModal { close ->
// ModalView(close = close, modifier = Modifier,
// background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
// modalView(chatModel)
// }
// }
//}
@Composable
fun SettingsLayout(
profile: LocalProfile,
stopped: Boolean,
encrypted: Boolean,
incognito: MutableState<Boolean>,
incognitoPref: SharedPreference<Boolean>,
developerTools: SharedPreference<Boolean>,
incognitoPref: Preference<Boolean>,
developerTools: Preference<Boolean>,
userDisplayName: String,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showVersion: () -> Unit,
withAuth: (block: () -> Unit) -> Unit
showTerminal: () -> Unit,
// showVideoChatPrototype: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Surface(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
@@ -141,38 +112,34 @@ fun SettingsLayout(
ProfilePreview(profile, stopped = stopped)
}
SectionDivider()
SettingsActionItem(Icons.Outlined.ManageAccounts, stringResource(R.string.your_chat_profiles), { withAuth { showSettingsModal { UserProfilesView(it) }() } }, disabled = stopped)
SectionDivider()
SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() }
SectionDivider()
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
SectionDivider()
ChatPreferencesItem(showCustomModal)
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_settings)) {
SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView() }, disabled = stopped)
SectionDivider()
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_help)) {
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Add, stringResource(R.string.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
SectionDivider()
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
SectionDivider()
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
@@ -193,14 +160,14 @@ fun SettingsLayout(
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools, devTools)
SectionDivider()
if (devTools.value) {
ChatConsoleItem { withAuth(showCustomModal { it, close -> TerminalView(it, close) }) }
ChatConsoleItem(showTerminal)
SectionDivider()
InstallTerminalAppItem(uriHandler)
SectionDivider()
}
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
// SectionDivider()
AppVersionItem(showVersion)
AppVersionItem()
}
}
}
@@ -208,7 +175,7 @@ fun SettingsLayout(
@Composable
fun SettingsIncognitoActionItem(
incognitoPref: SharedPreference<Boolean>,
incognitoPref: Preference<Boolean>,
incognito: MutableState<Boolean>,
stopped: Boolean,
onClickInfo: () -> Unit,
@@ -270,20 +237,6 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit))) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.chat_preferences),
click = {
withApi {
showCustomModal { m, close ->
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
}()
}
}
)
}
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -375,8 +328,8 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable private fun AppVersionItem(showVersion: () -> Unit) {
SectionItemView(showVersion) {
@Composable private fun AppVersionItem() {
SectionItemView() {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
}
@@ -412,18 +365,12 @@ fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = n
}
@Composable
fun SettingsPreferenceItem(
icon: ImageVector,
text: String,
pref: SharedPreference<Boolean>,
prefState: MutableState<Boolean>? = null,
onChange: ((Boolean) -> Unit)? = null,
) {
SectionItemView {
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
SharedPreferenceToggle(text, pref, prefState, onChange)
SharedPreferenceToggle(text, pref, prefState)
}
}
}
@@ -435,7 +382,7 @@ fun SettingsPreferenceItemWithInfo(
text: String,
stopped: Boolean,
onClickInfo: () -> Unit,
pref: SharedPreference<Boolean>,
pref: Preference<Boolean>,
prefState: MutableState<Boolean>? = null
) {
SectionItemView(onClickInfo) {
@@ -447,43 +394,21 @@ fun SettingsPreferenceItemWithInfo(
}
}
@Composable
fun PreferenceToggle(
text: String,
checked: Boolean,
onChange: (Boolean) -> Unit = {},
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(text)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = checked,
onCheckedChange = onChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
)
)
}
}
@Composable
fun PreferenceToggleWithIcon(
text: String,
icon: ImageVector? = null,
iconColor: Color? = HighOrLowlight,
icon: ImageVector,
iconColor: Color = HighOrLowlight,
checked: Boolean,
onChange: (Boolean) -> Unit = {},
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Icon(
icon,
null,
tint = iconColor ?: HighOrLowlight
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
Icon(
icon,
null,
tint = iconColor
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
@@ -499,17 +424,6 @@ fun PreferenceToggleWithIcon(
}
}
private fun runAuth(context: Context, onFinish: (success: Boolean) -> Unit) {
authenticate(
generalGetString(R.string.auth_open_chat_console),
generalGetString(R.string.auth_log_in_using_credential),
context as FragmentActivity,
completed = { laResult ->
onFinish(laResult == LAResult.Success || laResult == LAResult.Unavailable)
}
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -524,15 +438,15 @@ fun PreviewSettingsLayout() {
stopped = false,
encrypted = false,
incognito = remember { mutableStateOf(false) },
incognitoPref = SharedPreference({ false }, {}),
developerTools = SharedPreference({ false }, {}),
incognitoPref = Preference({ false }, {}),
developerTools = Preference({ false }, {}),
userDisplayName = "Alice",
setPerformLA = {},
showModal = { {} },
showSettingsModal = { {} },
showCustomModal = { {} },
showVersion = {},
withAuth = {},
showCustomModal = { {}},
showTerminal = {},
// showVideoChatPrototype = {}
)
}
}

View File

@@ -0,0 +1,44 @@
package chat.simplex.app.views.usersettings
import SectionViewSelectable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun ThemeSelectorView() {
val darkTheme = isSystemInDarkTheme()
val allThemes by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { ValueTitleDesc(it.second, it.third, "") }) }
ThemeSelectorLayout(
allThemes,
onSelectTheme = {
ThemeManager.applyTheme(it.name, darkTheme)
},
)
}
@Composable
private fun ThemeSelectorLayout(
allThemes: List<ValueTitleDesc<DefaultTheme>>,
onSelectTheme: (DefaultTheme) -> Unit,
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.settings_section_title_themes).lowercase().capitalize(Locale.current))
val currentTheme by CurrentColors.collectAsState()
val state = remember { derivedStateOf { currentTheme.second } }
SectionViewSelectable(null, state, allThemes, onSelectTheme)
}
}

View File

@@ -44,9 +44,12 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
close,
saveProfile = { displayName, fullName, image ->
withApi {
val newProfile = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName, fullName = fullName, image = image))
val p = Profile(displayName, fullName, image)
val newProfile = chatModel.controller.apiUpdateProfile(p)
if (newProfile != null) {
chatModel.updateCurrentUser(newProfile)
chatModel.currentUser.value?.profile?.profileId?.let {
chatModel.updateUserProfile(newProfile.toLocalProfile(it))
}
profile = newProfile
}
editProfile.value = false
@@ -94,7 +97,7 @@ fun UserProfileLayout(
.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start
) {
AppBarTitle(stringResource(R.string.your_current_profile), false)
AppBarTitle(stringResource(R.string.your_chat_profile), false)
Text(
stringResource(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it),
Modifier.padding(bottom = 24.dp),

View File

@@ -1,141 +0,0 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.chatlist.UserProfilePickerItem
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.CreateProfile
@Composable
fun UserProfilesView(m: ChatModel) {
val users by remember { derivedStateOf { m.users.map { it.user } } }
UserProfilesView(
users = users,
addUser = {
ModalManager.shared.showModalCloseable { close ->
CreateProfile(m, close)
}
},
activateUser = { user ->
withBGApi {
m.controller.changeActiveUser(user.userId)
}
},
removeUser = { user ->
val text = buildAnnotatedString {
append(generalGetString(R.string.users_delete_all_chats_deleted) + "\n\n" + generalGetString(R.string.users_delete_profile_for) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(user.displayName)
}
append(":")
}
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(R.string.users_delete_question),
text = text,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeUser(m, user, users, true)
}) {
Text(stringResource(R.string.users_delete_with_connections), color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeUser(m, user, users, false)
}
) {
Text(stringResource(R.string.users_delete_data_only), color = Color.Red)
}
}
}
)
}
)
}
@Composable
private fun UserProfilesView(
users: List<User>,
addUser: () -> Unit,
activateUser: (User) -> Unit,
removeUser: (User) -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING),
) {
AppBarTitle(stringResource(R.string.your_chat_profiles))
SectionView {
for (user in users) {
UserView(user, users, activateUser, removeUser)
SectionDivider()
}
SectionItemView(addUser, minHeight = 68.dp) {
Icon(Icons.Outlined.Add, stringResource(R.string.users_add), tint = MaterialTheme.colors.primary)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(stringResource(R.string.users_add), color = MaterialTheme.colors.primary)
}
}
SectionTextFooter(stringResource(R.string.your_chat_profiles_stored_locally))
}
}
@Composable
private fun UserView(user: User, users: List<User>, activateUser: (User) -> Unit, removeUser: (User) -> Unit) {
var showDropdownMenu by remember { mutableStateOf(false) }
UserProfilePickerItem(user, onLongClick = { if (users.size > 1) showDropdownMenu = true }) {
activateUser(user)
}
Box(Modifier.padding(horizontal = 16.dp)) {
DropdownMenu(
expanded = showDropdownMenu,
onDismissRequest = { showDropdownMenu = false },
Modifier.width(220.dp)
) {
ItemAction(stringResource(R.string.delete_verb), Icons.Outlined.Delete, color = Color.Red, onClick = {
removeUser(user)
showDropdownMenu = false
}
)
}
}
}
private fun removeUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean) {
if (users.size < 2) return
withBGApi {
try {
if (user.activeUser) {
val newActive = users.first { !it.activeUser }
m.controller.changeActiveUser_(newActive.userId)
}
m.controller.apiDeleteUser(user.userId, delSMPQueues)
m.users.removeAll { it.user.userId == user.userId }
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_user), e.stackTraceToString())
}
}
}

View File

@@ -1,29 +0,0 @@
package chat.simplex.app.views.usersettings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.BuildConfig
import chat.simplex.app.R
import chat.simplex.app.model.CoreVersionInfo
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.AppBarTitle
@Composable
fun VersionInfoView(info: CoreVersionInfo) {
Column(
Modifier.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start
) {
AppBarTitle(stringResource(R.string.app_version_title), false)
Text(String.format(stringResource(R.string.app_version_name), BuildConfig.VERSION_NAME))
Text(String.format(stringResource(R.string.app_version_code), BuildConfig.VERSION_CODE))
Text(String.format(stringResource(R.string.core_version), info.version))
Text(String.format(stringResource(R.string.core_build_timestamp), info.buildTimestamp))
Text(String.format(stringResource(R.string.core_simplexmq_version), info.simplexmqVersion, info.simplexmqCommit.substring(startIndex = 0, endIndex = 7)))
}
}

View File

@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View File

@@ -1,956 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="thousand_abbreviation">k</string>
<string name="connect_via_contact_link">Se connecter via le lien du contact \?</string>
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
<string name="profile_will_be_sent_to_contact_sending_link">Votre profil va être envoyé au contact qui vous a envoyé ce lien.</string>
<string name="you_will_join_group">Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe.</string>
<string name="connect_via_link_verb">Se connecter</string>
<string name="connect_via_group_link">Se connecter via le lien du groupe \?</string>
<string name="connect_via_invitation_link">Se connecter via un lien d\'invitation \?</string>
<string name="server_error">erreur</string>
<string name="server_connecting">connexion</string>
<string name="server_connected">connecté</string>
<string name="display_name_connection_established">connexion établie</string>
<string name="display_name_invited_to_connect">invité à se connecter</string>
<string name="simplex_link_invitation">Invitation unique SimpleX</string>
<string name="simplex_link_connection">via <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
<string name="simplex_link_mode">Liens SimpleX</string>
<string name="simplex_link_mode_description">Description</string>
<string name="error_deleting_contact">Erreur lors de la suppression du contact</string>
<string name="error_joining_group">Erreur lors de la liaison avec le groupe</string>
<string name="sender_cancelled_file_transfer">L\'expéditeur a annulé le transfert de fichiers.</string>
<string name="deleted_description">supprimé</string>
<string name="marked_deleted_description">supprimé</string>
<string name="unknown_message_format">format de message inconnu</string>
<string name="display_name_connecting">connexion…</string>
<string name="description_you_shared_one_time_link_incognito">vous avez partagé un lien unique en incognito</string>
<string name="description_via_group_link">via le lien de groupe</string>
<string name="description_via_contact_address_link">via le lien d\'adresse du contact</string>
<string name="description_via_contact_address_link_incognito">mode incognito via le lien d\'adresse du contact</string>
<string name="simplex_link_group">Lien de groupe SimpleX</string>
<string name="simplex_link_mode_browser">Via navigateur</string>
<string name="simplex_link_mode_browser_warning">Ouvrir le lien dans le navigateur peut réduire la confidentialité et la sécurité de la connexion. Les liens SimpleX non fiables seront en rouge.</string>
<string name="network_error_desc">Vérifiez votre connexion réseau avec <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> et réessayez.</string>
<string name="error_receiving_file">Erreur lors de la réception du fichier</string>
<string name="sender_may_have_deleted_the_connection_request">L\'expéditeur a peut-être supprimé la demande de connexion.</string>
<string name="connected_to_server_to_receive_messages_from_contact">Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact.</string>
<string name="sending_files_not_yet_supported">l\'envoi de fichiers n\'est pas encore supporté</string>
<string name="sender_you_pronoun">vous</string>
<string name="description_via_group_link_incognito">mode incognito via le lien de groupe</string>
<string name="simplex_link_contact">Adresse de contact SimpleX</string>
<string name="trying_to_connect_to_server_to_receive_messages">Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact.</string>
<string name="receiving_files_not_yet_supported">la réception de fichiers n\'est pas encore supportée</string>
<string name="connection_local_display_name">connexion <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
<string name="description_you_shared_one_time_link">vous avez partagé un lien unique</string>
<string name="description_via_one_time_link">via un lien unique</string>
<string name="description_via_one_time_link_incognito">mode incognito via un lien unique</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Assurez-vous que les adresses des serveurs SMP sont au bon format, séparées par des lignes et ne sont pas dupliquées.</string>
<string name="error_setting_network_config">Erreur lors de la mise à jour de la configuration réseau</string>
<string name="error_creating_address">Erreur lors de la création de l\'adresse</string>
<string name="contact_already_exists">Contact déjà existant</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Veuillez vérifier que vous avez utilisé le bon lien ou demandez à votre contact de vous en envoyer un autre.</string>
<string name="connection_error">Erreur de connexion</string>
<string name="error_adding_members">Erreur lors de l\'ajout de membre·s</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="invalid_message_format">format de message invalide</string>
<string name="simplex_link_mode_full">Lien entier</string>
<string name="error_saving_smp_servers">Erreur lors de la sauvegarde des serveurs SMP</string>
<string name="cannot_receive_file">Impossible de recevoir le fichier</string>
<string name="invalid_connection_link">Lien de connection invalide</string>
<string name="connection_timeout">Délai de connexion</string>
<string name="error_sending_message">Erreur lors de l\'envoi du message</string>
<string name="you_are_already_connected_to_vName_via_this_link">Vous êtes déjà connecté à <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
<string name="connection_error_auth">Erreur de connexion (AUTH)</string>
<string name="connection_error_auth_desc">A moins que votre contact ait supprimé la connexion ou que ce lien ait déjà été utilisé, il peut s\'agir d\'un bug - veuillez le signaler.
\nPour vous connecter, veuillez demander à votre contact de créer un autre lien de connexion et vérifiez que vous disposez d\'une connexion réseau stable.</string>
<string name="error_accepting_contact_request">Erreur de validation de la demande de contact</string>
<string name="error_deleting_group">Erreur lors de la suppression du groupe</string>
<string name="error_deleting_contact_request">Erreur lors de la suppression du contact</string>
<string name="error_deleting_pending_contact_connection">Erreur lors de la suppression de la connexion en attente</string>
<string name="error_changing_address">Erreur de changement d\'adresse</string>
<string name="error_smp_test_failed_at_step">Échec du test à l\'étape %s.</string>
<string name="error_smp_test_certificate">Il est possible que l\'empreinte du certificat dans l\'adresse du serveur soit incorrecte</string>
<string name="smp_server_test_connect">Se connecter</string>
<string name="smp_server_test_create_queue">Créer une file d\'attente</string>
<string name="smp_server_test_secure_queue">File d\'attente sécurisée</string>
<string name="smp_server_test_delete_queue">Supprimer la file d\'attente</string>
<string name="smp_server_test_disconnect">Se déconnecter</string>
<string name="icon_descr_instant_notifications">Notifications instantanées</string>
<string name="service_notifications">Notifications instantanées !</string>
<string name="service_notifications_disabled">Les notifications instantanées sont désactivées !</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Il peut être désactivé via les paramètres</b> - les notifications seront toujours affichées lorsque l\'application est en cours d\'exécution.</string>
<string name="turning_off_service_and_periodic">L\'optimisation de la batterie est active et désactive le service de fond et les demandes périodiques de nouveaux messages. Vous pouvez les réactiver via les paramètres.</string>
<string name="periodic_notifications">Notifications périodiques</string>
<string name="periodic_notifications_disabled">Les notifications périodiques sont désactivées !</string>
<string name="enter_passphrase_notification_title">Une phrase secrète est nécessaire</string>
<string name="turn_off_battery_optimization">Pour l\'utiliser, veuillez <b>désactiver l\'optimisation de la batterie</b> pour <xliff:g id="appName">SimpleX</xliff:g> dans la prochaine fenêtre de dialogue. Sinon, les notifications seront désactivées.</string>
<string name="error_smp_test_server_auth">Le serveur requiert une autorisation pour créer des files d\'attente, vérifiez le mot de passe</string>
<string name="periodic_notifications_desc">L\'application récupère périodiquement les nouveaux messages - elle utilise un peu votre batterie chaque jour. L\'application n\'utilise pas les notifications push - les données de votre appareil ne sont pas envoyées aux serveurs.</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Pour protéger votre vie privée, au lieu des notifications push, l\'application possède un <b><xliff:g id="appName">SimpleX</xliff:g> service de fond</b> - il utilise quelques pour cent de la batterie par jour.</string>
<string name="hide_notification">Cacher</string>
<string name="settings_notification_preview_mode_title">Montrer l\'aperçu</string>
<string name="notification_preview_mode_contact">Nom du contact</string>
<string name="notification_preview_somebody">Contact masqué:</string>
<string name="notification_preview_new_message">nouveau message</string>
<string name="notification_new_contact_request">Nouvelle demande de contact</string>
<string name="notification_contact_connected">Connecté</string>
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
<string name="la_notice_turn_on">Activer</string>
<string name="auth_simplex_lock_turned_on">SimpleX Lock activé</string>
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Il vous sera demandé de vous authentifier lorsque vous démarrez ou reprenez l\'application après 30 secondes en arrière-plan.</string>
<string name="auth_unlock">Déverrouiller</string>
<string name="auth_enable_simplex_lock">Activer SimpleX Lock</string>
<string name="auth_disable_simplex_lock">Désactiver SimpleX Lock</string>
<string name="auth_unavailable">Authentification indisponible</string>
<string name="auth_device_authentication_is_disabled_turning_off">L\'authentification de l\'appareil est désactivée. Désactivation de SimpleX Lock.</string>
<string name="auth_open_chat_console">Ouvrir la console du chat</string>
<string name="message_delivery_error_title">Erreur de distribution du message</string>
<string name="message_delivery_error_desc">Il est fort probable que ce contact ait supprimé la connexion avec vous.</string>
<string name="reply_verb">Répondre</string>
<string name="share_verb">Partager</string>
<string name="copy_verb">Copier</string>
<string name="delete_verb">Supprimer</string>
<string name="save_verb">Sauvegarder</string>
<string name="edit_verb">Modifier</string>
<string name="reveal_verb">Révéler</string>
<string name="hide_verb">Cacher</string>
<string name="allow_verb">Autoriser</string>
<string name="delete_message__question">Supprimer le message\?</string>
<string name="for_me_only">Supprimer pour moi</string>
<string name="your_chats">Vos chats</string>
<string name="notification_preview_mode_message">Texte du message</string>
<string name="notification_preview_mode_hidden">Caché</string>
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Pour protéger vos informations, activez la fonction SimpleX Lock.
\nVous serez invité à confirmer l\'authentification avant que cette fonction ne soit activée.</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">L\'authentification de l\'appareil n\'est pas activée. Vous pouvez activer SimpleX Lock via Paramètres, une fois que vous avez activé l\'authentification de l\'appareil.</string>
<string name="database_initialization_error_desc">La base de données ne fonctionne pas correctement. Appuyez ici pour en savoir plus.</string>
<string name="ntf_channel_calls">Appels SimpleX Chat</string>
<string name="ntf_channel_messages">Messages SimpleX Chat</string>
<string name="settings_notifications_mode_title">Service de notification</string>
<string name="notifications_mode_periodic">Lancer périodiquement</string>
<string name="notifications_mode_off">Exécuter lorsque lapp est ouverte</string>
<string name="notifications_mode_service">Toujours activé</string>
<string name="failed_to_parse_chat_title">Échec du chargement du chat</string>
<string name="failed_to_parse_chats_title">Échec du chargement des chats</string>
<string name="contact_developers">Veuillez mettre à jour lapp et contacter les développeurs.</string>
<string name="simplex_service_notification_text">Récupération des messages…</string>
<string name="settings_notification_preview_title">Aperçu de notification</string>
<string name="database_initialization_error_title">Échec dinitialisation de la base de données</string>
<string name="enter_passphrase_notification_desc">Pour recevoir des notifications, veuillez entrer la phrase secrète de la base de données</string>
<string name="simplex_service_notification_title">service <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="this_text_is_available_in_settings">Ce texte est disponible dans les paramètres</string>
<string name="group_preview_join_as">rejoindre en tant que %s</string>
<string name="group_preview_you_are_invited">vous êtes invité·e au groupe</string>
<string name="chat_with_developers">Discuter avec les développeurs</string>
<string name="tap_to_start_new_chat">Appuyez ici pour démarrer une nouvelle discussion</string>
<string name="you_have_no_chats">Vous n\'avez aucune discussion</string>
<string name="images_limit_title">Trop dimages !</string>
<string name="share_file">Partager le fichier…</string>
<string name="attach">Attacher</string>
<string name="icon_descr_cancel_image_preview">Annuler laperçu dimage</string>
<string name="icon_descr_cancel_file_preview">Annuler laperçu du fichier</string>
<string name="icon_descr_sent_msg_status_send_failed">échec denvoi</string>
<string name="icon_descr_received_msg_status_unread">non lu</string>
<string name="welcome">Bienvenue !</string>
<string name="contact_connection_pending">connexion…</string>
<string name="group_connection_pending">connexion…</string>
<string name="share_message">Partager le message…</string>
<string name="share_image">Partager limage…</string>
<string name="images_limit_desc">Envoi de 10 images en même temps maximum</string>
<string name="personal_welcome">Bienvenue <xliff:g>%1$s</xliff:g> !</string>
<string name="notifications_mode_periodic_desc">Vérifie les nouveaux messages toutes les 10 minutes pendant 1 minute au maximum.</string>
<string name="notification_preview_mode_contact_desc">Afficher uniquement le contact</string>
<string name="for_everybody">Pour tous</string>
<string name="icon_descr_sent_msg_status_sent">envoyé</string>
<string name="icon_descr_sent_msg_status_unauthorized_send">envoi non autorisé</string>
<string name="icon_descr_context">Icône contextuelle</string>
<string name="image_descr">Image</string>
<string name="image_decoding_exception_desc">L\'image ne peut pas être décodée. Veuillez essayer une autre image ou contacter les développeurs.</string>
<string name="icon_descr_waiting_for_image">En attente de l\'image</string>
<string name="icon_descr_asked_to_receive">Demandé à recevoir l\'image</string>
<string name="icon_descr_image_snd_complete">Image envoyée</string>
<string name="waiting_for_image">En attente de l\'image</string>
<string name="image_saved">Image enregistrée dans la phototèque</string>
<string name="icon_descr_file">Fichier</string>
<string name="large_file">Fichier trop lourd !</string>
<string name="file_saved">Fichier sauvegardé</string>
<string name="file_not_found">Fichier introuvable</string>
<string name="error_saving_file">Erreur lors de la sauvegarde du fichier</string>
<string name="delete_contact_question">Supprimer le contact \?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Le contact et tous les messages seront supprimés - impossible de revenir en arrière !</string>
<string name="button_delete_contact">Supprimer le contact</string>
<string name="icon_descr_server_status_connected">Connecté</string>
<string name="icon_descr_send_message">Envoyer un message</string>
<string name="switch_receiving_address_question">Changement d\'adresse de réception \?</string>
<string name="icon_descr_record_voice_message">Enregistrer un message vocal</string>
<string name="allow_voice_messages_question">Autoriser les messages vocaux \?</string>
<string name="you_need_to_allow_to_send_voice">Vous devez autoriser votre contact à envoyer des messages vocaux pour pouvoir en envoyer.</string>
<string name="voice_messages_prohibited">Messages vocaux interdits !</string>
<string name="ask_your_contact_to_enable_voice">Veuillez demander à votre contact de permettre l\'envoi de messages vocaux.</string>
<string name="cancel_verb">Annuler</string>
<string name="only_group_owners_can_enable_voice">Seuls les propriétaires de groupes peuvent activer les messages vocaux.</string>
<string name="back">Retour</string>
<string name="no_details">aucun détail</string>
<string name="add_contact">Lien d\'invitation unique</string>
<string name="copied">Copié dans le presse-papiers</string>
<string name="share_one_time_link">Créer un lien d\'invitation unique</string>
<string name="add_contact_or_create_group">Commencer une nouvelle discussion</string>
<string name="connect_via_link_or_qr">Se connecter via un lien / code QR</string>
<string name="scan_QR_code">Scanner un code QR</string>
<string name="to_share_with_your_contact">(à partager avec votre contact)</string>
<string name="create_group">Créer un groupe secret</string>
<string name="from_gallery_button">Depuis la Phototèque</string>
<string name="choose_file">Choisir le fichier</string>
<string name="to_start_a_new_chat_help_header">Pour démarrer une nouvelle discussion</string>
<string name="chat_help_tap_button">Appuyez sur le bouton</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Scanner un code QR</b> : pour vous connecter à votre contact qui vous montre un code QR.</string>
<string name="to_connect_via_link_title">Pour se connecter via un lien</string>
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Si vous avez reçu un lien d\'invitation <xliff:g id="appName">SimpleX Chat</xliff:g>, vous pouvez l\'ouvrir dans votre navigateur :</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 bureau : scanner le code QR affiché depuis l\'app, via <b>Scanner le code QR</b>.</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 mobile : appuyez sur <b>Ouvrir dans l\'application</b>, puis appuyez sur <b>se connecter</b> dans l\'app.</string>
<string name="accept_contact_incognito_button">Accepter en incognito</string>
<string name="reject_contact_button">Rejeter</string>
<string name="clear_chat_question">Effacer la conversation \?</string>
<string name="clear_chat_warning">Tous les messages seront supprimés - impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous.</string>
<string name="clear_chat_menu_action">Effacer</string>
<string name="delete_contact_menu_action">Supprimer</string>
<string name="delete_group_menu_action">Supprimer</string>
<string name="mark_read">Marquer comme lu</string>
<string name="mark_unread">Marquer non lu</string>
<string name="set_contact_name">Définir le nom du contact</string>
<string name="you_invited_your_contact">Vous avez invité votre contact</string>
<string name="you_accepted_connection">Vous avez accepté la connexion</string>
<string name="delete_pending_connection__question">Supprimer la connexion en attente \?</string>
<string name="connection_you_accepted_will_be_cancelled">La connexion que vous avez acceptée sera annulée !</string>
<string name="alert_title_contact_connection_pending">Le contact n\'est pas encore connecté !</string>
<string name="icon_descr_close_button">Bouton fermer</string>
<string name="image_descr_profile_image">image de profil</string>
<string name="image_descr_link_preview">image d\'aperçu du lien</string>
<string name="icon_descr_cancel_link_preview">annuler l\'aperçu du lien</string>
<string name="icon_descr_settings">Paramètres</string>
<string name="icon_descr_address">Adresse <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_help">aide</string>
<string name="icon_descr_simplex_team">Équipe <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_more_button">Plus</string>
<string name="show_QR_code">Afficher le code QR</string>
<string name="invalid_QR_code">Code QR invalide</string>
<string name="this_QR_code_is_not_a_link">Ce code QR n\'est pas un lien !</string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Vous serez connecté·e lorsque votre demande de connexion sera acceptée, veuillez attendre ou vérifier plus tard !</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">Vous serez connecté·e lorsque l\'appareil de votre contact sera en ligne, veuillez attendre ou vérifier plus tard !</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Votre contact peut scanner le code QR depuis l\'app.</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Votre profil de chat sera envoyé
\nà votre contact</string>
<string name="share_invitation_link">Partager le lien d\'invitation</string>
<string name="your_profile_will_be_sent">Votre profil de chat sera envoyé à votre contact</string>
<string name="paste_button">Coller</string>
<string name="this_string_is_not_a_connection_link">Cette chaîne n\'est pas un lien de connexion !</string>
<string name="you_can_also_connect_by_clicking_the_link">Vous pouvez aussi vous connecter en cliquant sur le lien. Si il s\'ouvre dans le navigateur, cliquez sur <b>Ouvrir dans l\'app mobile</b>.</string>
<string name="create_one_time_link">Créer un lien d\'invitation unique</string>
<string name="text_field_set_contact_placeholder">Définir le nom du contact…</string>
<string name="icon_descr_server_status_disconnected">Déconnecté</string>
<string name="icon_descr_server_status_error">Erreur</string>
<string name="icon_descr_server_status_pending">En attente</string>
<string name="accept_connection_request__question">Accepter la demande de connexion \?</string>
<string name="clear_verb">Effacer</string>
<string name="clear_chat_button">Effacer la conversation</string>
<string name="paste_connection_link_below_to_connect">Collez le lien que vous avez reçu dans le cadre ci-dessous pour vous connecter avec votre contact.</string>
<string name="connect_via_link">Se connecter via un lien</string>
<string name="clear_verification">Retirer la vérification</string>
<string name="one_time_link">Lien d\'invitation unique</string>
<string name="your_contact_address">Votre adresse de contact</string>
<string name="scan_code">Scanner le code</string>
<string name="incorrect_code">Code de sécurité incorrect !</string>
<string name="security_code">Code de sécurité</string>
<string name="mark_code_verified">Marquer comme vérifié</string>
<string name="view_security_code">Afficher le code de sécurité</string>
<string name="verify_security_code">Vérifier le code de sécurité</string>
<string name="confirm_verb">Confirmer</string>
<string name="reset_verb">Réinitialisation</string>
<string name="ok">OK</string>
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(scanner ou coller depuis le presse-papiers)</string>
<string name="only_stored_on_members_devices">(uniquement stocké par les membres du groupe)</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Votre contact a besoin d\'être en ligne pour completer la connexion.
\nVous pouvez annuler la connexion et supprimer le contact (et réessayer plus tard avec un autre lien).</string>
<string name="contact_wants_to_connect_with_you">veut établir une connexion !</string>
<string name="icon_descr_profile_image_placeholder">image de profil (placeholder)</string>
<string name="image_descr_qr_code">Code QR</string>
<string name="image_descr_simplex_logo">Logo <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_email">E-mail</string>
<string name="connect_button">Se connecter</string>
<string name="notifications_mode_off_desc">L\'application peut recevoir des notifications uniquement lorsqu\'elle est en cours d\'exécution, aucun service d\'arrière-plan ne sera lancé.</string>
<string name="notifications_mode_service_desc">Le service d\'arrière-plan fonctionne en permanence. Les notifications s\'affichent dès que les messages sont disponibles.</string>
<string name="notification_preview_mode_message_desc">Afficher le contact et le message</string>
<string name="notification_display_mode_hidden_desc">Masquer le contact et le message</string>
<string name="auth_log_in_using_credential">Connectez-vous en utilisant votre identifiant</string>
<string name="auth_confirm_credential">Confirmez vos identifiants</string>
<string name="auth_stop_chat">Arrêter le chat</string>
<string name="delete_message_cannot_be_undone_warning">Le message sera supprimé - impossible de revenir en arrière !</string>
<string name="delete_message_mark_deleted_warning">Le message sera marqué comme supprimé. Le·s destinataire·s pourrai·ent révéler ce message.</string>
<string name="icon_descr_edited">modifié</string>
<string name="image_will_be_received_when_contact_is_online">L\'image sera reçue quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard!</string>
<string name="image_decoding_exception_title">Erreur de décodage</string>
<string name="contact_sent_large_file">Votre contact a envoyé un fichier dont la taille est supérieure à la taille maximale actuellement prise en charge (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
<string name="waiting_for_file">En attente du fichier</string>
<string name="voice_message">Message vocal</string>
<string name="toast_permission_denied">Autorisation refusée !</string>
<string name="use_camera_button">Utiliser l\'Appareil photo</string>
<string name="thank_you_for_installing_simplex">Merci d\'avoir installé <xliff:g id="appNameFull">SimpleX Chat</xliff:g> !</string>
<string name="you_can_connect_to_simplex_chat_founder">Vous pouvez <font color="#0088ff">vous connecter aux développeurs de <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour leur poser des questions et recevoir des réponses :</font>.</string>
<string name="above_then_preposition_continuation">ci-dessus, puis :</string>
<string name="add_new_contact_to_create_one_time_QR_code"><b>Ajouter un nouveau contact</b> : afin de créer un code QR à usage unique pour votre contact.</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Si vous choisissez de la rejeter, l\'expéditeur·rice NE sera PAS notifié·e.</string>
<string name="accept_contact_button">Accepter</string>
<string name="mute_chat">Muet</string>
<string name="unmute_chat">Démute</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Le contact avec lequel vous avez partagé ce lien NE pourra PAS se connecter !</string>
<string name="invalid_contact_link">Lien invalide !</string>
<string name="this_link_is_not_a_valid_connection_link">Ce lien n\'est pas un lien de connexion valide !</string>
<string name="connection_request_sent">Demande de connexion envoyée !</string>
<string name="file_will_be_received_when_contact_is_online">Le fichier sera reçu quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard!</string>
<string name="voice_message_send_text">Message vocal…</string>
<string name="maximum_supported_file_size">La taille maximale supportés des fichiers actuellement est de <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
<string name="voice_message_with_duration">Message vocal (<xliff:g id="duration">%1$s</xliff:g>)</string>
<string name="notifications">Notifications</string>
<string name="switch_receiving_address_desc">Cette fonctionnalité est expérimentale ! Elle ne fonctionnera que si l\'autre client a la version 4.2 installée. Vous devriez voir le message dans la conversation une fois le changement d\'adresse effectué. Vérifiez que vous pouvez toujours recevoir des messages de ce contact (ou membre du groupe).</string>
<string name="you_will_be_connected_when_group_host_device_is_online">Vous serez connecté·e au groupe lorsque l\'appareil de l\'hôte sera en ligne, veuillez attendre ou vérifier plus tard !</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Si vous ne pouvez pas vous rencontrer en personne, <b>montrez le code QR lors d\'un appel vidéo</b>, ou partagez le lien.</string>
<string name="scan_code_from_contacts_app">Scannez le code de sécurité depuis l\'application de votre contact.</string>
<string name="to_verify_compare">Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils.</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Si vous ne pouvez pas vous rencontrer en personne, vous pouvez <b>scanner un code QR lors d\'un appel vidéo</b>, ou votre contact peut partager un lien d\'invitation.</string>
<string name="smp_servers_add">Ajouter un serveur…</string>
<string name="markdown_in_messages">Markdown dans les messages</string>
<string name="smp_servers_preset_add">Ajouter des serveurs prédéfinis</string>
<string name="use_simplex_chat_servers__question">Utiliser les serveurs <xliff:g id="appNameFull">SimpleX Chat</xliff:g> \?</string>
<string name="smp_servers_delete_server">Supprimer le serveur</string>
<string name="ensure_ICE_server_address_are_correct_format_and_unique">Assurez-vous que les adresses des serveurs WebRTC ICE sont au bon format et ne sont pas dupliquées, un par ligne.</string>
<string name="network_enable_socks_info">Accéder aux serveurs via un proxy SOCKS sur le port 9050 \? Le proxy doit être démarré avant d\'activer cette option.</string>
<string name="network_use_onion_hosts">Utiliser les hôtes .onions</string>
<string name="network_use_onion_hosts_prefer_desc_in_alert">Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles.</string>
<string name="network_use_onion_hosts_required_desc_in_alert">Les hôtes .onion seront nécessaires pour la connexion.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Vous contrôlez par quel·s serveur·s vous pouvez <b>transmettre</b> ainsi que par quel·s serveur·s vous pouvez <b>recevoir</b> les messages de vos contacts.</string>
<string name="your_settings">Vos paramètres</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Console du chat</string>
<string name="smp_servers">Serveurs SMP</string>
<string name="smp_servers_test_servers">Tester les serveurs</string>
<string name="smp_servers_save">Sauvegarder les serveurs</string>
<string name="smp_servers_scan_qr">Scanner un code QR de serveur</string>
<string name="smp_servers_use_server">Utiliser ce serveur</string>
<string name="smp_servers_use_server_for_new_conn">Utiliser pour les nouvelles connexions</string>
<string name="smp_servers_add_to_another_device">Ajouter à un autre appareil</string>
<string name="install_simplex_chat_for_terminal">Installer <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour terminal</string>
<string name="star_on_github">Star sur GitHub</string>
<string name="contribute">Contribuer</string>
<string name="rate_the_app">Évaluer l\'app</string>
<string name="your_SMP_servers">Vos serveurs SMP</string>
<string name="how_to_use_your_servers">Comment utiliser vos serveurs</string>
<string name="saved_ICE_servers_will_be_removed">Les serveurs WebRTC ICE sauvegardés seront supprimés.</string>
<string name="your_ICE_servers">Vos serveurs ICE</string>
<string name="configure_ICE_servers">Configurer les serveurs ICE</string>
<string name="network_settings">Paramètres réseau avancés</string>
<string name="network_settings_title">Paramètres réseau</string>
<string name="network_socks_toggle">Utiliser un proxy SOCKS (port 9050)</string>
<string name="network_enable_socks">Utiliser un proxy SOCKS \?</string>
<string name="network_disable_socks">Utiliser une connexion Internet directe \?</string>
<string name="network_disable_socks_info">Si vous confirmez, les serveurs de messagerie seront en mesure de voir votre adresse IP, votre fournisseur ainsi que les serveurs auxquels vous vous connectez.</string>
<string name="network_use_onion_hosts_no">Non</string>
<string name="network_use_onion_hosts_required">Requis</string>
<string name="network_use_onion_hosts_prefer_desc">Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles.</string>
<string name="appearance_settings">Apparence</string>
<string name="create_address">Créer une adresse</string>
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Vous pouvez partager votre adresse sous forme de lien ou de code QR - n\'importe qui pourra se connecter à vous. Vous ne perdrez pas vos contacts si vous la supprimez par la suite.</string>
<string name="your_current_profile">Votre profil de chat</string>
<string name="edit_image">Modifier l\'image</string>
<string name="save_and_notify_contacts">Sauvegarder et notifier les contacts</string>
<string name="save_and_notify_group_members">Sauvegarder et en informer les membres du groupe</string>
<string name="your_profile_is_stored_on_your_device">Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil.</string>
<string name="profile_is_only_shared_with_your_contacts">Le profil n\'est partagé qu\'avec vos contacts.</string>
<string name="display_name_cannot_contain_whitespace">Le nom d\'affichage ne peut pas contenir d\'espace.</string>
<string name="full_name_optional__prompt">Nom complet (optionnel)</string>
<string name="create_profile_button">Créer</string>
<string name="about_simplex">À propos de SimpleX</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Vous pouvez utiliser le format markdown pour mettre en forme les messages :</string>
<string name="bold">gras</string>
<string name="italic">italique</string>
<string name="strikethrough">barré</string>
<string name="callstatus_accepted">appel accepté</string>
<string name="callstatus_connecting">connexion à l\'appel…</string>
<string name="callstatus_error">erreur d\'appel</string>
<string name="callstate_received_answer">réponse reçu…</string>
<string name="callstate_received_confirmation">confimation reçu…</string>
<string name="callstate_connecting">connexion…</string>
<string name="opensource_protocol_and_code_anybody_can_run_servers">Protocole et code open-source n\'importe qui peut heberger un serveur.</string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Pour protéger votre vie privée, au lieu d\'IDs utilisés par toutes les autres plateformes, <xliff:g id="appName">SimpleX</xliff:g> possède des IDs pour les queues de messages, distinctes pour chacun de vos contacts.</string>
<string name="read_more_in_github">Plus d\'informations sur notre GitHub.</string>
<string name="paste_the_link_you_received">Coller le lien reçu</string>
<string name="use_chat">Utiliser le chat</string>
<string name="onboarding_notifications_mode_title">Notifications privées</string>
<string name="onboarding_notifications_mode_subtitle">Peut être modifié ultérieurement via les paramètres.</string>
<string name="onboarding_notifications_mode_off">Quand l\'application fonctionne</string>
<string name="onboarding_notifications_mode_periodic">Périodique</string>
<string name="onboarding_notifications_mode_service">Instantanée</string>
<string name="onboarding_notifications_mode_off_desc"><b>Économie de batterie</b>. Vous recevrez des notifications uniquement lorsque l\'application est en cours d\'exécution, le service de fond ne sera PAS utilisé.</string>
<string name="about_simplex_chat">À propos de <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="how_to_use_simplex_chat">Comment l\'utiliser</string>
<string name="markdown_help">Aide Markdown</string>
<string name="save_servers_button">Sauvegarder</string>
<string name="network_and_servers">Réseau et serveurs</string>
<string name="save_and_notify_contact">Sauvegarder et en informer les contacts</string>
<string name="exit_without_saving">Quitter sans sauvegarder</string>
<string name="callstatus_rejected">appel rejeté</string>
<string name="callstatus_in_progress">appel en cours</string>
<string name="callstatus_ended">appel terminé <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstate_starting">lancement…</string>
<string name="is_verified">%s est vérifié·e</string>
<string name="is_not_verified">%s n\'est pas vérifié·e</string>
<string name="your_simplex_contact_address">Votre adresse de contact <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="database_passphrase_and_export">Phrase secrète et exportation de la base de données</string>
<string name="chat_with_the_founder">Envoyez vos questions et idées</string>
<string name="send_us_an_email">Envoyez nous un e-mail</string>
<string name="smp_servers_preset_address">Adresse du serveur prédéfinie</string>
<string name="smp_servers_test_server">Tester le serveur</string>
<string name="smp_servers_test_failed">Échec du test du serveur !</string>
<string name="smp_servers_test_some_failed">Certains serveurs n\'ont pas réussi le test :</string>
<string name="smp_servers_enter_manually">Entrer un serveur manuellement</string>
<string name="smp_servers_preset_server">Serveur prédéfini</string>
<string name="smp_servers_your_server">Votre serveur</string>
<string name="smp_servers_your_server_address">Votre adresse de serveur</string>
<string name="smp_servers_invalid_address">Adresse de serveur invalide !</string>
<string name="smp_servers_check_address">Vérifiez l\'adresse du serveur et réessayez.</string>
<string name="using_simplex_chat_servers">Utilise les serveurs <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<string name="how_to">Comment faire</string>
<string name="enter_one_ICE_server_per_line">Serveurs ICE (un par ligne)</string>
<string name="error_saving_ICE_servers">Erreur lors de la sauvegarde des serveurs ICE</string>
<string name="update_onion_hosts_settings_question">Mettre à jour le paramètre des hôtes .onion \?</string>
<string name="network_use_onion_hosts_prefer">Quand disponible</string>
<string name="network_use_onion_hosts_no_desc">Les hôtes .onion ne seront pas utilisés.</string>
<string name="network_use_onion_hosts_required_desc">Les hôtes .onion seront nécessaires pour la connexion.</string>
<string name="network_use_onion_hosts_no_desc_in_alert">Les hôtes .onion ne seront pas utilisés.</string>
<string name="delete_address__question">Supprimer l\'adresse \?</string>
<string name="all_your_contacts_will_remain_connected">Tous vos contacts resteront connectés.</string>
<string name="share_link">Partager le lien</string>
<string name="delete_address">Supprimer l\'adresse</string>
<string name="contact_requests">Demandes de contact</string>
<string name="accept_requests">Accepter les demandes</string>
<string name="accept_automatically">Automatiquement</string>
<string name="section_title_welcome_message">MESSAGE DE BIENVENUE</string>
<string name="display_name__field">Nom affiché :</string>
<string name="full_name__field">Nom complet :</string>
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Votre profil est stocké sur votre appareil et partagé uniquement avec vos contacts.
\n
\nLes serveurs <xliff:g id="appName">SimpleX</xliff:g> ne peuvent pas voir votre profil.</string>
<string name="delete_image">Supprimer l\'image</string>
<string name="save_preferences_question">Sauvegarder les préférences \?</string>
<string name="you_control_your_chat">Vous maîtrisez vos discussions !</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">La plateforme de messagerie et d\'applications qui protège votre vie privée et votre sécurité.</string>
<string name="we_do_not_store_contacts_or_messages_on_servers">Nous ne stockons aucun de vos contacts ou messages (une fois délivrés) sur les serveurs.</string>
<string name="create_profile">Créer le profil</string>
<string name="display_name">Nom affiché</string>
<string name="how_to_use_markdown">Comment utiliser markdown</string>
<string name="a_plus_b">a + b</string>
<string name="colored">coloré</string>
<string name="secret">secret</string>
<string name="callstatus_calling">appel…</string>
<string name="callstatus_missed">appel manqué</string>
<string name="callstate_waiting_for_answer">en attente de réponse…</string>
<string name="callstate_waiting_for_confirmation">en attente de confirmation…</string>
<string name="callstate_connected">connecté</string>
<string name="callstate_ended">terminé</string>
<string name="next_generation_of_private_messaging">La nouvelle génération de messagerie privée</string>
<string name="privacy_redefined">La vie privée redéfinie</string>
<string name="first_platform_without_user_ids">La 1ère plateforme sans aucun identifiant d\'utilisateur privée par design.</string>
<string name="immune_to_spam_and_abuse">Protégé du spam et des abus</string>
<string name="people_can_connect_only_via_links_you_share">On ne peut se connecter à vous quavec les liens que vous partagez.</string>
<string name="decentralized">Décentralisé</string>
<string name="create_your_profile">Créez votre profil</string>
<string name="make_private_connection">Établir une connexion privée</string>
<string name="how_it_works">Comment ça fonctionne</string>
<string name="how_simplex_works">Comment <xliff:g id="appName">SimpleX</xliff:g> fonctionne</string>
<string name="many_people_asked_how_can_it_deliver">Beaucoup se demandent : <i>si <xliff:g id="appName">SimpleX</xliff:g> n\'a pas d\'identifiant d\'utilisateur, comment peut-il transmettre des messages \?</i></string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages envoyés avec un <b>chiffrement de bout en bout à deux couches</b>.</string>
<string name="read_more_in_github_with_link">Pour en savoir plus, consultez notre <font color="#0088ff">GitHub repository</font>.</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Batterie peu utilisée</b>. Le service de fond vérifie les nouveaux messages toutes les 10 minutes. Vous risquez de manquer des appels et des messages urgents.</string>
<string name="onboarding_notifications_mode_service_desc"><b>Batterie plus utilisée </b> ! Le service de fond est toujours en cours d\'exécution - les notifications s\'afficheront dès que les messages seront disponibles.</string>
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> message⸱s manqué⸱s</string>
<string name="integrity_msg_bad_id">ID de message incorrecte</string>
<string name="settings_section_title_settings">PARAMÈTRES</string>
<string name="alert_text_skipped_messages_it_can_happen_when">C\'est possible quand :
\n1. Les messages expirent du serveur (après 30 jours si ils ne sont pas reçu).
\n2. Le serveur que vous utilisez pour recevoir les messages de ce contact a été mise à jour ou redémarré.
\n3. La connection est compromise.
\nVeuillez vous connecter aux développeurs via les Paramètres pour recevoir les mises à jour concernant les serveurs.
\nNous allons ajouter une redondance des serveurs pour éviter la perte de messages.</string>
<string name="icon_descr_call_rejected">Appel rejeté</string>
<string name="rcv_group_event_member_deleted">a retiré <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_member_deleted">vous avez retiré <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_invited_via_your_group_link">invité par votre lien de groupe</string>
<string name="snd_conn_event_switch_queue_phase_completed">vous avez changé d\'adresse</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Arrêtez le chat pour exporter, importer ou supprimer la base de données du chat. Vous ne pourrez pas recevoir et envoyer de messages pendant que le chat est arrêté.</string>
<string name="enable_automatic_deletion_message">Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes.</string>
<string name="encrypted_with_random_passphrase">La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire, que vous pouvez modifier.</string>
<string name="restore_database">Restaurer la sauvegarde de la base de données</string>
<string name="restore_passphrase_not_found_desc">La phrase secrète n\'a pas été trouvée dans le Keystore, veuillez la saisir manuellement. Cela a pu se produire si vous avez restauré les données de l\'app à l\'aide d\'un outil de sauvegarde. Si ce n\'est pas le cas, veuillez contacter les développeurs.</string>
<string name="restore_database_alert_desc">Veuillez entrer le mot de passe précédent après avoir restauré la sauvegarde de la base de données. Cette action ne peut pas être annulée.</string>
<string name="database_restore_error">Erreur de restauration de la base de données</string>
<string name="archive_created_on_ts">Créé le <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="encrypted_video_call">appel vidéo (chiffrement de bout en bout)</string>
<string name="audio_call_no_encryption">appel audio (sans chiffrement)</string>
<string name="encrypted_audio_call">appel audio (chiffrement de bout en bout)</string>
<string name="accept">Accepter</string>
<string name="reject">Rejeter</string>
<string name="icon_descr_video_call">appel vidéo</string>
<string name="icon_descr_audio_call">appel audio</string>
<string name="accept_call_on_lock_screen">Accepter</string>
<string name="allow_accepting_calls_from_lock_screen">Activer les appels depuis l\'écran verrouillé via les Paramètres.</string>
<string name="open_verb">Ouvrir</string>
<string name="call_connection_via_relay">via relais</string>
<string name="icon_descr_hang_up">Raccrocher</string>
<string name="icon_descr_video_on">Vidéo ON</string>
<string name="icon_descr_video_off">Vidéo OFF</string>
<string name="icon_descr_call_progress">Appel en cours</string>
<string name="icon_descr_call_ended">Appel terminé</string>
<string name="your_privacy">Votre vie privée</string>
<string name="settings_section_title_device">APPAREIL</string>
<string name="settings_section_title_chats">CHATS</string>
<string name="settings_developer_tools">Outils du développeur</string>
<string name="settings_section_title_icon">ICONE DE L\'APP</string>
<string name="your_chat_database">Votre base de données de chat</string>
<string name="run_chat_section">LANCER LE CHAT</string>
<string name="stop_chat_question">Arrêter le chat \?</string>
<string name="restart_the_app_to_use_imported_chat_database">Redémarrez l\'application pour utiliser la base de données de chat importée.</string>
<string name="chat_item_ttl_day">1 jour</string>
<string name="delete_messages">Supprimer les messages</string>
<string name="save_passphrase_in_keychain">Sauvegarder la phrase secrète dans le keystore</string>
<string name="database_encrypted">Base de données chiffrée !</string>
<string name="error_encrypting_database">Erreur lors du chiffrement de la base de données</string>
<string name="update_database">Mise à jour</string>
<string name="encrypt_database">Chiffrer</string>
<string name="enter_correct_current_passphrase">Veuillez entrer la phrase secrète actuelle correcte.</string>
<string name="database_is_not_encrypted">Votre base de données de chat n\'est pas chiffrée - définissez une phrase secrète pour la protéger.</string>
<string name="impossible_to_recover_passphrase"><b>Veuillez noter</b> : vous NE pourrez PAS récupérer ou modifier la phrase secrète si vous la perdez.</string>
<string name="keychain_allows_to_receive_ntfs">Le Keystore d\'Android sera utilisé pour stocker en toute sécurité la phrase secrète après sa modification ou redémarrage de l\'app - cela permettra de recevoir les notifications.</string>
<string name="store_passphrase_securely_without_recover">Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS accéder au chat si vous la perdez.</string>
<string name="passphrase_is_different">La phrase secrète de la base de données est différente de celle enregistrée dans le Keystore.</string>
<string name="unknown_error">Erreur inconnue</string>
<string name="enter_correct_passphrase">Entrez la phrase secrète correcte.</string>
<string name="alert_message_no_group">Ce groupe n\'existe plus.</string>
<string name="you_joined_this_group">Vous avez rejoint ce groupe</string>
<string name="you_rejected_group_invitation">Vous avez rejeté l\'invitation du groupe</string>
<string name="snd_conn_event_switch_queue_phase_completed_for_member">vous avez changé d\'adresse pour %s</string>
<string name="rcv_conn_event_switch_queue_phase_changing">changement d\'adresse…</string>
<string name="incoming_video_call">Appel vidéo entrant</string>
<string name="video_call_no_encryption">appel vidéo (sans chiffrement)</string>
<string name="ignore">Ignorer</string>
<string name="call_already_ended">Appel déjà terminé !</string>
<string name="settings_audio_video_calls">Appels audio et vidéo</string>
<string name="status_e2e_encrypted">chiffré de bout en bout</string>
<string name="settings_section_title_develop">DEVELOPPER</string>
<string name="settings_experimental_features">Fonctionnalités expérimentales</string>
<string name="settings_section_title_socks">SOCKS PROXY</string>
<string name="settings_section_title_themes">THEMES</string>
<string name="settings_section_title_messages">MESSAGES</string>
<string name="settings_section_title_calls">APPELS</string>
<string name="import_database">Importer la base de données</string>
<string name="new_database_archive">Nouvelle archive de base de données</string>
<string name="old_database_archive">Archives de l\'ancienne base de données</string>
<string name="delete_database">Supprimer la base de données</string>
<string name="error_starting_chat">Erreur lors du démarrage du chat</string>
<string name="import_database_confirmation">Importer</string>
<string name="delete_chat_profile_action_cannot_be_undone_warning">Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus.</string>
<string name="chat_database_deleted">Base de données du chat supprimée</string>
<string name="restart_the_app_to_create_a_new_chat_profile">Redémarrez l\'application pour créer un nouveau profil de chat.</string>
<string name="you_must_use_the_most_recent_version_of_database">Vous devez utiliser la version la plus récente de votre base de données de chat sur un seul appareil UNIQUEMENT, sinon vous risquez de ne plus recevoir les messages de certains contacts.</string>
<string name="total_files_count_and_size">%d fichier·s avec une taille totale de %s</string>
<string name="chat_item_ttl_none">jamais</string>
<string name="chat_item_ttl_week">1 semaine</string>
<string name="database_will_be_encrypted_and_passphrase_stored">La base de données sera chiffrée et la phrase secrète sera stockée dans le Keystore.</string>
<string name="database_encryption_will_be_updated">La phrase secrète de la base de données sera mise à jour et stockée dans le Keystore.</string>
<string name="database_passphrase_will_be_updated">La phrase secrète de la base de données sera mise à jour.</string>
<string name="store_passphrase_securely">Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS la changer si vous la perdez.</string>
<string name="wrong_passphrase">Mauvaise phrase secrète pour la base de données</string>
<string name="encrypted_database">Base de données chiffrée</string>
<string name="database_error">Erreur de base de données</string>
<string name="error_with_info">Erreur : %s</string>
<string name="cannot_access_keychain">Impossible d\'accéder au Keystore pour enregistrer le mot de passe de la base de données</string>
<string name="unknown_database_error_with_info">Erreur de base de données inconnue : %s</string>
<string name="wrong_passphrase_title">Mauvaise phrase secrète !</string>
<string name="leave_group_question">Quitter le groupe \?</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Vous ne recevrez plus de messages de ce groupe. L\'historique du chat sera conservé.</string>
<string name="icon_descr_add_members">Inviter des membres</string>
<string name="icon_descr_group_inactive">Groupe inactif</string>
<string name="alert_title_group_invitation_expired">Invitation expirée !</string>
<string name="alert_message_group_invitation_expired">L\'invitation du groupe n\'est plus valide, elle a été supprimé par l\'expéditeur.</string>
<string name="alert_title_no_group">Groupe introuvable !</string>
<string name="alert_title_cant_invite_contacts">Impossible d\'inviter les contacts !</string>
<string name="alert_title_cant_invite_contacts_descr">Vous utilisez un profil incognito pour ce groupe - pour éviter de partager votre profil principal ; inviter des contacts n\'est pas possible</string>
<string name="you_sent_group_invitation">Vous avez envoyé une invitation de groupe</string>
<string name="rcv_group_event_member_left">a quitté</string>
<string name="icon_descr_speaker_on">Haut-parleur ON</string>
<string name="send_link_previews">Envoi d\'aperçus de liens</string>
<string name="error_deleting_database">Erreur lors de la suppression de la base de données du chat</string>
<string name="error_stopping_chat">Erreur lors de l\'arrêt du chat</string>
<string name="error_exporting_chat_database">Erreur lors de l\'exportation de la base de données du chat</string>
<string name="import_database_question">Importer la base de données du chat \?</string>
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Votre base de données de chat actuelle sera SUPPRIMÉE et REMPLACÉE par celle qui a été importée.
\nCette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irrémédiablement perdus.</string>
<string name="enter_passphrase">Entrez la phrase secrète…</string>
<string name="incoming_audio_call">Appel audio entrant</string>
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> veut se connecter à vous via</string>
<string name="your_calls">Vos appels</string>
<string name="connect_calls_via_relay">Se connecter via relais</string>
<string name="call_on_lock_screen">Appels en écran verrouillé :</string>
<string name="show_call_on_lock_screen">Montrer</string>
<string name="no_call_on_lock_screen">Désactiver</string>
<string name="your_ice_servers">Vos serveurs ICE</string>
<string name="webrtc_ice_servers">Serveurs WebRTC ICE</string>
<string name="open_simplex_chat_to_accept_call">Ouvrez <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour décrocher</string>
<string name="status_no_e2e_encryption">sans chiffrement de bout en bout</string>
<string name="status_contact_has_e2e_encryption">Ce contact a le chiffrement de bout en bout</string>
<string name="status_contact_has_no_e2e_encryption">Ce contact n\'a pas le chiffrement de bout en bout</string>
<string name="call_connection_peer_to_peer">pair-à-pair</string>
<string name="icon_descr_audio_off">Audio OFF</string>
<string name="icon_descr_audio_on">Audio ON</string>
<string name="icon_descr_speaker_off">Haut-parleur OFF</string>
<string name="icon_descr_flip_camera">Retourner la caméra</string>
<string name="icon_descr_call_pending_sent">Appel en suspend</string>
<string name="icon_descr_call_missed">Appel manqué</string>
<string name="icon_descr_call_connecting">Appel en connexion</string>
<string name="answer_call">Répondre à l\'appel</string>
<string name="integrity_msg_bad_hash">hash de message incorrect</string>
<string name="integrity_msg_duplicate">message dupliqué</string>
<string name="alert_title_skipped_messages">Messages manqués</string>
<string name="privacy_and_security">Vie privée et sécurité</string>
<string name="protect_app_screen">Protéger l\'écran de l\'app</string>
<string name="auto_accept_images">Images auto-acceptées</string>
<string name="transfer_images_faster">Transfert d\'images plus rapide</string>
<string name="full_backup">Sauvegarde des données de l\'app</string>
<string name="settings_section_title_you">VOUS</string>
<string name="settings_section_title_help">AIDE</string>
<string name="settings_section_title_support">SOUTENEZ SIMPLEX CHAT</string>
<string name="settings_section_title_incognito">Mode Incognito</string>
<string name="chat_is_running">Le chat est en cours d\'exécution</string>
<string name="chat_is_stopped">Le chat est arrêté</string>
<string name="chat_database_section">BASE DE DONNÉES DU CHAT</string>
<string name="database_passphrase">Phrase secrète de la base de données</string>
<string name="export_database">Exporter la base de données</string>
<string name="stop_chat_confirmation">Arrêter</string>
<string name="set_password_to_export">Définir la phrase secrète pour l\'export</string>
<string name="set_password_to_export_desc">La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire. Veuillez la changer avant d\'exporter.</string>
<string name="error_importing_database">Erreur lors de l\'importation de la base de données du chat</string>
<string name="chat_database_imported">Base de données du chat importée</string>
<string name="delete_chat_profile_question">Supprimer le profil du chat \?</string>
<string name="stop_chat_to_enable_database_actions">Arrêter le chat pour agir sur la base de données.</string>
<string name="delete_files_and_media_question">Supprimer les fichiers et médias \?</string>
<string name="delete_files_and_media_desc">Cette action ne peut être annulée - tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées.</string>
<string name="no_received_app_files">Aucun fichier reçu ou envoyé</string>
<string name="chat_item_ttl_month">1 mois</string>
<string name="chat_item_ttl_seconds">%s seconde·s</string>
<string name="delete_messages_after">Supprimer les messages après</string>
<string name="enable_automatic_deletion_question">Activer la suppression automatique des messages \?</string>
<string name="error_changing_message_deletion">Erreur de changement de paramètre</string>
<string name="remove_passphrase_from_keychain">Retirer la phrase secrète du Keystore \?</string>
<string name="notifications_will_be_hidden">Les notifications seront délivrées jusqu\'à ce que l\'application s\'arrête !</string>
<string name="remove_passphrase">Supprimer</string>
<string name="current_passphrase">Phrase secrète actuelle…</string>
<string name="new_passphrase">Nouvelle phrase secrète…</string>
<string name="confirm_new_passphrase">Confirmer la nouvelle phrase secrète…</string>
<string name="update_database_passphrase">Mise à jour de la phrase secrète de la base de données</string>
<string name="keychain_is_storing_securely">Le Keystore d\'Android est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner.</string>
<string name="you_have_to_enter_passphrase_every_time">Vous devez saisir la phrase secrète à chaque fois que l\'application démarre - elle n\'est pas stockée sur l\'appareil.</string>
<string name="encrypt_database_question">Chiffrer la base de données \?</string>
<string name="change_database_passphrase_question">Changer la phrase secrète de la base de données \?</string>
<string name="database_will_be_encrypted">La base de données sera chiffrée.</string>
<string name="keychain_error">Erreur de la keychain</string>
<string name="file_with_path">Fichier : %s</string>
<string name="database_passphrase_is_required">La phrase secrète de la base de données est nécessaire pour ouvrir le chat.</string>
<string name="save_passphrase_and_open_chat">Sauvegarder la phrase secrète et ouvrir le chat</string>
<string name="open_chat">Ouvrir le chat</string>
<string name="database_backup_can_be_restored">La tentative de modification de la phrase secrète de la base de données n\'a pas abouti.</string>
<string name="restore_database_alert_title">Restaurer la sauvegarde de la base de données \?</string>
<string name="restore_database_alert_confirm">Restaurer</string>
<string name="chat_is_stopped_indication">Le chat est arrêté</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Vous pouvez lancer le chat via les Paramètres / la Base de données de l\'app ou en la redémarrant.</string>
<string name="chat_archive_header">Archives du chat</string>
<string name="chat_archive_section">ARCHIVE DU CHAT</string>
<string name="save_archive">Sauvegarder l\'archive</string>
<string name="delete_archive">Supprimer l\'archive</string>
<string name="delete_chat_archive_question">Supprimer l\'archive du chat \?</string>
<string name="group_invitation_item_description">Invitation au groupe <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Rejoindre le groupe \?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Vous êtes invité·e dans un groupe. Rejoignez le pour vous connecter avec ses membres.</string>
<string name="join_group_button">Rejoindre</string>
<string name="join_group_incognito_button">Rejoindre en incognito</string>
<string name="joining_group">Entrain de rejoindre le groupe</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Vous avez rejoint ce groupe. Connexion à l\'invitation d\'un membre du groupe.</string>
<string name="leave_group_button">Quitter</string>
<string name="you_are_invited_to_group">Vous êtes invité·e au groupe</string>
<string name="group_invitation_tap_to_join">Appuyez pour rejoindre</string>
<string name="group_invitation_tap_to_join_incognito">Appuyez pour rejoindre incognito</string>
<string name="group_invitation_expired">Invitation au groupe expirée</string>
<string name="rcv_group_event_member_added">a invité <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_connected">est connecté·e</string>
<string name="rcv_group_event_changed_member_role">a modifié le rôle de %s pour %s</string>
<string name="rcv_group_event_changed_your_role">a modifié votre rôle pour %s</string>
<string name="rcv_group_event_user_deleted">vous a retiré</string>
<string name="rcv_group_event_group_deleted">a supprimé le groupe</string>
<string name="rcv_group_event_updated_group_profile">mise à jour du profil de groupe</string>
<string name="snd_group_event_changed_member_role">vous avez modifié le rôle de %s pour %s</string>
<string name="snd_group_event_changed_role_for_yourself">vous avez modifié votre rôle pour %s</string>
<string name="snd_group_event_user_left">vous avez quitté</string>
<string name="snd_group_event_group_profile_updated">mise à jour du profil de groupe</string>
<string name="rcv_conn_event_switch_queue_phase_completed">adresse modifiée pour vous</string>
<string name="snd_conn_event_switch_queue_phase_changing_for_member">changement d\'adresse pour %s…</string>
<string name="snd_conn_event_switch_queue_phase_changing">changement d\'adresse…</string>
<string name="group_member_role_member">membre</string>
<string name="group_member_role_admin">admin</string>
<string name="group_member_role_owner">propriétaire</string>
<string name="group_member_status_removed">supprimé</string>
<string name="group_member_status_left">a quitté</string>
<string name="group_member_status_group_deleted">groupe supprimé</string>
<string name="group_member_status_invited">invité·e</string>
<string name="group_member_status_introduced">connexion (introduite)</string>
<string name="group_member_status_intro_invitation">connexion (introduite par invitation)</string>
<string name="group_member_status_accepted">connexion (acceptée)</string>
<string name="group_member_status_announced">connexion (annoncée)</string>
<string name="group_member_status_connected">connecté</string>
<string name="group_member_status_complete">complet</string>
<string name="group_member_status_creator">créateur</string>
<string name="group_member_status_connecting">connexion</string>
<string name="no_contacts_to_add">Aucun contact à ajouter</string>
<string name="new_member_role">Nouveau rôle</string>
<string name="delete_group_question">Supprimer le groupe\?</string>
<string name="group_link">Lien du groupe</string>
<string name="button_create_group_link">Créer un lien</string>
<string name="button_edit_group_profile">Modifier le profil du groupe</string>
<string name="remove_member_confirmation">Supprimer</string>
<string name="member_info_section_title_member">MEMBRE</string>
<string name="live_message">Message dynamique !</string>
<string name="send_live_message">Envoyer un message dynamique</string>
<string name="send_live_message_desc">Envoyez un message dynamique - il sera mis à jour pour le⸱s destinataire⸱s au fur et à mesure que vous le tapez</string>
<string name="send_verb">Envoyer</string>
<string name="member_role_will_be_changed_with_invitation">Le rôle sera changé pour «%s». Le membre va recevoir une nouvelle invitation.</string>
<string name="live">LIVE</string>
<string name="button_add_members">Inviter des membres</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">Vous pouvez partager un lien ou un code QR - n\'importe qui pourra rejoindre le groupe. Vous ne perdrez pas les membres du groupe si vous le supprimez par la suite.</string>
<string name="info_row_local_name">Nom local</string>
<string name="create_group_link">Créer un lien de groupe</string>
<string name="error_deleting_link_for_group">Erreur lors de la suppression du lien du groupe</string>
<string name="error_creating_link_for_group">Erreur lors de la création du lien du groupe</string>
<string name="only_group_owners_can_change_prefs">Seuls les propriétaires du groupe peuvent modifier les préférences du groupe.</string>
<string name="section_title_for_console">POUR TERMINAL</string>
<string name="change_member_role_question">Changer le rôle du groupe \?</string>
<string name="member_role_will_be_changed_with_notification">Le rôle sera changé pour «%s». Les membres du groupe seront notifiés.</string>
<string name="icon_descr_contact_checked">Contact vérifié⸱e</string>
<string name="clear_contacts_selection_button">Effacer</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact·s sélectionné·e·s</string>
<string name="skip_inviting_button">Passer linvitation de membres</string>
<string name="select_contacts">Sélectionnez des contacts</string>
<string name="no_contacts_selected">Aucun contact sélectionné</string>
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBRES</string>
<string name="group_info_member_you">vous : <xliff:g id="group_info_you">%1$s</xliff:g></string>
<string name="button_delete_group">Supprimer le groupe</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Le groupe va être supprimé pour tout les membres - impossible de revenir en arrière!</string>
<string name="delete_group_for_self_cannot_undo_warning">Le groupe va être supprimé pour vous - impossible de revenir en arrière!</string>
<string name="button_leave_group">Quitter le groupe</string>
<string name="delete_link_question">Supprimer le lien\?</string>
<string name="delete_link">Supprimer le lien</string>
<string name="all_group_members_will_remain_connected">Tous les membres du groupe resteront connectés.</string>
<string name="icon_descr_expand_role">Étendre la sélection de rôle</string>
<string name="invite_to_group_button">Inviter au groupe</string>
<string name="invite_prohibited">Impossible d\'inviter le contact!</string>
<string name="invite_prohibited_description">Vous essayez d\'inviter un contact avec lequel vous avez partagé un profil incognito à rejoindre le groupe dans lequel vous utilisez votre profil principal</string>
<string name="info_row_database_id">ID de base de données</string>
<string name="button_remove_member">Retirer le membre</string>
<string name="button_send_direct_message">Envoi de message direct</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Ce membre sera retiré du groupe - impossible de revenir en arrière!</string>
<string name="role_in_group">Rôle</string>
<string name="change_role">Changer le rôle</string>
<string name="change_verb">Changer</string>
<string name="switch_verb">Échanger</string>
<string name="error_removing_member">Erreur lors de la suppression d\'un membre</string>
<string name="error_changing_role">Erreur lors du changement de rôle</string>
<string name="group_full_name_field">Nom complet du groupe :</string>
<string name="update_network_settings_confirmation">Mise à jour</string>
<string name="chat_preferences_on">on</string>
<string name="chat_preferences_off">off</string>
<string name="direct_messages">Messages dynamiques</string>
<string name="full_deletion">Supprimer pour tous</string>
<string name="only_you_can_delete_messages">Vous êtes le seul à pouvoir supprimer des messages de manière irréversible (votre contact peut les marquer comme supprimé).</string>
<string name="conn_stats_section_title_servers">SERVEURS</string>
<string name="receiving_via">Réception via</string>
<string name="theme_system">Système</string>
<string name="allow_direct_messages">Autoriser l\'envoi de messages directs aux membres.</string>
<string name="prohibit_direct_messages">Interdire l\'envoi de messages directs aux membres.</string>
<string name="group_members_can_delete">Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés.</string>
<string name="message_deletion_prohibited_in_chat">La suppression irréversible de messages est interdite dans ce groupe.</string>
<string name="sending_via">Envoi via</string>
<string name="network_status">État du réseau</string>
<string name="switch_receiving_address">Changer d\'adresse de réception</string>
<string name="create_secret_group_title">Créer un groupe secret</string>
<string name="group_main_profile_sent">Votre profil de chat sera envoyé aux membres du groupe</string>
<string name="network_option_enable_tcp_keep_alive">Activer le TCP keep-alive</string>
<string name="network_options_save">Sauvegarder</string>
<string name="update_network_settings_question">Mettre à jour les paramètres réseau \?</string>
<string name="incognito">Incognito</string>
<string name="incognito_random_profile">Votre profil aléatoire</string>
<string name="incognito_random_profile_description">Un profil aléatoire sera envoyé à votre contact</string>
<string name="incognito_random_profile_from_contact_description">Un profil aléatoire sera envoyé au contact qui vous a envoyé ce lien</string>
<string name="incognito_info_allows">Cela permet d\'avoir plusieurs connections anonymes sans aucune données partagées entre elles sur un même profil.</string>
<string name="incognito_info_find">Pour trouver le profil utilisé lors d\'une connexion incognito, appuyez sur le nom du contact ou du groupe en haut du chat.</string>
<string name="theme_light">Clair</string>
<string name="theme_dark">Sombre</string>
<string name="theme">Thème</string>
<string name="save_color">Sauvegarder la couleur</string>
<string name="reset_color">Réinitialisation des couleurs</string>
<string name="color_primary">Principale</string>
<string name="chat_preferences_you_allow">Vous autorisez</string>
<string name="chat_preferences_contact_allows">Votre contact autorise</string>
<string name="chat_preferences_default">par défaut (%s)</string>
<string name="chat_preferences_no">non</string>
<string name="chat_preferences_always">toujours</string>
<string name="chat_preferences">Préférences de chat</string>
<string name="contact_preferences">Préférences de contact</string>
<string name="group_preferences">Préférences du groupe</string>
<string name="set_group_preferences">Définir les préférences du groupe</string>
<string name="your_preferences">Vos préférences</string>
<string name="allow_your_contacts_irreversibly_delete">Autorise votre contact à supprimer de façon définitive des messages envoyés.</string>
<string name="contacts_can_mark_messages_for_deletion">Vos contacts peuvent marquer les messages pour les supprimer ; vous pourrez les consulter.</string>
<string name="allow_your_contacts_to_send_voice_messages">Autorise vos contacts à envoyer des messages vocaux.</string>
<string name="allow_voice_messages_only_if">Autoriser les messages vocaux uniquement si votre contact les autorise.</string>
<string name="prohibit_sending_voice_messages">Interdire l\'envoi de messages vocaux.</string>
<string name="only_you_can_send_disappearing">Seulement vous pouvez envoyer des messages éphémères.</string>
<string name="only_you_can_send_voice">Vous seul pouvez envoyer des messages vocaux.</string>
<string name="allow_to_delete_messages">Autoriser la suppression irréversible de messages envoyés.</string>
<string name="disappearing_messages_are_prohibited">Les messages éphémères sont interdits dans ce groupe.</string>
<string name="group_members_can_send_voice">Les membres du groupe peuvent envoyer des messages vocaux.</string>
<string name="delete_after">Supprimer après</string>
<string name="ttl_sec">%d sec</string>
<string name="ttl_s">%ds</string>
<string name="ttl_min">%d min</string>
<string name="ttl_month">%d mois</string>
<string name="ttl_months">%d mois</string>
<string name="ttl_m">%dm</string>
<string name="ttl_mth">%dm</string>
<string name="ttl_hour">%d heure</string>
<string name="ttl_hours">%d heures</string>
<string name="ttl_h">%dh</string>
<string name="ttl_day">%d jour</string>
<string name="ttl_days">%d jours</string>
<string name="ttl_d">%dj</string>
<string name="ttl_week">%d semaine</string>
<string name="ttl_weeks">%d semaines</string>
<string name="ttl_w">%dsmn</string>
<string name="timed_messages">Messages éphémères</string>
<string name="voice_messages">Messages vocaux</string>
<string name="feature_enabled">activé</string>
<string name="feature_enabled_for_you">activé pour vous</string>
<string name="feature_enabled_for_contact">activé pour le contact</string>
<string name="feature_off">off</string>
<string name="feature_received_prohibited">reçu, non autorisé</string>
<string name="allow_your_contacts_to_send_disappearing_messages">Autorise votre contact à envoyer des messages éphémères.</string>
<string name="conn_level_desc_direct">directe</string>
<string name="group_is_decentralized">Le groupe est entièrement décentralisé il n\'est visible que par ses membres.</string>
<string name="group_members_can_send_disappearing">Les membres du groupes peuvent envoyer des messages éphémères.</string>
<string name="network_options_revert">Revenir en arrière</string>
<string name="prohibit_sending_disappearing_messages">Interdit lenvoi de messages éphémères.</string>
<string name="incognito_info_protects">Le mode Incognito protège la confidentialité de votre profil principal — pour chaque nouveau contact un nouveau profil aléatoire est créé.</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">La mise à jour des ces paramètres reconnectera le client à tous les serveurs.</string>
<string name="incognito_info_share">Lorsque vous partagez un profil incognito avec quelqu\'un, ce profil sera utilisé pour les groupes auxquels il vous invite.</string>
<string name="chat_preferences_yes">oui</string>
<string name="allow_disappearing_messages_only_if">Autorise les messages éphémères seulement si votre contact les autorises.</string>
<string name="allow_irreversible_message_deletion_only_if">Autoriser la suppression irréversible des messages uniquement si votre contact vous l\'autorise.</string>
<string name="only_your_contact_can_delete">Seul votre contact peut supprimer de manière irréversible des messages (vous pouvez les marquer comme supprimé).</string>
<string name="only_your_contact_can_send_disappearing">Seulement votre contact peut envoyer des messages éphémères.</string>
<string name="both_you_and_your_contact_can_send_disappearing">Vous et votre contact êtes tous deux en mesure d\'envoyer des messages éphémères.</string>
<string name="voice_messages_are_prohibited">Les messages vocaux sont interdits dans ce groupe.</string>
<string name="group_display_name_field">Nom affiché du groupe :</string>
<string name="group_unsupported_incognito_main_profile_sent">Le mode Incognito n\'est pas supporté ici - votre profil principal sera envoyé aux membres du groupe</string>
<string name="conn_level_desc_indirect">indirecte (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<string name="info_row_group">Groupe</string>
<string name="info_row_connection">Connexion</string>
<string name="network_option_seconds_label">sec</string>
<string name="network_option_tcp_connection_timeout">Délai de connexion TCP</string>
<string name="group_profile_is_stored_on_members_devices">Le profil du groupe est stocké sur les appareils des membres, pas sur les serveurs.</string>
<string name="save_group_profile">Sauvegarder le profil du groupe</string>
<string name="error_saving_group_profile">Erreur lors de la sauvegarde du profil de groupe</string>
<string name="network_options_reset_to_defaults">Réinitialisation des valeurs par défaut</string>
<string name="network_option_protocol_timeout">Délai du protocole</string>
<string name="network_option_ping_interval">Intervalle de PING</string>
<string name="both_you_and_your_contacts_can_delete">Vous et votre contact êtes tous deux en mesure de supprimer de manière irréversible les messages envoyés.</string>
<string name="message_deletion_prohibited">La suppression irréversible de message est interdite dans ce chat.</string>
<string name="both_you_and_your_contact_can_send_voice">Vous et votre contact êtes tous deux en mesure d\'envoyer des messages vocaux.</string>
<string name="only_your_contact_can_send_voice">Seul votre contact peut envoyer des messages vocaux.</string>
<string name="voice_prohibited_in_this_chat">Les messages vocaux sont interdits dans ce chat.</string>
<string name="disappearing_prohibited_in_this_chat">Les messages éphémères sont interdits dans cette discussion.</string>
<string name="allow_to_send_voice">Autoriser l\'envoi de messages vocaux.</string>
<string name="prohibit_sending_voice">Interdire l\'envoi de messages vocaux.</string>
<string name="allow_to_send_disappearing">Autorise lenvoi de messages éphémères.</string>
<string name="prohibit_sending_disappearing">Interdit lenvoi de messages éphémères.</string>
<string name="prohibit_message_deletion">Interdire la suppression irréversible des messages.</string>
<string name="group_members_can_send_dms">Les membres du groupe peuvent envoyer des messages directs.</string>
<string name="direct_messages_are_prohibited_in_chat">Les messages directs entre membres sont interdits dans ce groupe.</string>
<string name="v4_4_live_messages_desc">Les destinataires voient les mises à jour au fur et à mesure que vous les tapez.</string>
<string name="v4_4_verify_connection_security">Vérifier la sécurité de la connexion</string>
<string name="v4_4_verify_connection_security_desc">Comparez les codes de sécurité avec vos contacts.</string>
<string name="new_in_version">Nouveautés de la %s</string>
<string name="v4_2_security_assessment">Évaluation de sécurité</string>
<string name="v4_2_group_links">Liens de groupe</string>
<string name="v4_2_auto_accept_contact_requests_desc">Avec message de bienvenue facultatif.</string>
<string name="v4_3_voice_messages">Messages vocaux</string>
<string name="v4_3_voice_messages_desc">Max 40 secondes, réception immédiate.</string>
<string name="v4_3_irreversible_message_deletion">Suppression irréversible des messages</string>
<string name="v4_3_irreversible_message_deletion_desc">Vos contacts peuvent autoriser la suppression complète des messages.</string>
<string name="v4_3_improved_privacy_and_security">Une meilleure sécurité et protection de la vie privée</string>
<string name="v4_3_improved_privacy_and_security_desc">Masquer l\'écran de l\'app dans les apps récentes.</string>
<string name="v4_4_disappearing_messages">Messages éphémères</string>
<string name="v4_4_disappearing_messages_desc">Les messages envoyés seront supprimés après une durée déterminée.</string>
<string name="v4_4_live_messages">Messages dynamiques</string>
<string name="accept_feature">Accepter</string>
<string name="v4_2_auto_accept_contact_requests">Demandes de contact auto-acceptées</string>
<string name="whats_new">Quoi de neuf \?</string>
<string name="v4_2_group_links_desc">Les admins peuvent créer les liens qui permettent de rejoindre les groupes.</string>
<string name="accept_feature_set_1_day">Définir 1 jour</string>
<string name="v4_2_security_assessment_desc">La sécurité de SimpleX Chat a été auditée par Trail of Bits.</string>
<string name="v4_3_improved_server_configuration">Configuration de serveur améliorée</string>
<string name="v4_3_improved_server_configuration_desc">Ajoutez des serveurs en scannant des codes QR.</string>
<string name="invalid_data">données invalides</string>
<string name="invalid_chat">chat invalide</string>
<string name="icon_descr_cancel_live_message">Annuler le message dynamique</string>
<string name="feature_offered_item">offert %s</string>
<string name="feature_offered_item_with_param">offert %s: %2s</string>
<string name="feature_cancelled_item">annulé %s</string>
<string name="app_version_title">Version de l\'application</string>
<string name="core_simplexmq_version">simplexmq : v%s (%2s)</string>
<string name="app_version_code">Build de l\'app : %s</string>
<string name="app_version_name">Version de l\'app : v%s</string>
<string name="core_build_timestamp">Cœur compilé le : %s</string>
<string name="core_version">Version du cœur : v%s</string>
<string name="network_option_ping_count">Nombre de PING</string>
<string name="users_delete_all_chats_deleted">Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière !</string>
<string name="delete_files_and_media_all">Effacer tous les fichiers</string>
<string name="users_delete_profile_for">Supprimer le profil de chat pour</string>
<string name="network_session_mode_user_description">Une connexion TCP distincte (et un identifiant SOCKS) sera utilisée <b>pour chaque profil de chat que vous avez dans l\'application</b>.</string>
<string name="users_delete_question">Supprimer le profil du chat \?</string>
<string name="files_and_media_section">Fichiers &amp; médias</string>
<string name="messages_section_title">Messages</string>
<string name="smp_servers_per_user">Les serveurs pour les nouvelles connexions de votre profil de chat actuel</string>
<string name="messages_section_description">Ce paramètre s\'applique aux messages de votre profil de chat actuel</string>
<string name="network_session_mode_entity">Connexion</string>
<string name="delete_files_and_media_for_all_users">Effacer les fichiers de tous les profils de chat</string>
<string name="users_delete_with_connections">Profil et connexions au serveur</string>
<string name="network_session_mode_transport_isolation">Isolement du transport</string>
<string name="update_network_session_mode_question">Mettre à jour le mode d\'isolation du transport \?</string>
<string name="your_chat_profiles_stored_locally">Vos profils de chat sont stockés localement, uniquement sur votre appareil</string>
<string name="network_session_mode_entity_description">Une connexion TCP distincte (et identifiant SOCKS) sera utilisée <b>pour chaque contact et membre de groupe</b>.
\n<b>Veuillez noter</b> : si vous avez de nombreuses connexions, votre consommation de batterie et de réseau peut être nettement plus élevée et certaines liaisons peuvent échouer.</string>
<string name="network_session_mode_user">Profil de chat</string>
<string name="users_add">Ajouter un profil</string>
<string name="users_delete_data_only">Données de profil local uniquement</string>
<string name="error_deleting_user">Erreur lors de la suppression du profil utilisateur</string>
<string name="your_chat_profiles">Vos profils de chat</string>
<string name="failed_to_active_user_title">Erreur lors du changement de profil !</string>
<string name="failed_to_create_user_title">Erreur lors de la création du profil !</string>
<string name="failed_to_create_user_duplicate_desc">Vous avez déjà un profil de chat avec ce même nom affiché. Veuillez choisir un autre nom.</string>
<string name="failed_to_create_user_duplicate_title">Nom d\'affichage en double !</string>
</resources>

View File

@@ -1,147 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="share_image">चित्र साझा करें…</string>
<string name="chat_preferences_off">बंद</string>
<string name="accept_feature_set_1_day">1 दिन निर्धारित करें</string>
<string name="v4_3_improved_server_configuration_desc">क्यूआर संहिता स्कैन करके सर्वर जोड़ें।</string>
<string name="group_preview_you_are_invited">आपको समूह में आमंत्रित किया जाता है</string>
<string name="icon_descr_server_status_connected">जुड़े हुए</string>
<string name="use_camera_button">कैमरे का प्रयोग करें</string>
<string name="above_then_preposition_continuation">ऊपर,तब:</string>
<string name="accept_contact_button">स्वीकार करना</string>
<string name="connect_button">जुडिये</string>
<string name="your_contact_address">आपका संपर्क पता</string>
<string name="smp_servers_add_to_another_device">दूसरे उपकरण में जोड़ें</string>
<string name="bold">निडर</string>
<string name="answer_call">कॉल का उत्तर दें</string>
<string name="settings_section_title_you">तुम</string>
<string name="settings_section_title_settings">समायोजन</string>
<string name="chat_item_ttl_month">1 महीना</string>
<string name="rcv_group_event_member_connected">जुड़े हुए</string>
<string name="group_member_role_admin">व्यवस्थापक</string>
<string name="all_group_members_will_remain_connected">समूह के सभी सदस्य जुड़े रहेंगे।</string>
<string name="change_verb">परिवर्तन</string>
<string name="sending_via">माध्यम से भेजा जा रहा है</string>
<string name="feature_off">बंद</string>
<string name="whats_new">नया क्या है</string>
<string name="v4_2_group_links_desc">व्यवस्थापक समूहों में शामिल होने के लिए लिंक बना सकते हैं।</string>
<string name="chat_item_ttl_day">1 दिन</string>
<string name="chat_item_ttl_week">1 सप्ताह</string>
<string name="about_simplex">सिंपलएक्स के बारे में</string>
<string name="about_simplex_chat">बारे में <xliff:g id="appNameFull">सिंप्लेक्स चैट</xliff:g></string>
<string name="accept_call_on_lock_screen">स्वीकार करना</string>
<string name="accept">स्वीकार करना</string>
<string name="accept_feature">स्वीकार करना</string>
<string name="accept_connection_request__question">संबंध अनुरोध स्वीकार करें\?</string>
<string name="callstatus_accepted">स्वीकृत कॉल</string>
<string name="accept_contact_incognito_button">गुप्त स्वीकार करें</string>
<string name="accept_requests">निवेदन स्वीकार करो</string>
<string name="smp_servers_preset_add">पूर्वनिर्धारित सर्वर जोड़ें</string>
<string name="users_add">प्रोफ़ाइल जोड़ें</string>
<string name="smp_servers_add">सर्वर जोड़े…</string>
<string name="notifications_mode_service">हमेशा बने रहें</string>
<string name="attach">संलग्न करना</string>
<string name="network_settings">उन्नत संजाल समायोजन</string>
<string name="users_delete_all_chats_deleted">सभी बातचीत और संदेश हटा दिए जाएंगे - इसे पूर्ववत नहीं किया जा सकता!</string>
<string name="chat_preferences_always">हमेशा</string>
<string name="allow_verb">अनुमति देना</string>
<string name="appearance_settings">दिखावट</string>
<string name="cancel_verb">रद्द करना</string>
<string name="icon_descr_cancel_file_preview">फ़ाइल पूर्वावलोकन रद्द करें</string>
<string name="icon_descr_cancel_image_preview">छवि पूर्वावलोकन रद्द करें</string>
<string name="clear_verb">साफ़</string>
<string name="colored">रंगीन</string>
<string name="callstate_connected">जुड़े हुए</string>
<string name="smp_server_test_connect">जुडिये</string>
<string name="connect_via_link_verb">जुडिये</string>
<string name="server_connected">जुड़े हुए</string>
<string name="group_member_role_owner">स्वामी</string>
<string name="group_member_status_connected">जुड़े हुए</string>
<string name="notification_contact_connected">जुड़े हुए</string>
<string name="you_joined_this_group">आप इस समूह में शामिल हो गए</string>
<string name="group_info_member_you">तुम: <xliff:g id="group_info_you">%1$s</xliff:g></string>
<string name="you_are_invited_to_group">आपको समूह में आमंत्रित किया जाता है</string>
<string name="snd_conn_event_switch_queue_phase_completed">तुमने पता बदल लिया</string>
<string name="snd_group_event_user_left">आप चले गए</string>
<string name="unknown_error">अज्ञात त्रुटि</string>
<string name="chat_preferences_you_allow">आप आज्ञा दें</string>
<string name="welcome">स्वागत!</string>
<string name="la_notice_turn_on">चालू करो</string>
<string name="section_title_welcome_message">स्वागत संदेश</string>
<string name="unknown_message_format">अज्ञात संदेश प्रारूप</string>
<string name="personal_welcome">स्वागत <xliff:g>%1$s</xliff:g>!</string>
<string name="callstate_starting">शुरुआत</string>
<string name="send_verb">भेजना</string>
<string name="save_color">रंग बचाओ</string>
<string name="share_verb">साझा करना</string>
<string name="reject_contact_button">अस्वीकार</string>
<string name="network_use_onion_hosts_required">आवश्यक</string>
<string name="reject">अस्वीकार</string>
<string name="open_verb">खुला</string>
<string name="group_member_status_removed">निकाला गया</string>
<string name="rcv_group_event_member_deleted">निकाला गया <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="reply_verb">जवाब दे दो</string>
<string name="leave_group_button">छोड़ना</string>
<string name="mark_read">पढ़ा हुआ चिह्नित करें</string>
<string name="icon_descr_more_button">अधिक</string>
<string name="network_use_onion_hosts_no">नहीं</string>
<string name="chat_item_ttl_none">कभी नहीं</string>
<string name="group_member_status_invited">आमंत्रित</string>
<string name="delete_after">बाद मिटा दें</string>
<string name="display_name_invited_to_connect">जुड़ने के लिए आमंत्रित किया</string>
<string name="rcv_group_event_invited_via_your_group_link">आपके समूह लिंक के माध्यम से आमंत्रित किया गया</string>
<string name="rcv_group_event_member_added">आमंत्रित <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="icon_descr_add_members">सदस्यों को आमंत्रित करो</string>
<string name="v4_3_irreversible_message_deletion">अपरिवर्तनीय संदेश विलोपन</string>
<string name="button_add_members">सदस्यों को आमंत्रित करो</string>
<string name="invite_to_group_button">समूह में आमंत्रित करें</string>
<string name="message_deletion_prohibited_in_chat">इस समूह में अपरिवर्तनीय संदेश हटाना प्रतिबंधित है।</string>
<string name="italic">तिरछा</string>
<string name="join_group_button">जोड़ना</string>
<string name="join_group_incognito_button">गुप्त में शामिल हों</string>
<string name="joining_group">समूह में शामिल होना</string>
<string name="join_group_question">समूह में शामिल हों\?</string>
<string name="thousand_abbreviation"></string>
<string name="keychain_error">चाबी का गुच्छा त्रुटि</string>
<string name="button_leave_group">समूह छोड़ दें</string>
<string name="leave_group_question">समूह छोड़ दें</string>
<string name="rcv_group_event_member_left">बाएं</string>
<string name="group_member_status_left">बाएं</string>
<string name="theme_light">रोशनी</string>
<string name="info_row_local_name">स्थानीय नाम</string>
<string name="users_delete_data_only">केवल स्थानीय प्रोफ़ाइल डेटा</string>
<string name="auth_log_in_using_credential">अपने क्रेडेंशियल का उपयोग करके लॉग इन करें</string>
<string name="make_private_connection">एक निजी संबंध बनाओ</string>
<string name="marked_deleted_description">मिटाया हुआ चिह्नित किया गया</string>
<string name="mark_unread">अपठित को चिह्नित करें</string>
<string name="v4_3_voice_messages_desc">अधिकतम 40 सेकंड, तुरन्त प्राप्त हुआ।</string>
<string name="you_sent_group_invitation">आपने समूह आमंत्रण भेजा</string>
<string name="message_delivery_error_desc">सबसे अधिक संभावना है कि इस संपर्क ने आपके साथ संबंध हटा दिया है।</string>
<string name="mute_chat">मूक</string>
<string name="network_status">नेटवर्क की स्थिति</string>
<string name="notification_new_contact_request">नया संपर्क अनुरोध</string>
<string name="delete_files_and_media_all">सभी फाइलों को मिटा दें</string>
<string name="delete_archive">संग्रह हटाएं</string>
<string name="new_database_archive">नया डेटाबेस संग्रह</string>
<string name="new_member_role">नए सदस्य की भूमिका</string>
<string name="settings_notifications_mode_title">अधिसूचना सेवा</string>
<string name="notification_preview_new_message">नया सन्देश</string>
<string name="no_contacts_to_add">जोड़ने के लिए कोई संपर्क नहीं है</string>
<string name="chat_preferences_no">नहीं</string>
<string name="no_contacts_selected">कोई संपर्क नहीं चुना गया</string>
<string name="no_details">कोई विवरण नहीं</string>
<string name="settings_notification_preview_title">अधिसूचना पूर्वावलोकन</string>
<string name="notifications">सूचनाएं</string>
<string name="full_deletion">सभी के लिए हटाएं</string>
<string name="delete_chat_archive_question">चैट संग्रह मिटाएं\?</string>
<string name="delete_chat_profile_question">चैट प्रोफ़ाइल हटाएं\?</string>
<string name="users_delete_question">चैट प्रोफ़ाइल हटाएं\?</string>
<string name="users_delete_profile_for">के लिए चैट प्रोफ़ाइल हटाएं</string>
<string name="button_delete_contact">संपर्क मिटा दें</string>
<string name="deleted_description">हटाए गए</string>
<string name="delete_contact_question">संपर्क मिटा दें\?</string>
<string name="rcv_group_event_group_deleted">हटाए गए समूह</string>
<string name="delete_image">छवि हटाएं</string>
<string name="button_delete_group">समूह हटाएं</string>
<string name="for_me_only">मेरे लिए हटाएं</string>
</resources>

View File

@@ -1,956 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="simplex_link_mode">Link di SimpleX</string>
<string name="network_error_desc">Controlla la tua connessione di rete con <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> e riprova.</string>
<string name="service_notifications_disabled">Le notifiche istantanee sono disattivate!</string>
<string name="contact_connection_pending">in connessione…</string>
<string name="attach">Allega</string>
<string name="icon_descr_cancel_image_preview">Annulla anteprima immagine</string>
<string name="images_limit_desc">Possono essere inviate solo 10 immagini alla volta</string>
<string name="image_will_be_received_when_contact_is_online">L\'immagine verrà ricevuta quando il tuo contatto sarà in linea, aspetta o controlla più tardi!</string>
<string name="waiting_for_image">In attesa dell\'immagine</string>
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
<string name="thousand_abbreviation">k</string>
<string name="connect_via_invitation_link">Connettere via link di invito\?</string>
<string name="connect_via_group_link">Connettere via link del gruppo\?</string>
<string name="profile_will_be_sent_to_contact_sending_link">Il tuo profilo verrà inviato al contatto da cui hai ricevuto questo link.</string>
<string name="connect_via_link_verb">Connetti</string>
<string name="server_connected">connesso</string>
<string name="server_error">errore</string>
<string name="server_connecting">in connessione</string>
<string name="connected_to_server_to_receive_messages_from_contact">Sei connesso al server usato per ricevere messaggi da questo contatto.</string>
<string name="trying_to_connect_to_server_to_receive_messages">Tentativo di connessione al server usato per ricevere messaggi da questo contatto.</string>
<string name="deleted_description">eliminato</string>
<string name="marked_deleted_description">contrassegnato eliminato</string>
<string name="sending_files_not_yet_supported">l\'invio di file non è ancora supportato</string>
<string name="receiving_files_not_yet_supported">la ricezione di file non è ancora supportata</string>
<string name="sender_you_pronoun">tu</string>
<string name="unknown_message_format">formato messaggio sconosciuto</string>
<string name="invalid_message_format">formato messaggio non valido</string>
<string name="live">IN DIRETTA</string>
<string name="invalid_chat">conversazione non valida</string>
<string name="invalid_data">dati non validi</string>
<string name="display_name_connection_established">connessione stabilita</string>
<string name="display_name_invited_to_connect">invitato a connettersi</string>
<string name="display_name_connecting">in connessione…</string>
<string name="description_you_shared_one_time_link">hai condiviso un link una tantum</string>
<string name="description_you_shared_one_time_link_incognito">hai condiviso un link incognito una tantum</string>
<string name="description_via_group_link">via link di gruppo</string>
<string name="description_via_group_link_incognito">incognito via link di gruppo</string>
<string name="description_via_contact_address_link">via link indirizzo del contatto</string>
<string name="description_via_contact_address_link_incognito">incognito via link indirizzo del contatto</string>
<string name="description_via_one_time_link">via link una tantum</string>
<string name="description_via_one_time_link_incognito">incognito via link una tantum</string>
<string name="simplex_link_contact">Indirizzo del contatto SimpleX</string>
<string name="simplex_link_invitation">Invito SimpleX una tantum</string>
<string name="simplex_link_group">Link gruppo SimpleX</string>
<string name="simplex_link_mode_full">Link completo</string>
<string name="simplex_link_mode_browser">Via browser</string>
<string name="error_saving_smp_servers">Errore di salvataggio server SMP</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Assicurati che gli indirizzi dei server SMP siano nel formato giusto, uno per riga e non doppi.</string>
<string name="error_setting_network_config">Errore di aggiornamento della configurazione di rete</string>
<string name="failed_to_parse_chat_title">Caricamento conversazione fallito</string>
<string name="failed_to_parse_chats_title">Caricamento delle chat fallito</string>
<string name="contact_developers">Aggiorna l\'app e contatta gli sviluppatori.</string>
<string name="connection_timeout">Connessione scaduta</string>
<string name="connection_error">Errore di connessione</string>
<string name="error_sending_message">Errore di invio del messaggio</string>
<string name="error_adding_members">Errore di aggiunta del/i membro/i</string>
<string name="error_joining_group">Errore di entrata nel gruppo</string>
<string name="cannot_receive_file">Impossibile ricevere il file</string>
<string name="sender_cancelled_file_transfer">Il mittente ha annullato il trasferimento del file.</string>
<string name="error_receiving_file">Errore di ricezione del file</string>
<string name="error_creating_address">Errore di creazione dell\'indirizzo</string>
<string name="contact_already_exists">Il contatto esiste già</string>
<string name="invalid_connection_link">Link di connessione non valido</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Controlla di aver usato il link giusto o chiedi al tuo contatto di inviartene un altro.</string>
<string name="connection_error_auth">Errore di connessione (AUTH)</string>
<string name="error_accepting_contact_request">Errore di accettazione della richiesta del contatto</string>
<string name="sender_may_have_deleted_the_connection_request">Il mittente potrebbe aver eliminato la richiesta di connessione.</string>
<string name="error_deleting_contact">Errore di eliminazione del contatto</string>
<string name="error_deleting_group">Errore di eliminazione del gruppo</string>
<string name="error_deleting_contact_request">Errore di eliminazione della richiesta di contatto</string>
<string name="error_deleting_pending_contact_connection">Errore di eliminazione della connessione del contatto in attesa</string>
<string name="error_changing_address">Errore di modifica dell\'indirizzo</string>
<string name="error_smp_test_failed_at_step">Test fallito al passo %s.</string>
<string name="error_smp_test_server_auth">Il server richiede l\'autorizzazione di creare code, controlla la password</string>
<string name="smp_server_test_connect">Connetti</string>
<string name="smp_server_test_create_queue">Crea coda</string>
<string name="smp_server_test_secure_queue">Coda sicura</string>
<string name="smp_server_test_delete_queue">Elimina coda</string>
<string name="smp_server_test_disconnect">Disconnetti</string>
<string name="icon_descr_instant_notifications">Notifiche istantanee</string>
<string name="service_notifications">Notifiche istantanee!</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Può essere disattivato nelle impostazioni</b>; le notifiche verranno comunque mostrate mentre l\'app è in uso.</string>
<string name="turning_off_service_and_periodic">L\'ottimizzazione della batteria è attiva, spegnimento del servizio in secondo piano e delle richieste periodiche di messaggi nuovi. Puoi riattivarli nelle impostazioni.</string>
<string name="periodic_notifications">Notifiche periodiche</string>
<string name="periodic_notifications_disabled">Le notifiche periodiche sono disattivate!</string>
<string name="periodic_notifications_desc">L\'app cerca nuovi messaggi periodicamente, utilizza una piccola percentuale di batteria al giorno. L\'app non usa notifiche push, non vengono inviati dati dal tuo dispositivo ai server.</string>
<string name="enter_passphrase_notification_title">Password necessaria</string>
<string name="enter_passphrase_notification_desc">Per ricevere notifiche, inserisci la password del database</string>
<string name="database_initialization_error_title">Impossibile inizializzare il database</string>
<string name="database_initialization_error_desc">Il database non funziona bene. Tocca per maggiori informazioni</string>
<string name="simplex_service_notification_text">Ricezione messaggi…</string>
<string name="hide_notification">Nascondi</string>
<string name="ntf_channel_messages">Messaggi di SimpleX Chat</string>
<string name="ntf_channel_calls">Chiamate di SimpleX Chat</string>
<string name="settings_notifications_mode_title">Servizio di notifica</string>
<string name="settings_notification_preview_mode_title">Mostra anteprima</string>
<string name="settings_notification_preview_title">Anteprima notifica</string>
<string name="notifications_mode_off">Quando l\'app è aperta</string>
<string name="notifications_mode_periodic">Periodicamente</string>
<string name="notifications_mode_service">Sempre attivo</string>
<string name="notifications_mode_off_desc">L\'app può ricevere notifiche solo quando è attiva, non verrà avviato alcun servizio in secondo piano</string>
<string name="notifications_mode_periodic_desc">Controlla messaggi nuovi ogni 10 minuti per massimo 1 minuto</string>
<string name="notification_preview_mode_message">Testo del messaggio</string>
<string name="notification_preview_mode_contact">Nome del contatto</string>
<string name="notification_preview_mode_hidden">Nascosta</string>
<string name="notification_preview_mode_message_desc">Mostra contatto e messaggio</string>
<string name="notification_preview_mode_contact_desc">Mostra solo il contatto</string>
<string name="notification_display_mode_hidden_desc">Nascondi contatto e messaggio</string>
<string name="notification_preview_somebody">Contatto nascosto:</string>
<string name="notification_preview_new_message">messaggio nuovo</string>
<string name="notification_new_contact_request">Nuova richiesta di contatto</string>
<string name="notification_contact_connected">Connesso</string>
<string name="la_notice_turn_on">Attiva</string>
<string name="auth_unlock">Sblocca</string>
<string name="auth_log_in_using_credential">Accedi usando le tue credenziali</string>
<string name="auth_enable_simplex_lock">Attiva SimpleX Lock</string>
<string name="auth_disable_simplex_lock">Disattiva SimpleX Lock</string>
<string name="auth_confirm_credential">Conferma le tue credenziali</string>
<string name="auth_unavailable">Autenticazione non disponibile</string>
<string name="auth_device_authentication_is_disabled_turning_off">L\'autenticazione del dispositivo è disattivata. Disattivazione di SimpleX Lock.</string>
<string name="auth_stop_chat">Ferma la chat</string>
<string name="auth_open_chat_console">Apri la console della chat</string>
<string name="message_delivery_error_title">Errore di recapito del messaggio</string>
<string name="message_delivery_error_desc">Probabilmente questo contatto ha eliminato la connessione con te.</string>
<string name="reply_verb">Rispondi</string>
<string name="share_verb">Condividi</string>
<string name="copy_verb">Copia</string>
<string name="save_verb">Salva</string>
<string name="edit_verb">Modifica</string>
<string name="delete_verb">Elimina</string>
<string name="reveal_verb">Rivela</string>
<string name="hide_verb">Nascondi</string>
<string name="allow_verb">Consenti</string>
<string name="delete_message__question">Eliminare il messaggio\?</string>
<string name="delete_message_cannot_be_undone_warning">Il messaggio verrà eliminato, non è reversibile!</string>
<string name="for_me_only">Elimina per me</string>
<string name="for_everybody">Per tutti</string>
<string name="icon_descr_edited">modificato</string>
<string name="icon_descr_sent_msg_status_sent">inviato</string>
<string name="icon_descr_sent_msg_status_unauthorized_send">invio non autorizzato</string>
<string name="icon_descr_sent_msg_status_send_failed">invio fallito</string>
<string name="icon_descr_received_msg_status_unread">non letto</string>
<string name="personal_welcome">Benvenuto/a <xliff:g>%1$s</xliff:g>!</string>
<string name="welcome">Benvenuto/a!</string>
<string name="this_text_is_available_in_settings">Questo testo è disponibile nelle impostazioni</string>
<string name="your_chats">Le tue chat</string>
<string name="group_preview_you_are_invited">sei stato invitato in un gruppo</string>
<string name="group_preview_join_as">entra come %s</string>
<string name="group_connection_pending">in connessione…</string>
<string name="tap_to_start_new_chat">Tocca per iniziare una conversazione</string>
<string name="chat_with_developers">Scrivi agli sviluppatori</string>
<string name="you_have_no_chats">Non hai chat</string>
<string name="share_image">Condividi immagine…</string>
<string name="share_file">Condividi file…</string>
<string name="icon_descr_context">Icona contestuale</string>
<string name="icon_descr_cancel_file_preview">Annulla anteprima file</string>
<string name="images_limit_title">Troppe immagini!</string>
<string name="image_decoding_exception_title">Errore di decodifica</string>
<string name="image_decoding_exception_desc">L\'immagine non può essere decodificata. Prova con un\'altra o contatta gli sviluppatori.</string>
<string name="image_descr">Immagine</string>
<string name="icon_descr_waiting_for_image">In attesa dell\'immagine</string>
<string name="icon_descr_asked_to_receive">Richiesta di ricezione immagine</string>
<string name="icon_descr_image_snd_complete">Immagine inviata</string>
<string name="image_saved">Immagine salvata nella Galleria</string>
<string name="icon_descr_file">File</string>
<string name="large_file">File grande!</string>
<string name="maximum_supported_file_size">Attualmente la dimensione massima supportata è di <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
<string name="waiting_for_file">In attesa del file</string>
<string name="file_will_be_received_when_contact_is_online">Il file verrà ricevuto quando il tuo contatto sarà in linea, aspetta o controlla più tardi!</string>
<string name="file_saved">File salvato</string>
<string name="file_not_found">File non trovato</string>
<string name="error_saving_file">Errore di salvataggio del file</string>
<string name="voice_message">Messaggio vocale</string>
<string name="voice_message_with_duration">Messaggio vocale (<xliff:g id="duration">%1$s</xliff:g>)</string>
<string name="voice_message_send_text">Messaggio vocale…</string>
<string name="notifications">Notifiche</string>
<string name="delete_contact_question">Eliminare il contatto\?</string>
<string name="button_delete_contact">Elimina contatto</string>
<string name="text_field_set_contact_placeholder">Imposta nome del contatto…</string>
<string name="icon_descr_server_status_connected">Connesso</string>
<string name="icon_descr_server_status_disconnected">Disconnesso</string>
<string name="icon_descr_server_status_error">Errore</string>
<string name="icon_descr_server_status_pending">In attesa</string>
<string name="switch_receiving_address_question">Cambiare l\'indirizzo di ricezione\?</string>
<string name="view_security_code">Vedi codice di sicurezza</string>
<string name="verify_security_code">Verifica codice di sicurezza</string>
<string name="icon_descr_send_message">Invia messaggio</string>
<string name="icon_descr_record_voice_message">Registra messaggio vocale</string>
<string name="allow_voice_messages_question">Permettere i messaggi vocali\?</string>
<string name="you_need_to_allow_to_send_voice">Devi consentire al tuo contatto di inviare messaggi vocali per poterli inviare anche tu.</string>
<string name="voice_messages_prohibited">Messaggi vocali vietati!</string>
<string name="ask_your_contact_to_enable_voice">Chiedi al tuo contatto di attivare l\'invio dei messaggi vocali.</string>
<string name="send_live_message">Invia messaggio in diretta</string>
<string name="live_message">Messaggio in diretta!</string>
<string name="send_verb">Invia</string>
<string name="back">Indietro</string>
<string name="cancel_verb">Annulla</string>
<string name="confirm_verb">Conferma</string>
<string name="reset_verb">Ripristina</string>
<string name="ok">OK</string>
<string name="connect_via_contact_link">Connettere via link del contatto\?</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="you_will_join_group">Entrerai in un gruppo a cui si riferisce questo link e ti connetterai ai suoi membri.</string>
<string name="connection_local_display_name">connessione <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
<string name="simplex_link_mode_description">Descrizione</string>
<string name="simplex_link_connection">via <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
<string name="simplex_link_mode_browser_warning">Aprire il link nel browser può ridurre la privacy e la sicurezza della connessione. I link SimpleX non fidati saranno in rosso.</string>
<string name="you_are_already_connected_to_vName_via_this_link">Sei già connesso a <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
<string name="connection_error_auth_desc">A meno che il tuo contatto non abbia eliminato la connessione o che questo link non sia già stato usato, potrebbe essere un errore; per favore segnalalo.
\nPer connetterti, chiedi al tuo contatto di creare un altro link di connessione e controlla di avere una connessione di rete stabile.</string>
<string name="error_smp_test_certificate">Probabilmente l\'impronta del certificato nell\'indirizzo del server è sbagliata</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Per rispettare la tua privacy, invece delle notifiche push l\'app ha un <b>servizio <xliff:g id="appName">SimpleX</xliff:g> in secondo piano</b>; usa una piccola percentuale di batteria al giorno.</string>
<string name="turn_off_battery_optimization">Per poterlo usare, <b>disattiva l\'ottimizzazione della batteria</b> per <xliff:g id="appName">SimpleX</xliff:g> nella prossima schermata. Altrimenti le notifiche saranno disattivate.</string>
<string name="simplex_service_notification_title">Servizio <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="notifications_mode_service_desc">Servizio in secondo piano sempre attivo. Le notifiche verranno mostrate appena i messaggi saranno disponibili.</string>
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Per proteggere le tue informazioni, attiva SimpleX Lock.
\nTi verrà chiesto di completare l\'autenticazione prima di attivare questa funzionalità.</string>
<string name="auth_simplex_lock_turned_on">SimpleX Lock attivo</string>
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Dovrai autenticarti quando avvii o riapri l\'app dopo 30 secondi in secondo piano.</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">L\'autenticazione del dispositivo non è attiva. Potrai attivare SimpleX Lock nelle impostazioni, quando avrai attivato l\'autenticazione del dispositivo.</string>
<string name="delete_message_mark_deleted_warning">Il messaggio verrà contrassegnato per l\'eliminazione. I destinatari potranno rivelare questo messaggio.</string>
<string name="share_message">Condividi messaggio…</string>
<string name="contact_sent_large_file">Il tuo contatto ha inviato un file più grande della dimensione massima supportata (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Il contatto e tutti i messaggi verranno eliminati, non è reversibile!</string>
<string name="switch_receiving_address_desc">Questa funzionalità è sperimentale! Funzionerà solo se l\'altro client ha la versione 4.2 installata. Dovresti vedere il messaggio nella conversazione una volta completato il cambio di indirizzo. Controlla di potere ancora ricevere messaggi da questo contatto (o membro del gruppo).</string>
<string name="only_group_owners_can_enable_voice">Solo i proprietari del gruppo possono attivare i messaggi vocali.</string>
<string name="send_live_message_desc">Invia un messaggio in diretta: si aggiornerà per i destinatari mentre lo digiti</string>
<string name="chat_item_ttl_day">1 giorno</string>
<string name="a_plus_b">a + b</string>
<string name="about_simplex_chat">Riguardo <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="group_member_role_admin">amministratore</string>
<string name="chat_item_ttl_week">1 settimana</string>
<string name="smp_servers_add_to_another_device">Aggiungi ad un altro dispositivo</string>
<string name="accept">Accetta</string>
<string name="v4_2_group_links_desc">Gli amministratori possono creare i link per entrare nei gruppi.</string>
<string name="allow_disappearing_messages_only_if">Consenti i messaggi a tempo solo se il tuo contatto li consente.</string>
<string name="allow_to_delete_messages">Permetti di eliminare irreversibilmente i messaggi inviati.</string>
<string name="allow_your_contacts_to_send_disappearing_messages">Permetti ai tuoi contatti di inviare messaggi a tempo.</string>
<string name="accept_requests">Accetta le richieste</string>
<string name="network_enable_socks_info">Accedere ai server via proxy SOCKS sulla porta 9050\? Il proxy deve essere avviato prima di attivare questa opzione.</string>
<string name="v4_3_improved_server_configuration_desc">Aggiungi server scansionando codici QR.</string>
<string name="all_group_members_will_remain_connected">Tutti i membri del gruppo resteranno connessi.</string>
<string name="allow_irreversible_message_deletion_only_if">Consenti l\'eliminazione irreversibile dei messaggi solo se il contatto la consente a te.</string>
<string name="above_then_preposition_continuation">sopra, quindi:</string>
<string name="accept_contact_button">Accetta</string>
<string name="accept_connection_request__question">Accettare la richiesta di connessione\?</string>
<string name="accept_contact_incognito_button">Accetta in incognito</string>
<string name="clear_chat_warning">Tutti i messaggi verranno eliminati, non è reversibile! I messaggi verranno eliminati SOLO per te.</string>
<string name="smp_servers_preset_add">Aggiungi server preimpostati</string>
<string name="smp_servers_add">Aggiungi server…</string>
<string name="network_settings">Impostazioni di rete avanzate</string>
<string name="about_simplex">Riguardo SimpleX</string>
<string name="callstatus_accepted">chiamata accettata</string>
<string name="accept_call_on_lock_screen">Accetta</string>
<string name="color_primary">Principale</string>
<string name="accept_feature">Accetta</string>
<string name="allow_voice_messages_only_if">Consenti i messaggi vocali solo se il tuo contatto li consente.</string>
<string name="allow_your_contacts_irreversibly_delete">Permetti ai tuoi contatti di eliminare irreversibilmente i messaggi inviati.</string>
<string name="allow_direct_messages">Permetti l\'invio di messaggi diretti ai membri.</string>
<string name="allow_to_send_disappearing">Permetti l\'invio di messaggi a tempo.</string>
<string name="allow_to_send_voice">Permetti l\'invio di messaggi vocali.</string>
<string name="chat_item_ttl_month">1 mese</string>
<string name="error_importing_database">Errore nell\'importazione del database della chat</string>
<string name="group_full_name_field">Nome completo del gruppo:</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Se non potete incontrarvi di persona, puoi <b>scansionare il codice QR nella videochiamata</b>, oppure il tuo contatto può condividere un link di invito.</string>
<string name="full_backup">Backup dei dati dell\'app</string>
<string name="keychain_is_storing_securely">Android Keystore è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica.</string>
<string name="allow_your_contacts_to_send_voice_messages">Permetti ai tuoi contatti di inviare messaggi vocali.</string>
<string name="chat_database_deleted">Database della chat eliminato</string>
<string name="settings_section_title_icon">ICONA APP</string>
<string name="incognito_random_profile_from_contact_description">Verrà inviato un profilo casuale al contatto da cui hai ricevuto questo link</string>
<string name="incognito_random_profile_description">Verrà inviato un profilo casuale al tuo contatto</string>
<string name="onboarding_notifications_mode_off_desc"><b>Ideale per la batteria</b>. Riceverai notifiche solo quando l\'app è in esecuzione, il servizio in secondo piano NON verrà usato.</string>
<string name="onboarding_notifications_mode_service_desc"><b>Consuma più batteria</b>! Il servizio in secondo piano è sempre attivo: le notifiche verranno mostrate non appena i messaggi saranno disponibili.</string>
<string name="callstatus_calling">chiamata…</string>
<string name="icon_descr_cancel_link_preview">annulla anteprima link</string>
<string name="cannot_access_keychain">Impossibile accedere al Keystore per salvare la password del database</string>
<string name="alert_title_cant_invite_contacts">Impossibile invitare i contatti!</string>
<string name="change_role">Cambia ruolo</string>
<string name="chat_archive_section">ARCHIVIO CHAT</string>
<string name="snd_conn_event_switch_queue_phase_changing">cambio indirizzo…</string>
<string name="chat_is_stopped">Chat fermata</string>
<string name="group_member_status_introduced">connessione (presentato)</string>
<string name="contact_requests">Richieste del contatto</string>
<string name="connection_request_sent">Richiesta di connessione inviata!</string>
<string name="delete_link_question">Eliminare il link\?</string>
<string name="delete_link">Elimina link</string>
<string name="create_address">Crea indirizzo</string>
<string name="button_create_group_link">Crea link</string>
<string name="database_encryption_will_be_updated">La password di crittografia del database verrà aggiornata e conservata nel Keystore.</string>
<string name="encrypted_with_random_passphrase">Il database è crittografato con una password casuale, puoi cambiarla.</string>
<string name="database_passphrase_is_required">La password del database è necessaria per aprire la chat.</string>
<string name="delete_group_menu_action">Elimina</string>
<string name="direct_messages_are_prohibited_in_chat">I messaggi diretti tra i membri sono vietati in questo gruppo.</string>
<string name="display_name">Nome da mostrare</string>
<string name="add_new_contact_to_create_one_time_QR_code"><b>Aggiungi un contatto</b>: per creare il tuo codice QR una tantum per il tuo contatto.</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Scansiona codice QR</b>: per connetterti al contatto che ti mostra il codice QR.</string>
<string name="choose_file">Scegli file</string>
<string name="clear_chat_button">Svuota chat</string>
<string name="clear_chat_question">Svuotare la chat\?</string>
<string name="clear_verb">Svuota</string>
<string name="connect_via_link_or_qr">Connetti via link / codice QR</string>
<string name="copied">Copiato negli appunti</string>
<string name="share_one_time_link">Crea link di invito una tantum</string>
<string name="create_group">Crea gruppo segreto</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 desktop: scansiona dall\'app il codice QR mostrato, tramite <b>Scansiona codice QR</b>.</string>
<string name="from_gallery_button">Dalla Galleria</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Se scegli di rifiutare, il mittente NON verrà avvisato.</string>
<string name="clear_chat_menu_action">Svuota</string>
<string name="icon_descr_close_button">Pulsante di chiusura</string>
<string name="alert_title_contact_connection_pending">Il contatto non è ancora connesso!</string>
<string name="delete_contact_menu_action">Elimina</string>
<string name="delete_pending_connection__question">Eliminare la connessione in attesa\?</string>
<string name="icon_descr_email">Email</string>
<string name="icon_descr_help">aiuto</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Se non potete incontrarvi di persona, <b>mostra il codice QR nella videochiamata</b>, oppure condividi il link.</string>
<string name="chat_console">Console della chat</string>
<string name="clear_verification">Annulla la verifica</string>
<string name="connect_button">Connetti</string>
<string name="connect_via_link">Connetti via link</string>
<string name="create_one_time_link">Crea link di invito una tantum</string>
<string name="database_passphrase_and_export">Password del database ed esportazione</string>
<string name="smp_servers_enter_manually">Inserisci il server manualmente</string>
<string name="how_to_use_simplex_chat">Come si usa</string>
<string name="all_your_contacts_will_remain_connected">Tutti i tuoi contatti resteranno connessi.</string>
<string name="appearance_settings">Aspetto</string>
<string name="smp_servers_check_address">Controlla l\'indirizzo del server e riprova.</string>
<string name="configure_ICE_servers">Configura server ICE</string>
<string name="contribute">Contribuisci</string>
<string name="delete_address">Elimina indirizzo</string>
<string name="delete_address__question">Eliminare l\'indirizzo\?</string>
<string name="smp_servers_delete_server">Elimina server</string>
<string name="error_saving_ICE_servers">Errore nel salvataggio dei server ICE</string>
<string name="how_to">Come si fa</string>
<string name="how_to_use_your_servers">Come usare i tuoi server</string>
<string name="enter_one_ICE_server_per_line">Server ICE (uno per riga)</string>
<string name="accept_automatically">Automaticamente</string>
<string name="bold">grassetto</string>
<string name="callstatus_ended">chiamata terminata <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstatus_error">errore di chiamata</string>
<string name="callstatus_in_progress">chiamata in corso</string>
<string name="colored">colorato</string>
<string name="callstate_connected">connesso</string>
<string name="callstate_connecting">connessione…</string>
<string name="callstatus_connecting">connessione chiamata…</string>
<string name="create_profile_button">Crea</string>
<string name="create_profile">Crea profilo</string>
<string name="delete_image">Elimina immagine</string>
<string name="display_name__field">Nome da mostrare:</string>
<string name="display_name_cannot_contain_whitespace">Il nome da mostrare non può contenere spazi.</string>
<string name="edit_image">Modifica immagine</string>
<string name="exit_without_saving">Esci senza salvare</string>
<string name="full_name__field">Nome completo:</string>
<string name="full_name_optional__prompt">Nome completo (facoltativo)</string>
<string name="how_to_use_markdown">Come usare il markdown</string>
<string name="icon_descr_audio_call">chiamata audio</string>
<string name="audio_call_no_encryption">chiamata audio (non crittografata e2e)</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Buono per la batteria</b>. Il servizio in secondo piano controlla nuovi messaggi ogni 10 minuti. Potresti perdere chiamate e messaggi urgenti.</string>
<string name="call_already_ended">Chiamata già terminata!</string>
<string name="create_your_profile">Crea il tuo profilo</string>
<string name="decentralized">Decentralizzato</string>
<string name="encrypted_audio_call">Chiamata crittografata e2e</string>
<string name="encrypted_video_call">Videochiamata crittografata e2e</string>
<string name="callstate_ended">terminata</string>
<string name="how_it_works">Come funziona</string>
<string name="how_simplex_works">Come funziona <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="answer_call">Rispondi alla chiamata</string>
<string name="icon_descr_audio_off">Audio spento</string>
<string name="icon_descr_audio_on">Audio acceso</string>
<string name="settings_audio_video_calls">Chiamate audio e video</string>
<string name="auto_accept_images">Auto-accetta immagini</string>
<string name="integrity_msg_bad_hash">hash del messaggio errato</string>
<string name="integrity_msg_bad_id">ID messaggio errato</string>
<string name="icon_descr_call_ended">Chiamata terminata</string>
<string name="icon_descr_call_progress">Chiamata in corso</string>
<string name="call_on_lock_screen">Chiamate sulla schermata di blocco:</string>
<string name="icon_descr_call_connecting">Connessione chiamata</string>
<string name="connect_calls_via_relay">Connetti via relay</string>
<string name="status_contact_has_e2e_encryption">il contatto ha la crittografia e2e</string>
<string name="status_contact_has_no_e2e_encryption">il contatto non ha la crittografia e2e</string>
<string name="no_call_on_lock_screen">Disattiva</string>
<string name="integrity_msg_duplicate">messaggio duplicato</string>
<string name="status_e2e_encrypted">crittografato e2e</string>
<string name="allow_accepting_calls_from_lock_screen">Attiva le chiamate dalla schermata di blocco tramite le impostazioni.</string>
<string name="icon_descr_flip_camera">Fotocamera frontale/posteriore</string>
<string name="icon_descr_hang_up">Riaggancia</string>
<string name="settings_section_title_calls">CHIAMATE</string>
<string name="chat_database_section">DATABASE DELLA CHAT</string>
<string name="chat_database_imported">Database della chat importato</string>
<string name="chat_is_running">Chat in esecuzione</string>
<string name="settings_section_title_chats">CHAT</string>
<string name="set_password_to_export_desc">Il database è crittografato con una password casuale. Cambiala prima di esportare.</string>
<string name="database_passphrase">Password del database</string>
<string name="delete_chat_profile_question">Eliminare il profilo di chat\?</string>
<string name="delete_database">Elimina database</string>
<string name="settings_section_title_develop">SVILUPPA</string>
<string name="settings_developer_tools">Strumenti di sviluppo</string>
<string name="settings_section_title_device">DISPOSITIVO</string>
<string name="error_deleting_database">Errore nell\'eliminazione del database della chat</string>
<string name="error_exporting_chat_database">Errore nell\'esportazione del database della chat</string>
<string name="error_starting_chat">Errore nell\'avvio della chat</string>
<string name="error_stopping_chat">Errore nell\'interruzione della chat</string>
<string name="settings_experimental_features">Funzionalità sperimentali</string>
<string name="export_database">Esporta database</string>
<string name="settings_section_title_help">AIUTO</string>
<string name="chat_archive_header">Archivio chat</string>
<string name="chat_is_stopped_indication">Chat fermata</string>
<string name="archive_created_on_ts">Creato il <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="database_error">Errore del database</string>
<string name="passphrase_is_different">La password del database è diversa da quella salvata nel Keystore.</string>
<string name="delete_archive">Elimina archivio</string>
<string name="delete_chat_archive_question">Eliminare l\'archivio della chat\?</string>
<string name="encrypted_database">Database crittografato</string>
<string name="enter_correct_passphrase">Inserisci la password giusta.</string>
<string name="enter_passphrase">Inserisci la password…</string>
<string name="error_with_info">Errore: %s</string>
<string name="file_with_path">File: %s</string>
<string name="icon_descr_group_inactive">Gruppo inattivo</string>
<string name="rcv_conn_event_switch_queue_phase_completed">indirizzo cambiato per te</string>
<string name="rcv_group_event_changed_member_role">cambiato il ruolo di %s in %s</string>
<string name="rcv_group_event_changed_your_role">cambiato il tuo ruolo in %s</string>
<string name="rcv_conn_event_switch_queue_phase_changing">cambio indirizzo…</string>
<string name="snd_conn_event_switch_queue_phase_changing_for_member">cambio indirizzo per %s…</string>
<string name="rcv_group_event_member_connected">connesso</string>
<string name="group_member_status_connected">connesso</string>
<string name="group_member_status_accepted">connessione (accettato)</string>
<string name="group_member_status_announced">connessione (annunciato)</string>
<string name="group_member_status_intro_invitation">connessione (invito di presentazione)</string>
<string name="rcv_group_event_group_deleted">gruppo eliminato</string>
<string name="group_member_status_group_deleted">gruppo eliminato</string>
<string name="group_invitation_expired">Invito al gruppo scaduto</string>
<string name="alert_message_group_invitation_expired">L\'invito al gruppo non è più valido, è stato rimosso dal mittente.</string>
<string name="alert_title_no_group">Gruppo non trovato!</string>
<string name="snd_group_event_group_profile_updated">profilo del gruppo aggiornato</string>
<string name="invite_prohibited">Impossibile invitare il contatto!</string>
<string name="change_verb">Cambia</string>
<string name="change_member_role_question">Cambiare il ruolo del gruppo\?</string>
<string name="clear_contacts_selection_button">Svuota</string>
<string name="group_member_status_complete">completo</string>
<string name="group_member_status_connecting">connessione</string>
<string name="icon_descr_contact_checked">Contatto controllato</string>
<string name="create_group_link">Crea link del gruppo</string>
<string name="group_member_status_creator">creatore</string>
<string name="info_row_database_id">ID database</string>
<string name="button_delete_group">Elimina gruppo</string>
<string name="delete_group_question">Eliminare il gruppo\?</string>
<string name="button_edit_group_profile">Modifica il profilo del gruppo</string>
<string name="error_creating_link_for_group">Errore nella creazione del link del gruppo</string>
<string name="error_deleting_link_for_group">Errore nell\'eliminazione del link del gruppo</string>
<string name="icon_descr_expand_role">Espandi la selezione dei ruoli</string>
<string name="section_title_for_console">PER CONSOLE</string>
<string name="group_link">Link del gruppo</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Il gruppo verrà eliminato per tutti i membri. Non è reversibile!</string>
<string name="delete_group_for_self_cannot_undo_warning">Il gruppo verrà eliminato per te. Non è reversibile!</string>
<string name="info_row_connection">Connessione</string>
<string name="create_secret_group_title">Crea gruppo segreto</string>
<string name="conn_level_desc_direct">diretta</string>
<string name="network_option_enable_tcp_keep_alive">Attiva il keep-alive TCP</string>
<string name="error_changing_role">Errore nel cambio di ruolo</string>
<string name="error_removing_member">Errore nella rimozione del membro</string>
<string name="error_saving_group_profile">Errore nel salvataggio del profilo del gruppo</string>
<string name="info_row_group">Gruppo</string>
<string name="group_display_name_field">Nome da mostrare del gruppo:</string>
<string name="group_profile_is_stored_on_members_devices">Il profilo del gruppo è memorizzato sui dispositivi dei membri, non sui server.</string>
<string name="chat_preferences_always">sempre</string>
<string name="both_you_and_your_contacts_can_delete">Sia tu che il tuo contatto potete eliminare irreversibilmente i messaggi inviati.</string>
<string name="both_you_and_your_contact_can_send_disappearing">Sia tu che il tuo contatto potete inviare messaggi a tempo.</string>
<string name="both_you_and_your_contact_can_send_voice">Sia tu che il tuo contatto potete inviare messaggi vocali.</string>
<string name="chat_preferences">Preferenze della chat</string>
<string name="chat_preferences_contact_allows">Il contatto lo consente</string>
<string name="contact_preferences">Preferenze del contatto</string>
<string name="contacts_can_mark_messages_for_deletion">I contatti possono contrassegnare i messaggi per l\'eliminazione; potrai vederli.</string>
<string name="theme_dark">Scuro</string>
<string name="chat_preferences_default">predefinito (%s)</string>
<string name="full_deletion">Elimina per tutti</string>
<string name="direct_messages">Messaggi diretti</string>
<string name="timed_messages">Messaggi a tempo</string>
<string name="disappearing_prohibited_in_this_chat">I messaggi a tempo sono vietati in questa conversazione.</string>
<string name="feature_enabled">attivato</string>
<string name="feature_enabled_for_contact">attivato per il contatto</string>
<string name="feature_enabled_for_you">attivato per te</string>
<string name="group_preferences">Preferenze del gruppo</string>
<string name="v4_2_auto_accept_contact_requests">Auto-accetta richieste di contatto</string>
<string name="ttl_d">%dg</string>
<string name="ttl_day">%d giorno</string>
<string name="ttl_days">%d giorni</string>
<string name="delete_after">Elimina dopo</string>
<string name="ttl_h">%do</string>
<string name="ttl_hour">%d ora</string>
<string name="ttl_hours">%d ore</string>
<string name="disappearing_messages_are_prohibited">I messaggi a tempo sono vietati in questo gruppo.</string>
<string name="ttl_m">%dm</string>
<string name="ttl_min">%d min</string>
<string name="ttl_month">%d mese</string>
<string name="ttl_months">%d mesi</string>
<string name="ttl_mth">%dmese</string>
<string name="ttl_s">%ds</string>
<string name="ttl_sec">%d sec</string>
<string name="ttl_w">%dset</string>
<string name="ttl_week">%d settimana</string>
<string name="ttl_weeks">%d settimane</string>
<string name="v4_2_group_links">Link del gruppo</string>
<string name="group_members_can_delete">I membri del gruppo possono eliminare irreversibilmente i messaggi inviati.</string>
<string name="group_members_can_send_dms">I membri del gruppo possono inviare messaggi diretti.</string>
<string name="group_members_can_send_disappearing">I membri del gruppo possono inviare messaggi a tempo.</string>
<string name="group_members_can_send_voice">I membri del gruppo possono inviare messaggi vocali.</string>
<string name="v4_4_verify_connection_security_desc">Confronta i codici di sicurezza con i tuoi contatti.</string>
<string name="v4_4_disappearing_messages">Messaggi a tempo</string>
<string name="v4_3_improved_privacy_and_security_desc">Nascondi la schermata dell\'app nelle app recenti.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore verrà usato per memorizzare in modo sicuro la password dopo il riavvio dell\'app o la modifica della password; consentirà di ricevere le notifiche.</string>
<string name="impossible_to_recover_passphrase"><b>Nota bene</b>: NON potrai recuperare o cambiare la password se la perdi.</string>
<string name="change_database_passphrase_question">Cambiare password del database\?</string>
<string name="confirm_new_passphrase">Conferma password nuova…</string>
<string name="current_passphrase">Password attuale…</string>
<string name="database_encrypted">Database crittografato!</string>
<string name="database_passphrase_will_be_updated">La password di crittografia del database verrà aggiornata.</string>
<string name="database_will_be_encrypted">Il database verrà crittografato.</string>
<string name="database_will_be_encrypted_and_passphrase_stored">Il database verrà crittografato e la password conservata nel Keystore.</string>
<string name="delete_files_and_media_question">Eliminare i file e i multimediali\?</string>
<string name="delete_messages">Elimina messaggi</string>
<string name="delete_messages_after">Elimina messaggi dopo</string>
<string name="total_files_count_and_size">%d file con dimensione totale di %s</string>
<string name="enable_automatic_deletion_question">Attivare l\'eliminazione automatica dei messaggi\?</string>
<string name="encrypt_database_question">Crittografare il database\?</string>
<string name="encrypt_database">Crittografare</string>
<string name="error_changing_message_deletion">Errore nella modifica dell\'impostazione</string>
<string name="error_encrypting_database">Errore nella crittografia del database</string>
<string name="your_settings">Le tue impostazioni</string>
<string name="you_will_be_connected_when_group_host_device_is_online">Verrai connesso/a al gruppo quando il dispositivo dell\'host del gruppo sarà in linea, attendi o controlla più tardi!</string>
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Se hai ricevuto il link di invito a <xliff:g id="appName">SimpleX Chat</xliff:g>, puoi aprirlo nel tuo browser:</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 mobile: tocca <b>Apri nell\'app mobile</b>, quindi <b>Connetti</b> nell\'app.</string>
<string name="no_details">nessun dettaglio</string>
<string name="add_contact">Link di invito una tantum</string>
<string name="only_stored_on_members_devices">(memorizzato solo dai membri del gruppo)</string>
<string name="toast_permission_denied">Autorizzazione negata!</string>
<string name="reject_contact_button">Rifiuta</string>
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(scansiona o incolla dagli appunti)</string>
<string name="scan_QR_code">Scansiona codice QR</string>
<string name="add_contact_or_create_group">Inizia una nuova conversazione</string>
<string name="chat_help_tap_button">Tocca il pulsante</string>
<string name="thank_you_for_installing_simplex">Grazie per aver installato <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
<string name="to_connect_via_link_title">Per connettersi via link</string>
<string name="to_share_with_your_contact">(da condividere con il tuo contatto)</string>
<string name="to_start_a_new_chat_help_header">Per iniziare una nuova chat</string>
<string name="use_camera_button">Usa la fotocamera</string>
<string name="you_can_connect_to_simplex_chat_founder">Puoi <font color="#0088ff">connetterti con gli sviluppatori di <xliff:g id="appNameFull">SimpleX Chat</xliff:g> per porre domande e ricevere aggiornamenti</font>.</string>
<string name="invalid_contact_link">Link non valido!</string>
<string name="invalid_QR_code">Codice QR non valido</string>
<string name="image_descr_link_preview">immagine di anteprima link</string>
<string name="mark_read">Segna come già letto</string>
<string name="mark_unread">Segna come non letto</string>
<string name="icon_descr_more_button">Altro</string>
<string name="mute_chat">Silenzia</string>
<string name="image_descr_profile_image">immagine del profilo</string>
<string name="icon_descr_profile_image_placeholder">segnaposto immagine del profilo</string>
<string name="image_descr_qr_code">Codice QR</string>
<string name="set_contact_name">Imposta il nome del contatto</string>
<string name="icon_descr_settings">Impostazioni</string>
<string name="show_QR_code">Mostra codice QR</string>
<string name="connection_you_accepted_will_be_cancelled">La connessione che hai accettato verrà annullata!</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Il contatto con cui hai condiviso questo link NON sarà in grado di connettersi!</string>
<string name="this_link_is_not_a_valid_connection_link">Questo non è un link di connessione valido!</string>
<string name="this_QR_code_is_not_a_link">Questo codice QR non è un link!</string>
<string name="unmute_chat">Riattiva audio</string>
<string name="contact_wants_to_connect_with_you">vuole connettersi con te!</string>
<string name="image_descr_simplex_logo">Logo di <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_address">Indirizzo di <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_simplex_team">Squadra di <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="you_accepted_connection">Hai accettato la connessione</string>
<string name="you_invited_your_contact">Hai invitato il contatto</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Il tuo profilo di chat verrà inviato
\nal tuo contatto</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Il tuo contatto può scansionare il codice QR dall\'app.</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Il tuo contatto deve essere in linea per completare la connessione.
\nPuoi annullare questa connessione e rimuovere il contatto (e riprovare più tardi con un link nuovo).</string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Verrai connesso/a quando la tua richiesta di connessione verrà accettata, attendi o controlla più tardi!</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">Verrai connesso/a quando il dispositivo del tuo contatto sarà in linea, attendi o controlla più tardi!</string>
<string name="incorrect_code">Codice di sicurezza sbagliato!</string>
<string name="smp_servers_invalid_address">Indirizzo del server non valido!</string>
<string name="markdown_help">Aiuto sul markdown</string>
<string name="markdown_in_messages">Markdown nei messaggi</string>
<string name="mark_code_verified">Segna come verificato/a</string>
<string name="one_time_link">Link di invito una tantum</string>
<string name="paste_button">Incolla</string>
<string name="paste_connection_link_below_to_connect">Incolla il link che hai ricevuto nella casella sottostante per connetterti con il tuo contatto.</string>
<string name="smp_servers_preset_server">Server preimpostato</string>
<string name="smp_servers_preset_address">Indirizzo server preimpostato</string>
<string name="smp_servers_save">Salva i server</string>
<string name="scan_code">Scansiona codice</string>
<string name="scan_code_from_contacts_app">Scansiona il codice di sicurezza dall\'app del tuo contatto.</string>
<string name="smp_servers_scan_qr">Scansiona codice QR del server</string>
<string name="security_code">Codice di sicurezza</string>
<string name="chat_with_the_founder">Invia domande e idee</string>
<string name="send_us_an_email">Inviaci un\'email</string>
<string name="smp_servers_test_failed">Test del server fallito!</string>
<string name="share_invitation_link">Condividi link di invito</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="is_not_verified">%s non è verificato/a</string>
<string name="is_verified">%s è verificato/a</string>
<string name="smp_servers">Server SMP</string>
<string name="smp_servers_test_some_failed">Alcuni server hanno fallito il test:</string>
<string name="smp_servers_test_server">Testa server</string>
<string name="smp_servers_test_servers">Testa i server</string>
<string name="this_string_is_not_a_connection_link">Questa stringa non è un link di connessione!</string>
<string name="to_verify_compare">Per verificare la crittografia end-to-end con il tuo contatto, confrontate (o scansionate) il codice sui vostri dispositivi.</string>
<string name="smp_servers_use_server_for_new_conn">Usa per connessioni nuove</string>
<string name="smp_servers_use_server">Usa il server</string>
<string name="you_can_also_connect_by_clicking_the_link">Puoi anche connetterti cliccando il link. Se si apre nel browser, clicca il pulsante <b>Apri nell\'app mobile</b>.</string>
<string name="your_profile_will_be_sent">Il tuo profilo di chat verrà inviato al tuo contatto</string>
<string name="your_contact_address">Il tuo indirizzo di contatto</string>
<string name="smp_servers_your_server">Il tuo server</string>
<string name="smp_servers_your_server_address">L\'indirizzo del tuo server</string>
<string name="your_simplex_contact_address">Il tuo indirizzo di contatto di <xliff:g id="appName">SimpleX</xliff:g>.</string>
<string name="network_disable_socks_info">Se confermi, i server di messaggistica saranno in grado di vedere il tuo indirizzo IP e il tuo fornitore, a quali server ti stai connettendo.</string>
<string name="install_simplex_chat_for_terminal">Installa <xliff:g id="appNameFull">SimpleX Chat</xliff:g> per terminale</string>
<string name="ensure_ICE_server_address_are_correct_format_and_unique">Assicurati che gli indirizzi dei server WebRTC ICE siano nel formato corretto, uno per riga e non doppi.</string>
<string name="network_and_servers">Rete e server</string>
<string name="network_settings_title">Impostazioni di rete</string>
<string name="network_use_onion_hosts_no">No</string>
<string name="network_use_onion_hosts_required_desc">Gli host Onion saranno necessari per la connessione.</string>
<string name="network_use_onion_hosts_required_desc_in_alert">Gli host Onion saranno necessari per la connessione.</string>
<string name="network_use_onion_hosts_prefer_desc">Gli host Onion verranno usati quando disponibili.</string>
<string name="network_use_onion_hosts_prefer_desc_in_alert">Gli host Onion verranno usati quando disponibili.</string>
<string name="network_use_onion_hosts_no_desc">Gli host Onion non verranno usati.</string>
<string name="network_use_onion_hosts_no_desc_in_alert">Gli host Onion non verranno usati.</string>
<string name="rate_the_app">Valuta l\'app</string>
<string name="network_use_onion_hosts_required">Obbligatorio</string>
<string name="save_servers_button">Salva</string>
<string name="saved_ICE_servers_will_be_removed">I server WebRTC ICE salvati verranno rimossi.</string>
<string name="share_link">Condividi link</string>
<string name="star_on_github">Stella su GitHub</string>
<string name="update_onion_hosts_settings_question">Aggiornare l\'impostazione degli host .onion\?</string>
<string name="network_disable_socks">Usare una connessione internet diretta\?</string>
<string name="network_use_onion_hosts">Usa gli host .onion</string>
<string name="network_enable_socks">Usare il proxy SOCKS\?</string>
<string name="network_socks_toggle">Usa il proxy SOCKS (porta 9050)</string>
<string name="use_simplex_chat_servers__question">Usare i server di <xliff:g id="appNameFull">SimpleX Chat</xliff:g>\?</string>
<string name="using_simplex_chat_servers">Stai usando i server di <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<string name="network_use_onion_hosts_prefer">Quando disponibili</string>
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Puoi condividere il tuo indirizzo come link o come codice QR: chiunque potrà connettersi a te. Non perderai i tuoi contatti se in seguito lo elimini.</string>
<string name="your_ICE_servers">I tuoi server ICE</string>
<string name="your_SMP_servers">I tuoi server SMP</string>
<string name="italic">corsivo</string>
<string name="callstatus_missed">chiamata persa</string>
<string name="callstate_received_answer">risposta ricevuta…</string>
<string name="callstate_received_confirmation">conferma ricevuta…</string>
<string name="callstatus_rejected">chiamata rifiutata</string>
<string name="save_and_notify_contact">Salva e avvisa il contatto</string>
<string name="save_and_notify_contacts">Salva e avvisa i contatti</string>
<string name="save_and_notify_group_members">Salva e avvisa i membri del gruppo</string>
<string name="save_preferences_question">Salvare le preferenze\?</string>
<string name="secret">segreto</string>
<string name="callstate_starting">avvio…</string>
<string name="strikethrough">barrato</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">La piattaforma di messaggistica che protegge la tua privacy e sicurezza.</string>
<string name="profile_is_only_shared_with_your_contacts">Il profilo è condiviso solo con i tuoi contatti.</string>
<string name="callstate_waiting_for_answer">in attesa di risposta…</string>
<string name="callstate_waiting_for_confirmation">in attesa di conferma…</string>
<string name="we_do_not_store_contacts_or_messages_on_servers">Non memorizziamo nessuno dei tuoi contatti o messaggi (una volta recapitati) sui server.</string>
<string name="section_title_welcome_message">MESSAGGIO DI BENVENUTO</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Puoi usare il markdown per formattare i messaggi:</string>
<string name="you_control_your_chat">Sei tu a controllare la tua chat!</string>
<string name="your_current_profile">Il tuo profilo attuale</string>
<string name="your_profile_is_stored_on_your_device">Il tuo profilo, i contatti e i messaggi recapitati sono memorizzati sul tuo dispositivo.</string>
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Il tuo profilo è memorizzato sul tuo dispositivo e condiviso solo con i tuoi contatti.
\n
\nI server di <xliff:g id="appName">SimpleX</xliff:g> non possono vedere il tuo profilo.</string>
<string name="ignore">Ignora</string>
<string name="immune_to_spam_and_abuse">Immune a spam e abusi</string>
<string name="incoming_audio_call">Chiamata in arrivo</string>
<string name="incoming_video_call">Videochiamata in arrivo</string>
<string name="onboarding_notifications_mode_service">Istantaneo</string>
<string name="onboarding_notifications_mode_subtitle">Può essere cambiato in seguito via impostazioni.</string>
<string name="make_private_connection">Crea una connessione privata</string>
<string name="many_people_asked_how_can_it_deliver">Molte persone hanno chiesto: <i>se <xliff:g id="appName">SimpleX</xliff:g> non ha identificatori utente, come può recapitare i messaggi\?</i></string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Solo i dispositivi client memorizzano i profili utente, i contatti, i gruppi e i messaggi inviati con <b>crittografia end-to-end a 2 livelli</b>.</string>
<string name="opensource_protocol_and_code_anybody_can_run_servers">Protocollo e codice open source: chiunque può gestire i server.</string>
<string name="paste_the_link_you_received">Incolla il link ricevuto</string>
<string name="people_can_connect_only_via_links_you_share">Le persone possono connettersi a te solo tramite i link che condividi.</string>
<string name="onboarding_notifications_mode_periodic">Periodico</string>
<string name="privacy_redefined">Privacy ridefinita</string>
<string name="onboarding_notifications_mode_title">Notifiche private</string>
<string name="read_more_in_github_with_link">Maggiori informazioni nel nostro <font color="#0088ff">repository GitHub</font>.</string>
<string name="read_more_in_github">Maggiori informazioni nel nostro repository GitHub.</string>
<string name="reject">Rifiuta</string>
<string name="first_platform_without_user_ids">La prima piattaforma senza alcun identificatore utente privata by design.</string>
<string name="next_generation_of_private_messaging">La nuova generazione di messaggistica privata</string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Per proteggere la privacy, invece degli ID utente usati da tutte le altre piattaforme, <xliff:g id="appName">SimpleX</xliff:g> dispone di identificatori per le code dei messaggi, separati per ciascuno dei tuoi contatti.</string>
<string name="use_chat">Usa la chat</string>
<string name="icon_descr_video_call">videochiamata</string>
<string name="video_call_no_encryption">videochiamata (non crittografata e2e)</string>
<string name="onboarding_notifications_mode_off">Quando l\'app è in esecuzione</string>
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> vuole connettersi con te via</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Puoi controllare attraverso quale/i server <b>ricevere</b> i messaggi, i tuoi contatti i server che usi per inviare loro i messaggi.</string>
<string name="alert_text_skipped_messages_it_can_happen_when">Può accadere quando:
\n1. I messaggi scadono sul server se non sono stati ricevuti per 30 giorni,
\n2. Il server usato per ricevere i messaggi da questo contatto è stato aggiornato e riavviato.
\n3. La connessione è compromessa.
\nConnettiti agli sviluppatori tramite Impostazioni per ricevere aggiornamenti riguardo i server.
\nAggiungeremo la ridondanza del server per prevenire la perdita di messaggi.</string>
<string name="icon_descr_call_rejected">Chiamata rifiutata</string>
<string name="icon_descr_call_missed">Chiamata persa</string>
<string name="status_no_e2e_encryption">nessuna crittografia e2e</string>
<string name="open_verb">Apri</string>
<string name="open_simplex_chat_to_accept_call">Apri <xliff:g id="appNameFull">SimpleX Chat</xliff:g> per accettare la chiamata</string>
<string name="call_connection_peer_to_peer">peer-to-peer</string>
<string name="icon_descr_call_pending_sent">Chiamata in sospeso</string>
<string name="privacy_and_security">Privacy e sicurezza</string>
<string name="protect_app_screen">Proteggi la schermata dell\'app</string>
<string name="show_call_on_lock_screen">Mostra</string>
<string name="alert_title_skipped_messages">Messaggi saltati</string>
<string name="icon_descr_speaker_off">Altoparlante spento</string>
<string name="icon_descr_speaker_on">Altoparlante acceso</string>
<string name="call_connection_via_relay">via relay</string>
<string name="icon_descr_video_off">Video off</string>
<string name="icon_descr_video_on">Video on</string>
<string name="webrtc_ice_servers">Server WebRTC ICE</string>
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> messaggio/i saltato/i</string>
<string name="your_calls">Le tue chiamate</string>
<string name="your_ice_servers">I tuoi server ICE</string>
<string name="your_privacy">La tua privacy</string>
<string name="import_database_confirmation">Importa</string>
<string name="import_database_question">Importare il database della chat\?</string>
<string name="import_database">Importa database</string>
<string name="settings_section_title_incognito">Modalità incognito</string>
<string name="settings_section_title_messages">MESSAGGI</string>
<string name="new_database_archive">Nuovo archivio database</string>
<string name="old_database_archive">Vecchio archivio del database</string>
<string name="restart_the_app_to_create_a_new_chat_profile">Riavvia l\'app per creare un profilo di chat nuovo.</string>
<string name="restart_the_app_to_use_imported_chat_database">Riavvia l\'app per usare il database della chat importato.</string>
<string name="run_chat_section">AVVIA CHAT</string>
<string name="send_link_previews">Invia anteprime dei link</string>
<string name="set_password_to_export">Imposta la password per esportare</string>
<string name="settings_section_title_settings">IMPOSTAZIONI</string>
<string name="settings_section_title_socks">PROXY SOCKS</string>
<string name="stop_chat_confirmation">Ferma</string>
<string name="stop_chat_question">Fermare la chat\?</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Ferma la chat per esportare, importare o eliminare il database della chat. Non potrai ricevere e inviare messaggi mentre la chat è ferma.</string>
<string name="settings_section_title_support">SUPPORTA SIMPLEX CHAT</string>
<string name="settings_section_title_themes">TEMI</string>
<string name="delete_chat_profile_action_cannot_be_undone_warning">Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile.</string>
<string name="transfer_images_faster">Trasferisci immagini più velocemente</string>
<string name="settings_section_title_you">TU</string>
<string name="your_chat_database">Il tuo database della chat</string>
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Il tuo attuale database di chat verrà ELIMINATO e SOSTITUITO con quello importato.
\nQuesta azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile.</string>
<string name="alert_title_group_invitation_expired">Invito scaduto!</string>
<string name="group_invitation_item_description">invito al gruppo <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="icon_descr_add_members">Invita membri</string>
<string name="join_group_button">Entra</string>
<string name="join_group_question">Entrare nel gruppo\?</string>
<string name="join_group_incognito_button">Entra in incognito</string>
<string name="joining_group">Ingresso nel gruppo</string>
<string name="keychain_error">Errore del portachiavi</string>
<string name="leave_group_button">Esci</string>
<string name="leave_group_question">Uscire dal gruppo\?</string>
<string name="open_chat">Apri chat</string>
<string name="restore_passphrase_not_found_desc">Password non trovata nel Keystore, inseriscila a mano. Potrebbe essere successo se hai ripristinato i dati dell\'app usando uno strumento di backup. In caso contrario, contatta gli sviluppatori.</string>
<string name="restore_database_alert_desc">Inserisci la password precedente dopo aver ripristinato il backup del database. Questa azione non può essere annullata.</string>
<string name="store_passphrase_securely_without_recover">Conserva la password in modo sicuro, NON potrai accedere alla chat se la perdi.</string>
<string name="restore_database_alert_confirm">Ripristina</string>
<string name="restore_database">Ripristina backup del database</string>
<string name="restore_database_alert_title">Ripristinare il backup del database\?</string>
<string name="database_restore_error">Errore di ripristino del database</string>
<string name="save_archive">Salva archivio</string>
<string name="save_passphrase_and_open_chat">Salva la password e apri la chat</string>
<string name="database_backup_can_be_restored">Il tentativo di cambiare la password del database non è stato completato.</string>
<string name="unknown_database_error_with_info">Errore del database sconosciuto: %s</string>
<string name="unknown_error">Errore sconosciuto</string>
<string name="wrong_passphrase">Password del database sbagliata</string>
<string name="wrong_passphrase_title">Password sbagliata!</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Sei stato/a invitato/a al gruppo. Entra per connetterti con i suoi membri.</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Puoi avviare la chat tramite Impostazioni -&gt; Database o riavviando l\'app.</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Sei entrato/a in questo gruppo. Connessione al membro del gruppo invitante.</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Non riceverai più messaggi da questo gruppo. La cronologia della chat verrà conservata.</string>
<string name="group_member_status_invited">invitato</string>
<string name="rcv_group_event_invited_via_your_group_link">invitato via link del tuo gruppo</string>
<string name="rcv_group_event_member_added">invitato <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_left">uscito/a</string>
<string name="group_member_status_left">uscito/a</string>
<string name="group_member_role_member">membro</string>
<string name="group_member_role_owner">proprietario</string>
<string name="group_member_status_removed">rimosso</string>
<string name="rcv_group_event_member_deleted">rimosso <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_user_deleted">sei stato/a rimosso/a</string>
<string name="group_invitation_tap_to_join">Tocca per entrare</string>
<string name="group_invitation_tap_to_join_incognito">Toccare per entrare in incognito</string>
<string name="alert_message_no_group">Questo gruppo non esiste più.</string>
<string name="rcv_group_event_updated_group_profile">profilo del gruppo aggiornato</string>
<string name="you_are_invited_to_group">Sei stato/a invitato/a al gruppo</string>
<string name="snd_conn_event_switch_queue_phase_completed">hai cambiato indirizzo</string>
<string name="snd_conn_event_switch_queue_phase_completed_for_member">hai cambiato l\'indirizzo per %s</string>
<string name="snd_group_event_changed_role_for_yourself">hai cambiato il tuo ruolo in %s</string>
<string name="snd_group_event_changed_member_role">hai cambiato il ruolo di %s in %s</string>
<string name="you_joined_this_group">Sei entrato/a in questo gruppo</string>
<string name="snd_group_event_user_left">sei uscito/a</string>
<string name="you_rejected_group_invitation">Hai rifiutato l\'invito al gruppo</string>
<string name="snd_group_event_member_deleted">hai rimosso <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="alert_title_cant_invite_contacts_descr">Stai usando un profilo in incognito per questo gruppo: per impedire la condivisione del tuo profilo principale non è consentito invitare contatti</string>
<string name="you_sent_group_invitation">Hai inviato un invito al gruppo</string>
<string name="button_add_members">Invita membri</string>
<string name="invite_to_group_button">Invita al gruppo</string>
<string name="button_leave_group">Esci dal gruppo</string>
<string name="info_row_local_name">Nome locale</string>
<string name="member_info_section_title_member">MEMBRO</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Il membro verrà rimosso dal gruppo, non è reversibile!</string>
<string name="new_member_role">Nuovo ruolo del membro</string>
<string name="no_contacts_selected">Nessun contatto selezionato</string>
<string name="no_contacts_to_add">Nessun contatto da aggiungere</string>
<string name="only_group_owners_can_change_prefs">Solo i proprietari del gruppo possono modificarne le preferenze.</string>
<string name="remove_member_confirmation">Rimuovi</string>
<string name="button_remove_member">Rimuovi membro</string>
<string name="role_in_group">Ruolo</string>
<string name="select_contacts">Seleziona i contatti</string>
<string name="button_send_direct_message">Invia messaggio diretto</string>
<string name="skip_inviting_button">Salta l\'invito di membri</string>
<string name="switch_verb">Cambia</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contatto/i selezionato/i</string>
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBRI</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">Puoi condividere un link o un codice QR: chiunque potrà unirsi al gruppo. Non perderai i membri del gruppo se in seguito lo elimini.</string>
<string name="invite_prohibited_description">Stai tentando di invitare un contatto con cui hai condiviso un profilo in incognito nel gruppo in cui stai usando il tuo profilo principale</string>
<string name="group_info_member_you">tu: <xliff:g id="group_info_you">%1$s</xliff:g></string>
<string name="incognito">Incognito</string>
<string name="group_unsupported_incognito_main_profile_sent">La modalità in incognito non è supportata qui: il tuo profilo principale verrà inviato ai membri del gruppo</string>
<string name="incognito_info_protects">La modalità in incognito protegge la privacy del nome e dell\'immagine del tuo profilo principale: per ogni nuovo contatto viene creato un nuovo profilo casuale.</string>
<string name="conn_level_desc_indirect">indiretta (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<string name="incognito_info_allows">Permette di avere molte connessioni anonime senza dati condivisi tra di loro in un unico profilo di chat.</string>
<string name="theme_light">Chiaro</string>
<string name="network_status">Stato della rete</string>
<string name="network_option_ping_interval">Intervallo PING</string>
<string name="network_option_protocol_timeout">Scadenza del protocollo</string>
<string name="receiving_via">Ricezione via</string>
<string name="network_options_reset_to_defaults">Ripristina i predefiniti</string>
<string name="network_options_revert">Annulla</string>
<string name="network_options_save">Salva</string>
<string name="save_group_profile">Salva il profilo del gruppo</string>
<string name="network_option_seconds_label">sec</string>
<string name="sending_via">Invio tramite</string>
<string name="conn_stats_section_title_servers">SERVER</string>
<string name="switch_receiving_address">Cambia indirizzo di ricezione</string>
<string name="theme_system">Sistema</string>
<string name="network_option_tcp_connection_timeout">Scadenza connessione TCP</string>
<string name="group_is_decentralized">Il gruppo è completamente decentralizzato: è visibile solo ai membri.</string>
<string name="member_role_will_be_changed_with_notification">Il ruolo verrà cambiato in \"%s\". Tutti i membri del gruppo riceveranno una notifica.</string>
<string name="member_role_will_be_changed_with_invitation">Il ruolo verrà cambiato in \"%s\". Il membro riceverà un nuovo invito.</string>
<string name="incognito_info_find">Per trovare il profilo usato per una connessione in incognito, tocca il nome del contatto o del gruppo in cima alla chat.</string>
<string name="update_network_settings_confirmation">Aggiorna</string>
<string name="update_network_settings_question">Aggiornare le impostazioni di rete\?</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">L\'aggiornamento delle impostazioni riconnetterà il client a tutti i server.</string>
<string name="incognito_info_share">Quando condividi un profilo in incognito con qualcuno, questo profilo verrà utilizzato per i gruppi a cui ti invitano.</string>
<string name="group_main_profile_sent">Il tuo profilo di chat verrà inviato ai membri del gruppo</string>
<string name="incognito_random_profile">Il tuo profilo casuale</string>
<string name="message_deletion_prohibited">L\'eliminazione irreversibile dei messaggi è vietata in questa chat.</string>
<string name="chat_preferences_no">no</string>
<string name="chat_preferences_off">off</string>
<string name="feature_off">off</string>
<string name="chat_preferences_on">on</string>
<string name="only_you_can_delete_messages">Solo tu puoi eliminare irreversibilmente i messaggi (il tuo contatto può contrassegnarli per l\'eliminazione).</string>
<string name="only_you_can_send_disappearing">Solo tu puoi inviare messaggi a tempo.</string>
<string name="only_your_contact_can_delete">Solo il tuo contatto può eliminare irreversibilmente i messaggi (tu puoi contrassegnarli per l\'eliminazione).</string>
<string name="only_your_contact_can_send_disappearing">Solo il tuo contatto può inviare messaggi a tempo.</string>
<string name="prohibit_sending_disappearing_messages">Proibisci l\'invio di messaggi a tempo.</string>
<string name="prohibit_sending_voice_messages">Proibisci l\'invio di messaggi vocali.</string>
<string name="feature_received_prohibited">ricevuto, vietato</string>
<string name="reset_color">Ripristina i colori</string>
<string name="save_color">Salva colore</string>
<string name="accept_feature_set_1_day">Imposta 1 giorno</string>
<string name="set_group_preferences">Imposta le preferenze del gruppo</string>
<string name="theme">Tema</string>
<string name="voice_messages">Messaggi vocali</string>
<string name="chat_preferences_yes"></string>
<string name="chat_preferences_you_allow">Lo consenti</string>
<string name="your_preferences">Le tue preferenze</string>
<string name="v4_3_improved_server_configuration">Configurazione del server migliorata</string>
<string name="v4_3_irreversible_message_deletion">Eliminazione irreversibile del messaggio</string>
<string name="message_deletion_prohibited_in_chat">L\'eliminazione irreversibile dei messaggi è vietata in questo gruppo.</string>
<string name="v4_3_voice_messages_desc">Max 40 secondi, ricevuto istantaneamente.</string>
<string name="new_in_version">Novità nella %s</string>
<string name="only_you_can_send_voice">Solo tu puoi inviare messaggi vocali.</string>
<string name="only_your_contact_can_send_voice">Solo il tuo contatto può inviare messaggi vocali.</string>
<string name="prohibit_message_deletion">Proibisci l\'eliminazione irreversibile dei messaggi.</string>
<string name="prohibit_direct_messages">Proibisci l\'invio di messaggi diretti ai membri.</string>
<string name="prohibit_sending_disappearing">Proibisci l\'invio di messaggi a tempo.</string>
<string name="prohibit_sending_voice">Proibisci l\'invio di messaggi vocali.</string>
<string name="v4_2_security_assessment">Valutazione della sicurezza</string>
<string name="v4_2_security_assessment_desc">La sicurezza di SimpleX Chat è stata verificata da Trail of Bits.</string>
<string name="v4_3_voice_messages">Messaggi vocali</string>
<string name="voice_prohibited_in_this_chat">I messaggi vocali sono vietati in questa chat.</string>
<string name="voice_messages_are_prohibited">I messaggi vocali sono vietati in questo gruppo.</string>
<string name="whats_new">Novità</string>
<string name="v4_2_auto_accept_contact_requests_desc">Con messaggio di benvenuto facoltativo.</string>
<string name="v4_3_irreversible_message_deletion_desc">I tuoi contatti possono consentire l\'eliminazione completa dei messaggi.</string>
<string name="v4_3_improved_privacy_and_security">Privacy e sicurezza migliorate</string>
<string name="v4_4_live_messages">Messaggi in diretta</string>
<string name="v4_4_live_messages_desc">I destinatari vedono gli aggiornamenti mentre li digiti.</string>
<string name="v4_4_disappearing_messages_desc">I messaggi inviati verranno eliminati dopo il tempo impostato.</string>
<string name="v4_4_verify_connection_security">Verifica la sicurezza della connessione</string>
<string name="chat_item_ttl_none">mai</string>
<string name="new_passphrase">Nuova password…</string>
<string name="no_received_app_files">Nessun file ricevuto o inviato</string>
<string name="notifications_will_be_hidden">Le notifiche verranno mostrate solo fino all\'arresto dell\'app!</string>
<string name="enter_correct_current_passphrase">Inserisci la password attuale corretta.</string>
<string name="store_passphrase_securely">Conserva la password in modo sicuro, NON potrai cambiarla se la perdi.</string>
<string name="remove_passphrase">Rimuovi</string>
<string name="remove_passphrase_from_keychain">Rimuovere la password dal Keystore\?</string>
<string name="save_passphrase_in_keychain">Salva la password nel Keystore</string>
<string name="chat_item_ttl_seconds">%s secondo/i</string>
<string name="stop_chat_to_enable_database_actions">Ferma la chat per attivare le azioni del database.</string>
<string name="delete_files_and_media_desc">Questa azione non può essere annullata: tutti i file e i media ricevuti e inviati verranno eliminati. Rimarranno le immagini a bassa risoluzione.</string>
<string name="enable_automatic_deletion_message">Questa azione non può essere annullata: i messaggi inviati e ricevuti prima di quanto selezionato verranno eliminati. Potrebbe richiedere diversi minuti.</string>
<string name="update_database">Aggiorna</string>
<string name="update_database_passphrase">Aggiorna la password del database</string>
<string name="you_have_to_enter_passphrase_every_time">Devi inserire la password ogni volta che si avvia l\'app: non viene memorizzata sul dispositivo.</string>
<string name="you_must_use_the_most_recent_version_of_database">Devi usare la versione più recente del tuo database della chat SOLO su un dispositivo, altrimenti potresti non ricevere più i messaggi da alcuni contatti.</string>
<string name="database_is_not_encrypted">Il database della chat non è crittografato: imposta la password per proteggerlo.</string>
<string name="icon_descr_cancel_live_message">Annulla messaggio in diretta</string>
<string name="feature_offered_item">offerto %s</string>
<string name="feature_offered_item_with_param">offerto %s: %2s</string>
<string name="feature_cancelled_item">annullato %s</string>
<string name="network_option_ping_count">Conteggio PING</string>
<string name="app_version_title">Versione dell\'app</string>
<string name="core_version">Versione core: v%s</string>
<string name="core_simplexmq_version">simplexmq: v%s (%2s)</string>
<string name="app_version_code">Build dell\'app: %s</string>
<string name="app_version_name">Versione app: v%s</string>
<string name="core_build_timestamp">Core compilato il: %s</string>
<string name="smp_servers_per_user">I server per le nuove connessioni del profilo di chat attuale</string>
<string name="users_add">Aggiungi profilo</string>
<string name="users_delete_question">Eliminare il profilo di chat\?</string>
<string name="network_session_mode_user_description">Verrà usata una connessione TCP separata (e le credenziali SOCKS) <b> per ogni profilo di chat presente nell\'app</b>.</string>
<string name="delete_files_and_media_all">Elimina tutti i file</string>
<string name="users_delete_data_only">Solo dati del profilo locale</string>
<string name="messages_section_title">Messaggi</string>
<string name="files_and_media_section">File e multimediali</string>
<string name="update_network_session_mode_question">Aggiornare la modalità di isolamento del trasporto\?</string>
<string name="users_delete_all_chats_deleted">Tutte le chat e i messaggi verranno eliminati. Non è reversibile!</string>
<string name="network_session_mode_user">Profilo di chat</string>
<string name="network_session_mode_entity_description">Verrà usata una connessione TCP separata (e le credenziali SOCKS) <b> per ogni contatto e membro del gruppo </b>.
\n<b> Nota: </b>: se hai molte connessioni, il consumo di batteria e traffico può essere notevolmente superiore e alcune connessioni potrebbero fallire.</string>
<string name="network_session_mode_entity">Connessione</string>
<string name="messages_section_description">Questa impostazione si applica ai messaggi del profilo di chat attuale</string>
<string name="network_session_mode_transport_isolation">Isolamento del trasporto</string>
<string name="users_delete_profile_for">Elimina il profilo di chat per</string>
<string name="delete_files_and_media_for_all_users">Elimina i file per tutti i profili di chat</string>
<string name="failed_to_active_user_title">Errore nel cambio di profilo!</string>
<string name="failed_to_create_user_title">Errore nella creazione del profilo!</string>
<string name="your_chat_profiles_stored_locally">I tuoi profili di chat sono memorizzati localmente, solo sul tuo dispositivo</string>
<string name="error_deleting_user">Errore nell\'eliminazione del profilo utente</string>
<string name="users_delete_with_connections">Profilo e connessioni al server</string>
<string name="your_chat_profiles">I tuoi profili di chat</string>
<string name="failed_to_create_user_duplicate_desc">Hai già un profilo chat con lo stesso nome da mostrare. Scegli un altro nome.</string>
<string name="failed_to_create_user_duplicate_title">Nome da mostrare doppio!</string>
</resources>

Some files were not shown because too many files have changed in this diff Show More