Compare commits
290 Commits
v4.5.3-bet
...
_archived-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
061d2c25ed | ||
|
|
4d700d113d | ||
|
|
17bdd2a1d2 | ||
|
|
80a68012a2 | ||
|
|
3742906f75 | ||
|
|
ae90edcdb5 | ||
|
|
9e76aadb0f | ||
|
|
043544d7ec | ||
|
|
2bf7d1dddc | ||
|
|
e1741118ce | ||
|
|
58fb3f7f2d | ||
|
|
48e92a7e9b | ||
|
|
5bf16da09d | ||
|
|
23ca3dd665 | ||
|
|
2caff25fa2 | ||
|
|
37f835be8c | ||
|
|
a6c1f2f776 | ||
|
|
acf0dfc38c | ||
|
|
67f13831ce | ||
|
|
d50e65af20 | ||
|
|
b8d74b74a6 | ||
|
|
802a28d759 | ||
|
|
c85d43b349 | ||
|
|
64ad491197 | ||
|
|
ddd8e719ef | ||
|
|
e51f7a51cc | ||
|
|
e66f5d488b | ||
|
|
b386cc83a7 | ||
|
|
09481e09b6 | ||
|
|
6913bf1a46 | ||
|
|
e6c87ff00b | ||
|
|
5f41cf3c52 | ||
|
|
9edbe2e589 | ||
|
|
b6876712f0 | ||
|
|
5b4c183466 | ||
|
|
393238f47c | ||
|
|
aea526f69d | ||
|
|
13cf1cc004 | ||
|
|
1766a8bb2c | ||
|
|
9a5732ab70 | ||
|
|
2923ca1356 | ||
|
|
96f0083384 | ||
|
|
febfc396e3 | ||
|
|
29735a807b | ||
|
|
eb36f64676 | ||
|
|
4e01970d69 | ||
|
|
e5713087e3 | ||
|
|
b40fc7ff18 | ||
|
|
06ad2b7972 | ||
|
|
14eeb4451c | ||
|
|
ead67adeb8 | ||
|
|
c2d70a5107 | ||
|
|
90dffc975a | ||
|
|
e5ba7caddc | ||
|
|
ec6cee1389 | ||
|
|
1d16a19373 | ||
|
|
04c90b4f07 | ||
|
|
747cbb8e09 | ||
|
|
834487e548 | ||
|
|
e1b6b54209 | ||
|
|
6366d60e4c | ||
|
|
fabea36682 | ||
|
|
991332a809 | ||
|
|
85537a99e8 | ||
|
|
717c90c58b | ||
|
|
ccb52e0acd | ||
|
|
d84b30c071 | ||
|
|
5ae0afe1fe | ||
|
|
d250e503b0 | ||
|
|
afb0ae3d03 | ||
|
|
1a3f0bed47 | ||
|
|
1e280fb7e1 | ||
|
|
6feac55380 | ||
|
|
93d8eac037 | ||
|
|
a11f99be3d | ||
|
|
da17639309 | ||
|
|
10301aa742 | ||
|
|
2148d50393 | ||
|
|
12fb2a4ec5 | ||
|
|
8085e5b85c | ||
|
|
4ba310ec16 | ||
|
|
865c56f400 | ||
|
|
c510e73256 | ||
|
|
73638129bc | ||
|
|
1a7a79d504 | ||
|
|
d3268e4a72 | ||
|
|
15a93014a5 | ||
|
|
e7735329bc | ||
|
|
3e222c68eb | ||
|
|
a596bd9011 | ||
|
|
21a49710a8 | ||
|
|
ce6fdb2558 | ||
|
|
0baee848a6 | ||
|
|
6f304bc9e6 | ||
|
|
1ca0dfffa0 | ||
|
|
1420084f5e | ||
|
|
3e03474437 | ||
|
|
95366e4d1b | ||
|
|
df1775a1e6 | ||
|
|
30ccea18ab | ||
|
|
4cd90d74ad | ||
|
|
7f1214688a | ||
|
|
aa89d0d156 | ||
|
|
787cd94362 | ||
|
|
ec61a7fc51 | ||
|
|
9b627534f5 | ||
|
|
400a3707b2 | ||
|
|
38a5676b37 | ||
|
|
f00cfa9108 | ||
|
|
afa24722b2 | ||
|
|
ea5cec53bc | ||
|
|
61dc649c70 | ||
|
|
b20824e16c | ||
|
|
39330fdce3 | ||
|
|
6b725a8ef7 | ||
|
|
cbcdeb2b43 | ||
|
|
4351610eca | ||
|
|
935d826a21 | ||
|
|
a8c8137ade | ||
|
|
7b33e1fba8 | ||
|
|
ade7bba97b | ||
|
|
08dd321311 | ||
|
|
67961180c9 | ||
|
|
1093892ede | ||
|
|
ef05fa4905 | ||
|
|
6a99a4f1ae | ||
|
|
4895f396a2 | ||
|
|
c3dffc5909 | ||
|
|
af73e5993d | ||
|
|
dfec1cbb02 | ||
|
|
8d8f7b2524 | ||
|
|
db7b81587f | ||
|
|
31bb744ba7 | ||
|
|
f4b349162f | ||
|
|
7f8adf8f03 | ||
|
|
1f4bb8a224 | ||
|
|
0c3dc8a6e9 | ||
|
|
1f15cf54af | ||
|
|
c96ba30018 | ||
|
|
ffea61917d | ||
|
|
f5c11b8faf | ||
|
|
48b4b23204 | ||
|
|
9df78c8ac8 | ||
|
|
450bfe2e17 | ||
|
|
c79eb36a7a | ||
|
|
a58b3a42db | ||
|
|
e344958224 | ||
|
|
05c4a6c682 | ||
|
|
b2aec6d6a7 | ||
|
|
09c4609b6c | ||
|
|
f6d2aa7aae | ||
|
|
92facf58f7 | ||
|
|
15c36c5a84 | ||
|
|
cea0543e98 | ||
|
|
c0bbe77788 | ||
|
|
9f8cbe140d | ||
|
|
d0cf550b51 | ||
|
|
a86725480f | ||
|
|
9ad22e1f6d | ||
|
|
a266bcbae7 | ||
|
|
1ba210fe77 | ||
|
|
7898395359 | ||
|
|
aeb732c2f6 | ||
|
|
b665dce383 | ||
|
|
f349f124d8 | ||
|
|
8212d7a00e | ||
|
|
36bcb1b26e | ||
|
|
8d6fe2be99 | ||
|
|
d9571c70f2 | ||
|
|
babbca48f8 | ||
|
|
8c4e2e57f9 | ||
|
|
8308651f44 | ||
|
|
ad881bd46a | ||
|
|
c037eb2d24 | ||
|
|
8b4353deba | ||
|
|
a2be0d35fb | ||
|
|
1909bdc702 | ||
|
|
63909defaf | ||
|
|
8544984b17 | ||
|
|
563984c0df | ||
|
|
6500ee5fc9 | ||
|
|
2a9c138a23 | ||
|
|
61d8fa02d4 | ||
|
|
0fac2187f0 | ||
|
|
1db61be860 | ||
|
|
06a0dbd0f2 | ||
|
|
47c6daf0cc | ||
|
|
bcdf502ce6 | ||
|
|
f9e2f4931a | ||
|
|
8929d15df0 | ||
|
|
60d6a47bdb | ||
|
|
2f529535b1 | ||
|
|
90c9eae283 | ||
|
|
added6105b | ||
|
|
2df39b5e24 | ||
|
|
3477dd9400 | ||
|
|
5282551f3d | ||
|
|
dcadaaf29b | ||
|
|
e9d6baa6ba | ||
|
|
6ae052a7a1 | ||
|
|
9f750c2516 | ||
|
|
1fe46834f2 | ||
|
|
3db85c7d37 | ||
|
|
1e4f1b8891 | ||
|
|
0fcd6d40ee | ||
|
|
aaa4ffe789 | ||
|
|
8c4720d0cb | ||
|
|
94b97f6097 | ||
|
|
85800d96c8 | ||
|
|
3b4c06111a | ||
|
|
548d695a82 | ||
|
|
c986a4b88b | ||
|
|
09940ccf8d | ||
|
|
cfc323862f | ||
|
|
d8cc867099 | ||
|
|
dce8a1dff9 | ||
|
|
5bc9e014c2 | ||
|
|
b0c9ba05f3 | ||
|
|
8a2876fca9 | ||
|
|
00d5f3b769 | ||
|
|
17f39ec6a0 | ||
|
|
498ffe8a71 | ||
|
|
858f0f2650 | ||
|
|
66ea2d5d71 | ||
|
|
3dd5b5d835 | ||
|
|
9127b1bbc6 | ||
|
|
1657bcf97d | ||
|
|
428db2f8f4 | ||
|
|
a8fa9b5e58 | ||
|
|
4cc59d9fbd | ||
|
|
c50306709b | ||
|
|
37d0bc2f14 | ||
|
|
2fda0454e3 | ||
|
|
be19af62d9 | ||
|
|
f915eb2a20 | ||
|
|
2bc1236a2c | ||
|
|
9db1924268 | ||
|
|
7a9f220290 | ||
|
|
8145387f77 | ||
|
|
063440e735 | ||
|
|
6724de09c9 | ||
|
|
f379fd0f8c | ||
|
|
34a3387830 | ||
|
|
809cc1f234 | ||
|
|
12200a74ff | ||
|
|
2643ea9066 | ||
|
|
840df89ca6 | ||
|
|
0404b020e6 | ||
|
|
fda41817e9 | ||
|
|
f48cabcc0a | ||
|
|
f123a905d5 | ||
|
|
9b7fbfd513 | ||
|
|
e21b4d4236 | ||
|
|
9ec6911005 | ||
|
|
bfc178faf3 | ||
|
|
c4c93f881d | ||
|
|
d7f9e17bcb | ||
|
|
13706c4f64 | ||
|
|
f2f4b26c35 | ||
|
|
1b7b9da07c | ||
|
|
2817306659 | ||
|
|
5f587c2104 | ||
|
|
f5670c39da | ||
|
|
c0105d135c | ||
|
|
f1a9814faa | ||
|
|
8f0e7512be | ||
|
|
7d49209f79 | ||
|
|
b2e285c2c7 | ||
|
|
54020250dc | ||
|
|
01acbb970a | ||
|
|
36cad35d46 | ||
|
|
41c9c84139 | ||
|
|
9e9ca521b0 | ||
|
|
1927862871 | ||
|
|
c80eaf8550 | ||
|
|
9e6a35bac3 | ||
|
|
62ffcf94a6 | ||
|
|
2b77920dcd | ||
|
|
38b7e4d4a4 | ||
|
|
3e4d4f04ef | ||
|
|
f6f3d17383 | ||
|
|
d5f6b76ec5 | ||
|
|
ae75be56ea | ||
|
|
50b90c4814 | ||
|
|
6eddb5f30f | ||
|
|
cee8f3a4b6 | ||
|
|
a2e5733be6 | ||
|
|
5075657c02 | ||
|
|
0450b1ace2 | ||
|
|
0ebf1da05d |
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -52,9 +52,9 @@ jobs:
|
||||
- os: ubuntu-20.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
- os: ubuntu-18.04
|
||||
- os: ubuntu-22.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-18_04-x86-64
|
||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||
- os: macos-latest
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
run: brew install pkg-config
|
||||
|
||||
- name: Unix prepare cabal.project.local for Ubuntu
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-18.04'
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
@@ -112,8 +112,8 @@ jobs:
|
||||
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest' && matrix.os != 'ubuntu-20.04'
|
||||
timeout-minutes: 20
|
||||
if: matrix.os != 'windows-latest'
|
||||
timeout-minutes: 30
|
||||
shell: bash
|
||||
run: cabal test --test-show-details=direct
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -45,10 +45,12 @@ tests/tmp
|
||||
tests/tmp*
|
||||
logs/
|
||||
|
||||
|
||||
*.devcontainer
|
||||
# for website
|
||||
website/node_modules/
|
||||
website/src/blog/
|
||||
website/translations.json
|
||||
website/src/_data/supported_languages.json
|
||||
website/src/img/images/
|
||||
website/src/images/
|
||||
# Generated files
|
||||
@@ -73,3 +75,4 @@ website/package-lock.json
|
||||
# Ignore test files
|
||||
website/.cache
|
||||
website/test/stubs-layout-cache/_includes/*.js
|
||||
apps/android/app/release
|
||||
|
||||
213
README.md
213
README.md
@@ -1,15 +1,29 @@
|
||||
| Updated 07.02.2023 | Languages: EN, [FR](/docs/lang/fr/README.md) |
|
||||
|
||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||
|
||||
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
|
||||
|
||||
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
[](https://mastodon.social/@simplex)
|
||||
|
||||
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||
|
||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||
|
||||
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
|
||||
|
||||
[<img src="./images/trail-of-bits.jpg" height="100">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [<img src="./images/privacy-guides.jpg" height="80">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [<img src="./images/kuketz-blog.jpg" height="80">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
|
||||
|
||||
## Welcome to SimpleX Chat!
|
||||
|
||||
1. 📲 [Install the app](#install-the-app).
|
||||
2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups).
|
||||
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
||||
|
||||
[Learn more about SimpleX Chat](#contents).
|
||||
|
||||
## Install the app
|
||||
|
||||
[<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)
|
||||
|
||||
[](https://play.google.com/store/apps/details?id=chat.simplex.app)
|
||||
@@ -26,7 +40,91 @@
|
||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
|
||||
|
||||
**NEW**: Security audit by [Trail of Bits](https://www.trailofbits.com/about), the [new website](https://simplex.chat) and v4.2 released! [See the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
||||
## Connect to the team via the app
|
||||
|
||||
- to ask any questions
|
||||
- to suggest any improvements
|
||||
- to share anything relevant
|
||||
|
||||
## Join user groups
|
||||
|
||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-3](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FeDEgekVhh0zIBYvUupGWZ96kiBEMbXwK%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAS7Z3zQyOhZ9o1qzI9OTZRySpkFTagdLMa6Rc7opuNh4%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22cG3W1qxZGzyrvHTugx16bg%3D%3D%22%7D)
|
||||
|
||||
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
|
||||
|
||||
[\#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 join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||
|
||||
## Make a private connection
|
||||
|
||||
You need to share a link with your friend or scan a QR code from their phone, in person or during a video call, to make a connection and start messaging.
|
||||
|
||||
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">
|
||||
|
||||
After you connect, you can [verify connection security code](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification).
|
||||
|
||||
## User guide (NEW)
|
||||
|
||||
Read about the app features and settings in the new [User guide](./docs/guide/README.md).
|
||||
|
||||
## Help translating SimpleX Chat
|
||||
|
||||
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
|
||||
|
||||
Join our translators to help SimpleX grow!
|
||||
|
||||
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
||||
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
||||
|🇬🇧 en|English | |✓|✓|✓|✓|
|
||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
||||
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
||||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|
||||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [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, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||
|
||||
## Contribute
|
||||
|
||||
We would love to have you join the development! You can help us with:
|
||||
|
||||
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
|
||||
- contributing to SimpleX Chat knowledge-base.
|
||||
- developing features - please connect to us via chat so we can help you get started.
|
||||
|
||||
## Help us with donations
|
||||
|
||||
Huge thank you to everybody who donated to SimpleX Chat!
|
||||
|
||||
We are prioritizing users privacy and security - it would be impossible without your support.
|
||||
|
||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||
|
||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||
|
||||
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
|
||||
|
||||
Thank you,
|
||||
|
||||
Evgeny
|
||||
|
||||
SimpleX Chat founder
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -38,16 +136,11 @@
|
||||
- [Users own SimpleX network](#users-own-simplex-network)
|
||||
- [Frequently asked questions](#frequently-asked-questions)
|
||||
- [News and updates](#news-and-updates)
|
||||
- [Make a private connection](#make-a-private-connection)
|
||||
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
||||
- [SimpleX Platform design](#simplex-platform-design)
|
||||
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
||||
- [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)
|
||||
|
||||
## Why privacy matters
|
||||
@@ -88,26 +181,22 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent updates:
|
||||
|
||||
[Feb 04, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
||||
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||
|
||||
[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).
|
||||
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.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).
|
||||
[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.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).
|
||||
[Jan 3, 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).
|
||||
|
||||
[Dec 6, 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).
|
||||
|
||||
[Nov 8, 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 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||
|
||||
[All updates](./blog)
|
||||
|
||||
## Make a private connection
|
||||
|
||||
You need to share a link or scan a QR code (in person or during a video call) to make a connection and start messaging.
|
||||
|
||||
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">
|
||||
|
||||
## :zap: Quick installation of a terminal app
|
||||
|
||||
```sh
|
||||
@@ -202,17 +291,21 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ Multiple user profiles in the same chat database.
|
||||
- ✅ Optionally avoid re-using the same TCP session for multiple connections.
|
||||
- ✅ Preserve message drafts.
|
||||
- 🏗 File server to optimize for efficient and private sending of large files.
|
||||
- 🏗 Improved audio & video calls.
|
||||
- ✅ File server to optimize for efficient and private sending of large files.
|
||||
- ✅ Improved audio & video calls.
|
||||
- ✅ Support older Android OS and 32-bit CPUs.
|
||||
- ✅ Hidden chat profiles.
|
||||
- 🏗 Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
- 🏗 Video messages.
|
||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||
- 🏗 Reduced battery and traffic usage in large groups.
|
||||
- 🏗 Support older Android OS and 32-bit CPUs.
|
||||
- Include optional message into connection request sent via contact address.
|
||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Access password/pin (with optional alternative access password).
|
||||
- Local app files encryption.
|
||||
- Video messages.
|
||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||
- Privately share your location.
|
||||
- Feeds/broadcasts.
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
||||
@@ -225,70 +318,6 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- High capacity multi-node SMP relays.
|
||||
|
||||
## Join a user group
|
||||
|
||||
You can join an English-speaking group if you want to ask any questions: [#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 join these groups either by opening these links in the app or by opening them in a desktop browser and scanning QR code.
|
||||
|
||||
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: [@ishi_sama](https://github.com/ishi-sama)
|
||||
- Italian: [@unbranched](https://github.com/unbranched)
|
||||
- Russian: project team
|
||||
|
||||
Languages in progress: Chinese, Hindi, Czech, 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, review the [translation guide](./docs/TRANSLATIONS.md) 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.
|
||||
|
||||
## Help us with donations
|
||||
|
||||
Huge thank you to everybody who donated to SimpleX Chat!
|
||||
|
||||
We are prioritizing users privacy and security - it would be impossible without your support.
|
||||
|
||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||
|
||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||
|
||||
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
|
||||
- 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,
|
||||
|
||||
Evgeny
|
||||
|
||||
SimpleX Chat founder
|
||||
|
||||
## Disclaimers
|
||||
|
||||
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
|
||||
|
||||
@@ -9,15 +9,12 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
minSdk 26
|
||||
targetSdk 32
|
||||
versionCode 101
|
||||
versionName "4.5.3-beta.0"
|
||||
versionCode 116
|
||||
versionName "5.0-beta.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
@@ -77,9 +74,37 @@ android {
|
||||
jniLibs.useLegacyPackaging = compression_level != "0"
|
||||
}
|
||||
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||
if (isRelease) {
|
||||
def isBundle = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("bundle") }) != null
|
||||
// if (isRelease) {
|
||||
// Comma separated list of languages that will be included in the apk
|
||||
android.defaultConfig.resConfigs("en", "ru", "de", "fr", "it", "nl")
|
||||
android.defaultConfig.resConfigs(
|
||||
"en",
|
||||
"cs",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"nl",
|
||||
"pl",
|
||||
"ru",
|
||||
"zh-rCN"
|
||||
)
|
||||
// }
|
||||
if (isBundle) {
|
||||
defaultConfig.ndk.abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||
} else {
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
if (isRelease) {
|
||||
include 'arm64-v8a', 'armeabi-v7a'
|
||||
} else {
|
||||
include 'arm64-v8a', 'armeabi-v7a'
|
||||
universalApk false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +154,9 @@ dependencies {
|
||||
implementation "io.coil-kt:coil-compose:2.1.0"
|
||||
implementation "io.coil-kt:coil-gif:2.1.0"
|
||||
|
||||
// Video support
|
||||
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
@@ -136,19 +164,12 @@ dependencies {
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||
}
|
||||
|
||||
def buildType = "unknown"
|
||||
// Don't do anything if no compression is needed
|
||||
if (compression_level != "0") {
|
||||
tasks.whenTaskAdded { task ->
|
||||
if (task.name == 'packageDebug') {
|
||||
task.doLast {
|
||||
buildType = "debug"
|
||||
}
|
||||
task.finalizedBy compressApk
|
||||
} else if (task.name == 'packageRelease') {
|
||||
task.doLast {
|
||||
buildType = "release"
|
||||
}
|
||||
task.finalizedBy compressApk
|
||||
}
|
||||
}
|
||||
@@ -156,6 +177,13 @@ if (compression_level != "0") {
|
||||
|
||||
tasks.register("compressApk") {
|
||||
doLast {
|
||||
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||
def buildType
|
||||
if (isRelease) {
|
||||
buildType = "release"
|
||||
} else {
|
||||
buildType = "debug"
|
||||
}
|
||||
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
||||
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
||||
def keyAlias = ""
|
||||
@@ -196,6 +224,8 @@ tasks.register("compressApk") {
|
||||
|
||||
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
||||
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||
new File(outputDir, "app-armeabi-v7a-release.apk").renameTo(new File(outputDir, "simplex-armv7a.apk"))
|
||||
new File(outputDir, "app-arm64-v8a-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||
}
|
||||
|
||||
// View all gradle properties set
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
android:extractNativeLibs="${extract_native_libs}"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
<!-- android:localeConfig="@xml/locales_config"-->
|
||||
|
||||
<!-- Main activity -->
|
||||
<activity
|
||||
|
||||
@@ -53,10 +53,6 @@ add_library( support SHARED IMPORTED )
|
||||
set_target_properties( support PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
|
||||
|
||||
add_library( crypto SHARED IMPORTED )
|
||||
set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libcrypto.so)
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
# can link multiple libraries, such as libraries you define in this
|
||||
# build script, prebuilt third-party libraries, or system libraries.
|
||||
@@ -64,7 +60,7 @@ set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
|
||||
target_link_libraries( # Specifies the target library.
|
||||
app-lib
|
||||
|
||||
simplex support crypto
|
||||
simplex support
|
||||
|
||||
# Links the target library to the log library
|
||||
# included in the NDK.
|
||||
|
||||
@@ -7,6 +7,17 @@ void hs_init(int * argc, char **argv[]);
|
||||
void setLineBuffering(void);
|
||||
int pipe_std_to_socket(const char * name);
|
||||
|
||||
extern void __svfscanf(void){};
|
||||
extern void __vfwscanf(void){};
|
||||
extern void __memset_chk_fail(void){};
|
||||
extern void __strcpy_chk_generic(void){};
|
||||
extern void __strcat_chk_generic(void){};
|
||||
extern void __libc_globals(void){};
|
||||
extern void __rel_iplt_start(void){};
|
||||
|
||||
// Android 9 only, not 13
|
||||
extern void reallocarray(void){};
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
||||
@@ -24,21 +35,24 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass
|
||||
// from simplex-chat
|
||||
typedef long* chat_ctrl;
|
||||
|
||||
extern char *chat_migrate_init(const char *path, const char *key, chat_ctrl *ctrl);
|
||||
extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
|
||||
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
||||
extern char *chat_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);
|
||||
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
|
||||
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
||||
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
||||
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
|
||||
jlong _ctrl = (jlong) 0;
|
||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, &_ctrl));
|
||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
|
||||
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
|
||||
|
||||
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
|
||||
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
|
||||
@@ -85,3 +99,13 @@ Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass
|
||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
|
||||
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
|
||||
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
|
||||
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
|
||||
(*env)->ReleaseStringUTFChars(env, salt, _salt);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ 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.*
|
||||
import android.os.SystemClock.elapsedRealtime
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
@@ -14,8 +12,7 @@ import androidx.activity.viewModels
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.runtime.*
|
||||
@@ -27,8 +24,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.*
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.MainActivity.Companion.enteredBackground
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.model.NtfManager.Companion.getUserIdFromIntent
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
@@ -39,11 +36,13 @@ import chat.simplex.app.views.chat.ChatView
|
||||
import chat.simplex.app.views.chatlist.*
|
||||
import chat.simplex.app.views.database.DatabaseErrorView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.localauth.SetAppPasscodeView
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import kotlinx.coroutines.delay
|
||||
import chat.simplex.app.views.usersettings.LAMode
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
companion object {
|
||||
@@ -68,6 +67,7 @@ class MainActivity: FragmentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
// testJson()
|
||||
val m = vm.chatModel
|
||||
applyAppLocale(m.controller.appPrefs.appLanguage)
|
||||
// When call ended and orientation changes, it re-process old intent, it's unneeded.
|
||||
// Only needed to be processed on first creation of activity
|
||||
if (savedInstanceState == null) {
|
||||
@@ -95,7 +95,7 @@ class MainActivity: FragmentActivity() {
|
||||
laFailed,
|
||||
::runAuthenticate,
|
||||
::setPerformLA,
|
||||
showLANotice = { m.controller.showLANotice(this) }
|
||||
showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown, this) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -110,10 +110,11 @@ class MainActivity: FragmentActivity() {
|
||||
processExternalIntent(intent, vm.chatModel)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val enteredBackgroundVal = enteredBackground.value
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30_000) {
|
||||
val delay = vm.chatModel.controller.appPrefs.laLockDelay.get()
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
@@ -130,6 +131,7 @@ class MainActivity: FragmentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
VideoPlayer.stopAll()
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
|
||||
@@ -161,43 +163,155 @@ class MainActivity: FragmentActivity() {
|
||||
} else {
|
||||
userAuthorized.value = false
|
||||
ModalManager.shared.closeModals()
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_unlock),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success ->
|
||||
userAuthorized.value = true
|
||||
is LAResult.Error, LAResult.Failed ->
|
||||
laFailed.value = true
|
||||
LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
// To make Main thread free in order to allow to Compose to show blank view that hiding content underneath of it faster on slow devices
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
delay(50)
|
||||
withContext(Dispatchers.Main) {
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_unlock)
|
||||
else
|
||||
generalGetString(R.string.la_enter_app_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_log_in_using_credential)
|
||||
else
|
||||
generalGetString(R.string.auth_unlock),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success ->
|
||||
userAuthorized.value = true
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
laFailed.value = true
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.PASSCODE) {
|
||||
laFailedAlert()
|
||||
}
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLANotice(laNoticeShown: SharedPreference<Boolean>, activity: FragmentActivity) {
|
||||
Log.d(TAG, "showLANotice")
|
||||
if (!laNoticeShown.get()) {
|
||||
laNoticeShown.set(true)
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.la_notice_title_simplex_lock),
|
||||
text = generalGetString(R.string.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
|
||||
confirmText = generalGetString(R.string.la_notice_turn_on),
|
||||
onConfirm = {
|
||||
withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager
|
||||
showChooseLAMode(laNoticeShown, activity)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPerformLA(on: Boolean) {
|
||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (on) {
|
||||
enableLA()
|
||||
} else {
|
||||
disableLA()
|
||||
}
|
||||
private fun showChooseLAMode(laNoticeShown: SharedPreference<Boolean>, activity: FragmentActivity) {
|
||||
Log.d(TAG, "showLANotice")
|
||||
laNoticeShown.set(true)
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.la_lock_mode),
|
||||
text = null,
|
||||
confirmText = generalGetString(R.string.la_lock_mode_passcode),
|
||||
dismissText = generalGetString(R.string.la_lock_mode_system),
|
||||
onConfirm = {
|
||||
AlertManager.shared.hideAlert()
|
||||
setPasscode()
|
||||
},
|
||||
onDismiss = {
|
||||
AlertManager.shared.hideAlert()
|
||||
initialEnableLA(activity)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun enableLA() {
|
||||
private fun initialEnableLA(activity: FragmentActivity) {
|
||||
val m = vm.chatModel
|
||||
val appPrefs = m.controller.appPrefs
|
||||
m.controller.appPrefs.laMode.set(LAMode.SYSTEM)
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_enable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
this@MainActivity,
|
||||
activity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
m.showAdvertiseLAUnavailableAlert.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setPasscode() {
|
||||
val chatModel = vm.chatModel
|
||||
val appPrefs = chatModel.controller.appPrefs
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
SetAppPasscodeView(
|
||||
submit = {
|
||||
chatModel.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
appPrefs.laMode.set(LAMode.PASSCODE)
|
||||
laTurnedOnAlert()
|
||||
},
|
||||
cancel = {
|
||||
chatModel.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laPasscodeNotSetAlert()
|
||||
},
|
||||
close)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPerformLA(on: Boolean, activity: FragmentActivity) {
|
||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (on) {
|
||||
enableLA(activity)
|
||||
} else {
|
||||
disableLA(activity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableLA(activity: FragmentActivity) {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_enable_simplex_lock)
|
||||
else
|
||||
generalGetString(R.string.new_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_confirm_credential)
|
||||
else
|
||||
"",
|
||||
activity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
@@ -206,11 +320,13 @@ class MainActivity: FragmentActivity() {
|
||||
prefPerformLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Error, LAResult.Failed -> {
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laFailedAlert()
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableInstructionAlert()
|
||||
@@ -220,24 +336,33 @@ class MainActivity: FragmentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableLA() {
|
||||
private fun disableLA(activity: FragmentActivity) {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_disable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
this@MainActivity,
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_disable_simplex_lock)
|
||||
else
|
||||
generalGetString(R.string.la_enter_app_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_confirm_credential)
|
||||
else
|
||||
generalGetString(R.string.auth_disable_simplex_lock),
|
||||
activity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
ksAppPassword.remove()
|
||||
}
|
||||
is LAResult.Error, LAResult.Failed -> {
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laFailedAlert()
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
@@ -259,17 +384,9 @@ fun MainPage(
|
||||
userAuthorized: MutableState<Boolean?>,
|
||||
laFailed: MutableState<Boolean>,
|
||||
runAuthenticate: () -> Unit,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
setPerformLA: (Boolean, FragmentActivity) -> Unit,
|
||||
showLANotice: () -> Unit
|
||||
) {
|
||||
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
|
||||
var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(userAuthorized.value) {
|
||||
if (chatModel.controller.appPrefs.performLA.get()) {
|
||||
delay(500L)
|
||||
}
|
||||
chatsAccessAuthorized = userAuthorized.value == true
|
||||
}
|
||||
var showChatDatabaseError by rememberSaveable {
|
||||
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
||||
}
|
||||
@@ -328,7 +445,7 @@ fun MainPage(
|
||||
}
|
||||
}
|
||||
onboarding == null || userCreated == null -> SplashView()
|
||||
!chatsAccessAuthorized -> {
|
||||
userAuthorized.value != true -> {
|
||||
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||
authView()
|
||||
} else {
|
||||
@@ -395,6 +512,14 @@ fun MainPage(
|
||||
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||
AlertManager.shared.showInView()
|
||||
}
|
||||
|
||||
DisposableEffectOnRotate {
|
||||
// When using lock delay = 0 and screen rotates, the app will be locked which is not useful.
|
||||
// Let's prolong the unlocked period to 3 sec for screen rotation to take place
|
||||
if (chatModel.controller.appPrefs.laLockDelay.get() == 0) {
|
||||
enteredBackground.value = elapsedRealtime() + 3000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
@@ -405,8 +530,9 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||
if (chatId != null) {
|
||||
withBGApi {
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId) {
|
||||
chatModel.controller.changeActiveUser(userId)
|
||||
awaitChatStartedIfNeeded(chatModel)
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||
chatModel.controller.changeActiveUser(userId, null)
|
||||
}
|
||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||
chatModel.clearOverlays.value = true
|
||||
@@ -417,8 +543,9 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
NtfManager.ShowChatsAction -> {
|
||||
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||
withBGApi {
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId) {
|
||||
chatModel.controller.changeActiveUser(userId)
|
||||
awaitChatStartedIfNeeded(chatModel)
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||
chatModel.controller.changeActiveUser(userId, null)
|
||||
}
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
@@ -455,14 +582,23 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
when {
|
||||
"text/plain" == intent.type -> intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
chatModel.sharedContent.value = SharedContent.Text(it)
|
||||
intent.type == "text/plain" -> {
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (text != null) {
|
||||
chatModel.sharedContent.value = SharedContent.Text(text)
|
||||
}
|
||||
}
|
||||
intent.type?.startsWith("image/") == true -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
|
||||
chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(it))
|
||||
} // All other mime types
|
||||
else -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
|
||||
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
|
||||
isMediaIntent(intent) -> {
|
||||
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
if (uri != null) {
|
||||
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri))
|
||||
} // All other mime types
|
||||
}
|
||||
else -> {
|
||||
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
if (uri != null) {
|
||||
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,16 +606,23 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
// Close active chat and show a list of chats
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
Log.e(TAG, "ACTION_SEND_MULTIPLE ${intent.type}")
|
||||
when {
|
||||
intent.type?.startsWith("image/") == true -> (intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>)?.let {
|
||||
chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
|
||||
} // All other mime types
|
||||
isMediaIntent(intent) -> {
|
||||
val uris = intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>
|
||||
if (uris != null) {
|
||||
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris)
|
||||
} // All other mime types
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isMediaIntent(intent: Intent): Boolean =
|
||||
intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
|
||||
|
||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
@@ -508,6 +651,20 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
|
||||
// Still decrypting database
|
||||
if (chatModel.chatRunning.value == null) {
|
||||
val step = 50L
|
||||
for (i in 0..(timeout / step)) {
|
||||
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
|
||||
break
|
||||
}
|
||||
delay(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//fun testJson() {
|
||||
// val str: String = """
|
||||
// """.trimIndent()
|
||||
|
||||
@@ -26,22 +26,26 @@ external fun pipeStdOutToSocket(socketName: String) : Int
|
||||
|
||||
// SimpleX API
|
||||
typealias ChatCtrl = Long
|
||||
external fun chatMigrateInit(dbPath: String, dbKey: String): Array<Any>
|
||||
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>
|
||||
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
|
||||
external fun chatPasswordHash(pwd: String, salt: String): String
|
||||
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
lateinit var chatController: ChatController
|
||||
|
||||
var isAppOnForeground: Boolean = false
|
||||
|
||||
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
|
||||
val defaultLocale: Locale = Locale.getDefault()
|
||||
|
||||
fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
|
||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
|
||||
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
||||
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey)
|
||||
val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey, confirm.value)
|
||||
val res: DBMigrationResult = kotlin.runCatching {
|
||||
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
||||
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
||||
|
||||
@@ -326,4 +326,4 @@ class SimplexService: Service() {
|
||||
|
||||
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +54,8 @@ class ChatModel(val controller: ChatController) {
|
||||
|
||||
val terminalItems = mutableStateListOf<TerminalItem>()
|
||||
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
|
||||
val userSMPServers = mutableStateOf<(List<ServerCfg>)?>(null)
|
||||
// Allows to temporary save servers that are being edited on multiple screens
|
||||
val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null)
|
||||
val presetSMPServers = mutableStateOf<(List<String>)?>(null)
|
||||
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
|
||||
|
||||
// set when app opened from external intent
|
||||
@@ -94,6 +92,32 @@ class ChatModel(val controller: ChatController) {
|
||||
val filesToDelete = mutableSetOf<File>()
|
||||
val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
|
||||
|
||||
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
|
||||
currentUser.value
|
||||
} else {
|
||||
users.firstOrNull { it.user.userId == userId }?.user
|
||||
}
|
||||
|
||||
private fun getUserIndex(user: User): Int =
|
||||
users.indexOfFirst { it.user.userId == user.userId }
|
||||
|
||||
fun updateUser(user: User) {
|
||||
val i = getUserIndex(user)
|
||||
if (i != -1) {
|
||||
users[i] = users[i].copy(user = user)
|
||||
}
|
||||
if (currentUser.value?.userId == user.userId) {
|
||||
currentUser.value = user
|
||||
}
|
||||
}
|
||||
|
||||
fun removeUser(user: User) {
|
||||
val i = getUserIndex(user)
|
||||
if (i != -1 && users[i].user.userId != currentUser.value?.userId) {
|
||||
users.removeAt(i)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
|
||||
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
|
||||
fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
|
||||
@@ -166,10 +190,13 @@ class ChatModel(val controller: ChatController) {
|
||||
// add to current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
// Prevent situation when chat item already in the list received from backend
|
||||
if (chatItems.none { it.id == cItem.id }) {
|
||||
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,19 +223,19 @@ class ChatModel(val controller: ChatController) {
|
||||
res = true
|
||||
}
|
||||
// update current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
chatItems[itemIndex] = cItem
|
||||
return false
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
return if (chatId.value == cInfo.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
chatItems[itemIndex] = cItem
|
||||
false
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
true
|
||||
}
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,13 +449,19 @@ data class User(
|
||||
val localDisplayName: String,
|
||||
val profile: LocalProfile,
|
||||
val fullPreferences: FullChatPreferences,
|
||||
val activeUser: Boolean
|
||||
val activeUser: Boolean,
|
||||
val showNtfs: Boolean,
|
||||
val viewPwdHash: UserPwdHash?
|
||||
): NamedChat {
|
||||
override val displayName: String get() = profile.displayName
|
||||
override val fullName: String get() = profile.fullName
|
||||
override val image: String? get() = profile.image
|
||||
override val localAlias: String = ""
|
||||
|
||||
val hidden: Boolean = viewPwdHash != null
|
||||
|
||||
val showNotifications: Boolean = activeUser || showNtfs
|
||||
|
||||
companion object {
|
||||
val sampleData = User(
|
||||
userId = 1,
|
||||
@@ -436,11 +469,19 @@ data class User(
|
||||
localDisplayName = "alice",
|
||||
profile = LocalProfile.sampleData,
|
||||
fullPreferences = FullChatPreferences.sampleData,
|
||||
activeUser = true
|
||||
activeUser = true,
|
||||
showNtfs = true,
|
||||
viewPwdHash = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UserPwdHash(
|
||||
val hash: String,
|
||||
val salt: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserInfo(
|
||||
val user: User,
|
||||
@@ -486,6 +527,24 @@ data class Chat (
|
||||
val chatItems: List<ChatItem>,
|
||||
val chatStats: ChatStats = ChatStats(),
|
||||
) {
|
||||
val userCanSend: Boolean
|
||||
get() = when (chatInfo) {
|
||||
is ChatInfo.Direct -> true
|
||||
is ChatInfo.Group -> {
|
||||
val m = chatInfo.groupInfo.membership
|
||||
m.memberActive && m.memberRole >= GroupMemberRole.Member
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
val userIsObserver: Boolean get() = when(chatInfo) {
|
||||
is ChatInfo.Group -> {
|
||||
val m = chatInfo.groupInfo.membership
|
||||
m.memberActive && m.memberRole == GroupMemberRole.Observer
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
val id: String get() = chatInfo.id
|
||||
|
||||
@Serializable
|
||||
@@ -670,6 +729,7 @@ data class Contact(
|
||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
|
||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
|
||||
ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
|
||||
ChatFeature.Calls -> mergedPreferences.calls.enabled.forUser
|
||||
}
|
||||
override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null }
|
||||
override val displayName get() = localAlias.ifEmpty { profile.displayName }
|
||||
@@ -688,12 +748,14 @@ data class Contact(
|
||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
|
||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
|
||||
ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
|
||||
ChatFeature.Calls -> mergedPreferences.calls.contactPreference.allow != FeatureAllowed.NO
|
||||
}
|
||||
|
||||
fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
|
||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
|
||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
|
||||
ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
|
||||
ChatFeature.Calls -> mergedPreferences.calls.userPreference.pref.allow != FeatureAllowed.NO
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -823,6 +885,7 @@ data class GroupInfo (
|
||||
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
|
||||
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
|
||||
ChatFeature.Voice -> fullGroupPreferences.voice.on
|
||||
ChatFeature.Calls -> false
|
||||
}
|
||||
override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
|
||||
override val displayName get() = groupProfile.displayName
|
||||
@@ -963,11 +1026,13 @@ class GroupMemberRef(
|
||||
|
||||
@Serializable
|
||||
enum class GroupMemberRole(val memberRole: String) {
|
||||
@SerialName("member") Member("member"), // order matters in comparisons
|
||||
@SerialName("observer") Observer("observer"), // order matters in comparisons
|
||||
@SerialName("member") Member("member"),
|
||||
@SerialName("admin") Admin("admin"),
|
||||
@SerialName("owner") Owner("owner");
|
||||
|
||||
val text: String get() = when (this) {
|
||||
Observer -> generalGetString(R.string.group_member_role_observer)
|
||||
Member -> generalGetString(R.string.group_member_role_member)
|
||||
Admin -> generalGetString(R.string.group_member_role_admin)
|
||||
Owner -> generalGetString(R.string.group_member_role_owner)
|
||||
@@ -1218,9 +1283,24 @@ data class ChatItem (
|
||||
when (content) {
|
||||
is CIContent.SndDeleted -> true
|
||||
is CIContent.RcvDeleted -> true
|
||||
is CIContent.SndModerated -> true
|
||||
is CIContent.RcvModerated -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun memberToModerate(chatInfo: ChatInfo): Pair<GroupInfo, GroupMember>? {
|
||||
return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
|
||||
val m = chatInfo.groupInfo.membership
|
||||
if (m.memberRole >= GroupMemberRole.Admin && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) {
|
||||
chatInfo.groupInfo to chatDir.groupMember
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private val showNtfDir: Boolean get() = !chatDir.sent
|
||||
|
||||
val showNotification: Boolean get() =
|
||||
@@ -1232,6 +1312,7 @@ data class ChatItem (
|
||||
is CIContent.SndCall -> showNtfDir
|
||||
is CIContent.RcvCall -> false // notification is shown on CallInvitation instead
|
||||
is CIContent.RcvIntegrityError -> showNtfDir
|
||||
is CIContent.RcvDecryptionError -> showNtfDir
|
||||
is CIContent.RcvGroupInvitation -> showNtfDir
|
||||
is CIContent.SndGroupInvitation -> showNtfDir
|
||||
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
||||
@@ -1344,7 +1425,7 @@ data class ChatItem (
|
||||
file = null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
|
||||
const val TEMP_LIVE_CHAT_ITEM_ID = -2L
|
||||
|
||||
@@ -1389,10 +1470,10 @@ data class ChatItem (
|
||||
file = null
|
||||
)
|
||||
|
||||
fun invalidJSON(json: String): ChatItem =
|
||||
fun invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String): ChatItem =
|
||||
ChatItem(
|
||||
chatDir = CIDirection.DirectSnd(),
|
||||
meta = CIMeta.invalidJSON(),
|
||||
chatDir = chatDir ?: CIDirection.DirectSnd(),
|
||||
meta = meta ?: CIMeta.invalidJSON(),
|
||||
content = CIContent.InvalidJSON(json),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
@@ -1535,6 +1616,7 @@ sealed class CIContent: ItemContent {
|
||||
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@@ -1561,6 +1643,7 @@ sealed class CIContent: ItemContent {
|
||||
is SndCall -> status.text(duration)
|
||||
is RcvCall -> status.text(duration)
|
||||
is RcvIntegrityError -> msgError.text
|
||||
is RcvDecryptionError -> msgDecryptError.text
|
||||
is RcvGroupInvitation -> groupInvitation.text
|
||||
is SndGroupInvitation -> groupInvitation.text
|
||||
is RcvGroupEventContent -> rcvGroupEvent.text
|
||||
@@ -1599,6 +1682,17 @@ sealed class CIContent: ItemContent {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class MsgDecryptError {
|
||||
@SerialName("ratchetHeader") RatchetHeader,
|
||||
@SerialName("tooManySkipped") TooManySkipped;
|
||||
|
||||
val text: String get() = when (this) {
|
||||
RatchetHeader -> generalGetString(R.string.decryption_error)
|
||||
TooManySkipped -> generalGetString(R.string.decryption_error)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class CIQuote (
|
||||
val chatDir: CIDirection? = null,
|
||||
@@ -1636,18 +1730,40 @@ class CIFile(
|
||||
val fileName: String,
|
||||
val fileSize: Long,
|
||||
val filePath: String? = null,
|
||||
val fileStatus: CIFileStatus
|
||||
val fileStatus: CIFileStatus,
|
||||
val fileProtocol: FileProtocol
|
||||
) {
|
||||
val loaded: Boolean = when (fileStatus) {
|
||||
CIFileStatus.SndStored -> true
|
||||
CIFileStatus.SndTransfer -> true
|
||||
CIFileStatus.SndComplete -> true
|
||||
CIFileStatus.SndCancelled -> true
|
||||
CIFileStatus.RcvInvitation -> false
|
||||
CIFileStatus.RcvAccepted -> false
|
||||
CIFileStatus.RcvTransfer -> false
|
||||
CIFileStatus.RcvCancelled -> false
|
||||
CIFileStatus.RcvComplete -> true
|
||||
is CIFileStatus.SndStored -> true
|
||||
is CIFileStatus.SndTransfer -> true
|
||||
is CIFileStatus.SndComplete -> true
|
||||
is CIFileStatus.SndCancelled -> true
|
||||
is CIFileStatus.SndError -> true
|
||||
is CIFileStatus.RcvInvitation -> false
|
||||
is CIFileStatus.RcvAccepted -> false
|
||||
is CIFileStatus.RcvTransfer -> false
|
||||
is CIFileStatus.RcvCancelled -> false
|
||||
is CIFileStatus.RcvComplete -> true
|
||||
is CIFileStatus.RcvError -> false
|
||||
}
|
||||
|
||||
val cancelAction: CancelAction? = when (fileStatus) {
|
||||
is CIFileStatus.SndStored -> sndCancelAction
|
||||
is CIFileStatus.SndTransfer -> sndCancelAction
|
||||
is CIFileStatus.SndComplete ->
|
||||
if (fileProtocol == FileProtocol.XFTP) {
|
||||
revokeCancelAction
|
||||
} else {
|
||||
null
|
||||
}
|
||||
is CIFileStatus.SndCancelled -> null
|
||||
is CIFileStatus.SndError -> null
|
||||
is CIFileStatus.RcvInvitation -> null
|
||||
is CIFileStatus.RcvAccepted -> rcvCancelAction
|
||||
is CIFileStatus.RcvTransfer -> rcvCancelAction
|
||||
is CIFileStatus.RcvCancelled -> null
|
||||
is CIFileStatus.RcvComplete -> null
|
||||
is CIFileStatus.RcvError -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -1658,21 +1774,67 @@ class CIFile(
|
||||
filePath: String? = "test.txt",
|
||||
fileStatus: CIFileStatus = CIFileStatus.RcvComplete
|
||||
): CIFile =
|
||||
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus)
|
||||
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CIFileStatus {
|
||||
@SerialName("snd_stored") SndStored,
|
||||
@SerialName("snd_transfer") SndTransfer,
|
||||
@SerialName("snd_complete") SndComplete,
|
||||
@SerialName("snd_cancelled") SndCancelled,
|
||||
@SerialName("rcv_invitation") RcvInvitation,
|
||||
@SerialName("rcv_accepted") RcvAccepted,
|
||||
@SerialName("rcv_transfer") RcvTransfer,
|
||||
@SerialName("rcv_complete") RcvComplete,
|
||||
@SerialName("rcv_cancelled") RcvCancelled;
|
||||
class CancelAction(
|
||||
val uiActionId: Int,
|
||||
val alert: AlertInfo
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class AlertInfo(
|
||||
val titleId: Int,
|
||||
val messageId: Int,
|
||||
val confirmId: Int
|
||||
)
|
||||
|
||||
private val sndCancelAction: CancelAction = CancelAction(
|
||||
uiActionId = R.string.stop_file__action,
|
||||
alert = AlertInfo(
|
||||
titleId = R.string.stop_snd_file__title,
|
||||
messageId = R.string.stop_snd_file__message,
|
||||
confirmId = R.string.stop_file__confirm
|
||||
)
|
||||
)
|
||||
private val revokeCancelAction: CancelAction = CancelAction(
|
||||
uiActionId = R.string.revoke_file__action,
|
||||
alert = AlertInfo(
|
||||
titleId = R.string.revoke_file__title,
|
||||
messageId = R.string.revoke_file__message,
|
||||
confirmId = R.string.revoke_file__confirm
|
||||
)
|
||||
)
|
||||
private val rcvCancelAction: CancelAction = CancelAction(
|
||||
uiActionId = R.string.stop_file__action,
|
||||
alert = AlertInfo(
|
||||
titleId = R.string.stop_rcv_file__title,
|
||||
messageId = R.string.stop_rcv_file__message,
|
||||
confirmId = R.string.stop_file__confirm
|
||||
)
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class FileProtocol {
|
||||
@SerialName("smp") SMP,
|
||||
@SerialName("xftp") XFTP;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CIFileStatus {
|
||||
@Serializable @SerialName("sndStored") object SndStored: CIFileStatus()
|
||||
@Serializable @SerialName("sndTransfer") class SndTransfer(val sndProgress: Long, val sndTotal: Long): CIFileStatus()
|
||||
@Serializable @SerialName("sndComplete") object SndComplete: CIFileStatus()
|
||||
@Serializable @SerialName("sndCancelled") object SndCancelled: CIFileStatus()
|
||||
@Serializable @SerialName("sndError") object SndError: CIFileStatus()
|
||||
@Serializable @SerialName("rcvInvitation") object RcvInvitation: CIFileStatus()
|
||||
@Serializable @SerialName("rcvAccepted") object RcvAccepted: CIFileStatus()
|
||||
@Serializable @SerialName("rcvTransfer") class RcvTransfer(val rcvProgress: Long, val rcvTotal: Long): CIFileStatus()
|
||||
@Serializable @SerialName("rcvComplete") object RcvComplete: CIFileStatus()
|
||||
@Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus()
|
||||
@Serializable @SerialName("rcvError") object RcvError: CIFileStatus()
|
||||
}
|
||||
|
||||
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
|
||||
@@ -1683,6 +1845,7 @@ sealed class MsgContent {
|
||||
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||
@@ -1736,6 +1899,11 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
element<String>("text")
|
||||
element<String>("image")
|
||||
})
|
||||
element("MCVideo", buildClassSerialDescriptor("MCVideo") {
|
||||
element<String>("text")
|
||||
element<String>("image")
|
||||
element<Int>("duration")
|
||||
})
|
||||
element("MCFile", buildClassSerialDescriptor("MCFile") {
|
||||
element<String>("text")
|
||||
})
|
||||
@@ -1759,6 +1927,11 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||
MsgContent.MCImage(text, image)
|
||||
}
|
||||
"video" -> {
|
||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
MsgContent.MCVideo(text, image, duration)
|
||||
}
|
||||
"voice" -> {
|
||||
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
MsgContent.MCVoice(text, duration)
|
||||
@@ -1794,6 +1967,13 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
put("text", value.text)
|
||||
put("image", value.image)
|
||||
}
|
||||
is MsgContent.MCVideo ->
|
||||
buildJsonObject {
|
||||
put("type", "video")
|
||||
put("text", value.text)
|
||||
put("image", value.image)
|
||||
put("duration", value.duration)
|
||||
}
|
||||
is MsgContent.MCVoice ->
|
||||
buildJsonObject {
|
||||
put("type", "voice")
|
||||
@@ -1929,7 +2109,11 @@ enum class CICallStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fun durationText(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
||||
fun durationText(sec: Int): String {
|
||||
val s = sec % 60
|
||||
val m = sec / 60
|
||||
return if (m < 60) "%02d:%02d".format(m, s) else "%02d:%02d:%02d".format(m / 60, m % 60, s)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class MsgErrorType() {
|
||||
|
||||
@@ -80,7 +80,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
|
||||
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
|
||||
notifyMessageReceived(
|
||||
displayNotification(
|
||||
user = user,
|
||||
chatId = cInfo.id,
|
||||
displayName = cInfo.displayName,
|
||||
@@ -91,7 +91,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
|
||||
fun notifyContactConnected(user: User, contact: Contact) {
|
||||
notifyMessageReceived(
|
||||
displayNotification(
|
||||
user = user,
|
||||
chatId = contact.id,
|
||||
displayName = contact.displayName,
|
||||
@@ -101,11 +101,11 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
|
||||
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
|
||||
if (!cInfo.ntfsEnabled) return
|
||||
|
||||
notifyMessageReceived(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||
displayNotification(user = user, 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 displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
||||
if (!user.showNotifications) return
|
||||
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,3 +27,4 @@ val WarningOrange = Color(255, 127, 0, 255)
|
||||
val WarningYellow = Color(255, 192, 0, 255)
|
||||
val FileLight = Color(183, 190, 199, 255)
|
||||
val FileDark = Color(101, 101, 106, 255)
|
||||
val MenuTextColorDark = Color.White.copy(alpha = 0.8f)
|
||||
|
||||
@@ -8,4 +8,4 @@ val Shapes = Shapes(
|
||||
small = RoundedCornerShape(4.dp),
|
||||
medium = RoundedCornerShape(4.dp),
|
||||
large = RoundedCornerShape(0.dp)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -17,6 +17,8 @@ enum class DefaultTheme {
|
||||
val DEFAULT_PADDING = 16.dp
|
||||
val DEFAULT_SPACE_AFTER_ICON = 4.dp
|
||||
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
|
||||
val DEFAULT_BOTTOM_PADDING = 48.dp
|
||||
val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp
|
||||
|
||||
val DarkColorPalette = darkColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
@@ -26,7 +28,7 @@ val DarkColorPalette = darkColors(
|
||||
// surface = Color.Black,
|
||||
// background = Color(0xFF121212),
|
||||
// surface = Color(0xFF121212),
|
||||
// error = Color(0xFFCF6679),
|
||||
error = Color.Red,
|
||||
onBackground = Color(0xFFFFFBFA),
|
||||
onSurface = Color(0xFFFFFBFA),
|
||||
// onError: Color = Color.Black,
|
||||
@@ -35,6 +37,7 @@ val LightColorPalette = lightColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = LightGray,
|
||||
error = Color.Red,
|
||||
// background = Color.White,
|
||||
// surface = Color.White
|
||||
// onPrimary = Color.White,
|
||||
@@ -73,4 +76,4 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
||||
shapes = Shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ fun TerminalLayout(
|
||||
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = sendCommand,
|
||||
sendLiveMessage = null,
|
||||
|
||||
@@ -6,19 +6,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.MaterialTheme.colors
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBackIosNew
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.*
|
||||
@@ -26,61 +29,72 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Profile
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
fun isValidDisplayName(name: String) : Boolean {
|
||||
return (name.firstOrNull { it.isWhitespace() }) == null
|
||||
return (name.firstOrNull { it.isWhitespace() }) == null && !name.startsWith("@") && !name.startsWith("#")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||
val displayName = remember { mutableStateOf("") }
|
||||
val fullName = remember { mutableStateOf("") }
|
||||
val displayName = rememberSaveable { mutableStateOf("") }
|
||||
val fullName = rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.create_profile), false)
|
||||
ReadableText(R.string.your_profile_is_stored_on_your_device)
|
||||
ReadableText(R.string.profile_is_only_shared_with_your_contacts)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
stringResource(R.string.display_name),
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
ProfileNameField(displayName, focusRequester)
|
||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
stringResource(R.string.full_name_optional__prompt),
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
/*CloseSheetBar(close = {
|
||||
if (chatModel.users.isEmpty()) {
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
})*/
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING * 1f)) {
|
||||
AppBarTitleCentered(stringResource(R.string.create_profile))
|
||||
ReadableText(R.string.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues())
|
||||
ReadableText(R.string.profile_is_only_shared_with_your_contacts, TextAlign.Center)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING * 1.5f))
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(R.string.display_name),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
if (!isValidDisplayName(displayName.value)) {
|
||||
Text(
|
||||
stringResource(R.string.no_spaces),
|
||||
fontSize = 16.sp,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Text(
|
||||
stringResource(R.string.full_name_optional__prompt),
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
ProfileNameField(fullName, "", ::isValidDisplayName)
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
if (chatModel.users.isEmpty()) {
|
||||
SimpleButton(
|
||||
SimpleButtonDecorated(
|
||||
text = stringResource(R.string.about_simplex),
|
||||
icon = Icons.Outlined.ArrowBackIosNew
|
||||
icon = Icons.Outlined.ArrowBackIosNew,
|
||||
textDecoration = TextDecoration.None,
|
||||
fontWeight = FontWeight.Medium
|
||||
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
|
||||
}
|
||||
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
val createModifier: Modifier
|
||||
val createColor: Color
|
||||
@@ -93,7 +107,7 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
||||
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor)
|
||||
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
|
||||
Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor)
|
||||
}
|
||||
}
|
||||
@@ -128,24 +142,50 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, c
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileNameField(name: MutableState<String>, focusRequester: FocusRequester? = null) {
|
||||
fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) {
|
||||
var valid by rememberSaveable { mutableStateOf(true) }
|
||||
var focused by rememberSaveable { mutableStateOf(false) }
|
||||
val strokeColor by remember {
|
||||
derivedStateOf {
|
||||
if (valid) {
|
||||
if (focused) {
|
||||
HighOrLowlight.copy(alpha = 0.6f)
|
||||
} else {
|
||||
HighOrLowlight.copy(alpha = 0.3f)
|
||||
}
|
||||
} else Color.Red
|
||||
}
|
||||
}
|
||||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.secondary)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.padding(8.dp)
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
.navigationBarsWithImePadding()
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(HighOrLowlight)
|
||||
)
|
||||
}
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(50)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(HighOrLowlight)
|
||||
)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { name.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
valid = isValid(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ class CallManager(val chatModel: ChatModel) {
|
||||
Log.d(TAG, "CallManager.reportNewIncomingCall")
|
||||
with (chatModel) {
|
||||
callInvitations[invitation.contact.id] = invitation
|
||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||
activeCallInvitation.value = invitation
|
||||
controller.ntfManager.notifyCallInvitation(invitation)
|
||||
} else {
|
||||
val contact = invitation.contact
|
||||
controller.ntfManager.notifyMessageReceived(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||
if (invitation.user.showNotifications) {
|
||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||
activeCallInvitation.value = invitation
|
||||
controller.ntfManager.notifyCallInvitation(invitation)
|
||||
} else {
|
||||
val contact = invitation.contact
|
||||
controller.ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,4 +106,4 @@ class CallManager(val chatModel: ChatModel) {
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ package chat.simplex.app.views.call
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.*
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.media.*
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.*
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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
|
||||
@@ -37,7 +39,7 @@ import androidx.webkit.WebViewClientCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
@@ -53,6 +55,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||
})
|
||||
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
|
||||
LaunchedEffect(Unit) {
|
||||
// Start service when call happening since it's not already started.
|
||||
@@ -60,17 +63,48 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
if (!ntfModeService) SimplexService.start(SimplexApp.context)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
var btDeviceCount = 0
|
||||
val audioCallback = object: AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||
Log.d(TAG, "Added audio devices: ${addedDevices.map { it.type }}")
|
||||
super.onAudioDevicesAdded(addedDevices)
|
||||
val addedCount = addedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||
btDeviceCount += addedCount
|
||||
audioViaBluetooth.value = btDeviceCount > 0
|
||||
if (addedCount > 0 && chatModel.activeCall.value?.callState == CallState.Connected) {
|
||||
// Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12
|
||||
setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth)
|
||||
}
|
||||
}
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||
Log.d(TAG, "Removed audio devices: ${removedDevices.map { it.type }}")
|
||||
super.onAudioDevicesRemoved(removedDevices)
|
||||
val removedCount = removedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||
btDeviceCount -= removedCount
|
||||
audioViaBluetooth.value = btDeviceCount > 0
|
||||
if (btDeviceCount == 0 && chatModel.activeCall.value?.callState == CallState.Connected) {
|
||||
// Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12
|
||||
setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth)
|
||||
}
|
||||
}
|
||||
}
|
||||
am.registerAudioDeviceCallback(audioCallback, null)
|
||||
val pm = (SimplexApp.context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, SimplexApp.context.packageName + ":proximityLock")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
proximityLock?.acquire()
|
||||
onDispose {
|
||||
// Stop it when call ended
|
||||
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
||||
// Clear selected communication device to default value after we changed it in call
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.clearCommunicationDevice()
|
||||
}
|
||||
dropAudioManagerOverrides()
|
||||
am.unregisterAudioDeviceCallback(audioCallback)
|
||||
proximityLock?.release()
|
||||
}
|
||||
}
|
||||
val cxt = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
WebRTCView(chatModel.callCommand) { apiMsg ->
|
||||
@@ -100,6 +134,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||
} catch (e: Error) {
|
||||
@@ -108,8 +143,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
is WCallResponse.Connected -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
||||
scope.launch {
|
||||
delay(2000L)
|
||||
setCallSound(cxt, call)
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
}
|
||||
is WCallResponse.Ended -> {
|
||||
@@ -143,15 +177,18 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) ActiveCallOverlay(call, chatModel)
|
||||
if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
val prevVolumeControlStream = activity.volumeControlStream
|
||||
activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
|
||||
// Lock orientation to portrait in order to have good experience with calls
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
onDispose {
|
||||
activity.volumeControlStream = prevVolumeControlStream
|
||||
// Unlock orientation
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
@@ -159,10 +196,10 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
|
||||
var cxt = LocalContext.current
|
||||
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetooth: MutableState<Boolean>) {
|
||||
ActiveCallOverlayLayout(
|
||||
call = call,
|
||||
speakerCanBeEnabled = !audioViaBluetooth.value,
|
||||
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
||||
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
||||
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
||||
@@ -171,38 +208,55 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
|
||||
if (call != null) {
|
||||
call = call.copy(soundSpeaker = !call.soundSpeaker)
|
||||
chatModel.activeCall.value = call
|
||||
setCallSound(cxt, call)
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
},
|
||||
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun setCallSound(cxt: Context, call: Call) {
|
||||
Log.d(TAG, "setCallSound: set audio mode")
|
||||
val am = cxt.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
if (call.soundSpeaker) {
|
||||
am.mode = AudioManager.MODE_NORMAL
|
||||
am.isSpeakerphoneOn = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }?.let {
|
||||
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val btDevice = am.availableCommunicationDevices.lastOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||
val preferredSecondaryDevice = if (speaker) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
|
||||
if (btDevice != null) {
|
||||
am.setCommunicationDevice(btDevice)
|
||||
} else if (am.communicationDevice?.type != preferredSecondaryDevice) {
|
||||
am.availableCommunicationDevices.firstOrNull { it.type == preferredSecondaryDevice }?.let {
|
||||
am.setCommunicationDevice(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
am.mode = AudioManager.MODE_IN_CALL
|
||||
am.isSpeakerphoneOn = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE }?.let {
|
||||
am.setCommunicationDevice(it)
|
||||
}
|
||||
if (audioViaBluetooth.value) {
|
||||
am.isSpeakerphoneOn = false
|
||||
am.startBluetoothSco()
|
||||
} else {
|
||||
am.stopBluetoothSco()
|
||||
am.isSpeakerphoneOn = speaker
|
||||
}
|
||||
am.isBluetoothScoOn = am.isBluetoothScoAvailableOffCall && audioViaBluetooth.value
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropAudioManagerOverrides() {
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.mode = AudioManager.MODE_NORMAL
|
||||
// Clear selected communication device to default value after we changed it in call
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
am.clearCommunicationDevice()
|
||||
} else {
|
||||
am.isSpeakerphoneOn = false
|
||||
am.stopBluetoothSco()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallOverlayLayout(
|
||||
call: Call,
|
||||
speakerCanBeEnabled: Boolean,
|
||||
dismiss: () -> Unit,
|
||||
toggleAudio: () -> Unit,
|
||||
toggleVideo: () -> Unit,
|
||||
@@ -240,7 +294,7 @@ private fun ActiveCallOverlayLayout(
|
||||
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 48.dp), contentAlignment = Alignment.CenterStart) {
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
IconButton(onClick = dismiss) {
|
||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
@@ -251,7 +305,7 @@ private fun ActiveCallOverlayLayout(
|
||||
}
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.padding(end = 32.dp)) {
|
||||
ToggleSoundButton(call, toggleSound)
|
||||
ToggleSoundButton(call, speakerCanBeEnabled, toggleSound)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,10 +315,10 @@ private fun ActiveCallOverlayLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit) {
|
||||
private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit, enabled: Boolean = true) {
|
||||
if (call.hasMedia) {
|
||||
IconButton(onClick = action) {
|
||||
Icon(icon, stringResource(iconText), tint = Color(0xFFFFFFD8), modifier = Modifier.size(40.dp))
|
||||
IconButton(onClick = action, enabled = enabled) {
|
||||
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else HighOrLowlight, modifier = Modifier.size(40.dp))
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(40.dp))
|
||||
@@ -281,11 +335,11 @@ private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleSoundButton(call: Call, toggleSound: () -> Unit) {
|
||||
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
|
||||
if (call.soundSpeaker) {
|
||||
ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound)
|
||||
ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound, enabled)
|
||||
} else {
|
||||
ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound)
|
||||
ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +539,7 @@ fun PreviewActiveCallOverlayVideo() {
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null)
|
||||
)
|
||||
),
|
||||
speakerCanBeEnabled = true,
|
||||
dismiss = {},
|
||||
toggleAudio = {},
|
||||
toggleVideo = {},
|
||||
@@ -509,6 +564,7 @@ fun PreviewActiveCallOverlayAudio() {
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null)
|
||||
)
|
||||
),
|
||||
speakerCanBeEnabled = true,
|
||||
dismiss = {},
|
||||
toggleAudio = {},
|
||||
toggleVideo = {},
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -25,6 +26,7 @@ import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -36,7 +38,6 @@ import chat.simplex.app.model.*
|
||||
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.onboarding.SimpleXLogo
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class IncomingCallActivity: ComponentActivity() {
|
||||
@@ -186,6 +187,17 @@ fun IncomingCallLockScreenAlertLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimpleXLogo() {
|
||||
Image(
|
||||
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
||||
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||
modifier = Modifier
|
||||
.padding(vertical = DEFAULT_PADDING)
|
||||
.fillMaxWidth(0.80f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
||||
Surface(
|
||||
|
||||
@@ -48,4 +48,4 @@ class SoundPlayer {
|
||||
companion object {
|
||||
val shared = SoundPlayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,9 +152,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
}
|
||||
} else if (chat.chatInfo is ChatInfo.Group) {
|
||||
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
|
||||
var groupLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
|
||||
val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
|
||||
var groupLink = link?.first
|
||||
var groupLinkMemberRole = link?.second
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
GroupChatInfoView(chatModel, groupLink, { groupLink = it }, close)
|
||||
GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
|
||||
groupLink = it.first;
|
||||
groupLinkMemberRole = it.second
|
||||
}, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,27 +198,42 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
deleteMessage = { itemId, mode ->
|
||||
withApi {
|
||||
val cInfo = chat.chatInfo
|
||||
val r = 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)
|
||||
}
|
||||
val toDeleteItem = chatModel.chatItems.firstOrNull { it.id == itemId }
|
||||
val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo)
|
||||
val groupInfo = toModerate?.first
|
||||
val groupMember = toModerate?.second
|
||||
val deletedChatItem: ChatItem?
|
||||
val toChatItem: ChatItem?
|
||||
if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) {
|
||||
val r = chatModel.controller.apiDeleteMemberChatItem(
|
||||
groupId = groupInfo.groupId,
|
||||
groupMemberId = groupMember.groupMemberId,
|
||||
itemId = itemId
|
||||
)
|
||||
deletedChatItem = r?.first
|
||||
toChatItem = r?.second
|
||||
} else {
|
||||
val r = chatModel.controller.apiDeleteChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = itemId,
|
||||
mode = mode
|
||||
)
|
||||
deletedChatItem = r?.deletedChatItem?.chatItem
|
||||
toChatItem = r?.toChatItem?.chatItem
|
||||
}
|
||||
if (toChatItem == null && deletedChatItem != null) {
|
||||
chatModel.removeChatItem(cInfo, deletedChatItem)
|
||||
} else if (toChatItem != null) {
|
||||
chatModel.upsertChatItem(cInfo, toChatItem)
|
||||
}
|
||||
}
|
||||
},
|
||||
receiveFile = { fileId ->
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
withApi { chatModel.controller.receiveFile(user, fileId) }
|
||||
}
|
||||
withApi { chatModel.controller.receiveFile(user, fileId) }
|
||||
},
|
||||
cancelFile = { fileId ->
|
||||
withApi { chatModel.controller.cancelFile(user, fileId) }
|
||||
},
|
||||
joinGroup = { groupId ->
|
||||
withApi { chatModel.controller.apiJoinGroup(groupId) }
|
||||
@@ -293,6 +313,7 @@ fun ChatLayout(
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
@@ -337,7 +358,7 @@ fun ChatLayout(
|
||||
ChatItemsList(
|
||||
chat, unreadCount, composeState, chatItems, searchValue,
|
||||
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||
receiveFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
|
||||
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -357,7 +378,7 @@ fun ChatInfoToolbar(
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var showMenu by rememberSaveable { mutableStateOf(false) }
|
||||
val showMenu = rememberSaveable { mutableStateOf(false) }
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
val onBackClicked = {
|
||||
if (!showSearch) {
|
||||
@@ -372,15 +393,15 @@ fun ChatInfoToolbar(
|
||||
val menuItems = arrayListOf<@Composable () -> Unit>()
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), Icons.Outlined.Search, onClick = {
|
||||
showMenu = false
|
||||
showMenu.value = false
|
||||
showSearch = true
|
||||
})
|
||||
}
|
||||
|
||||
if (chat.chatInfo is ChatInfo.Direct) {
|
||||
if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) {
|
||||
barButtons.add {
|
||||
IconButton({
|
||||
showMenu = false
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Audio)
|
||||
}) {
|
||||
Icon(Icons.Outlined.Phone, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
@@ -388,14 +409,14 @@ fun ChatInfoToolbar(
|
||||
}
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), Icons.Outlined.Videocam, onClick = {
|
||||
showMenu = false
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Video)
|
||||
})
|
||||
}
|
||||
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
|
||||
barButtons.add {
|
||||
IconButton({
|
||||
showMenu = false
|
||||
showMenu.value = false
|
||||
addMembers(chat.chatInfo.groupInfo)
|
||||
}) {
|
||||
Icon(Icons.Outlined.PersonAdd, stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
|
||||
@@ -408,7 +429,7 @@ fun ChatInfoToolbar(
|
||||
if (ntfsEnabled.value) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
||||
if (ntfsEnabled.value) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
|
||||
onClick = {
|
||||
showMenu = false
|
||||
showMenu.value = false
|
||||
// Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu
|
||||
scope.launch {
|
||||
delay(200)
|
||||
@@ -419,7 +440,7 @@ fun ChatInfoToolbar(
|
||||
}
|
||||
|
||||
barButtons.add {
|
||||
IconButton({ showMenu = true }) {
|
||||
IconButton({ showMenu.value = true }) {
|
||||
Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
@@ -436,11 +457,7 @@ fun ChatInfoToolbar(
|
||||
Divider(Modifier.padding(top = AppBarHeight))
|
||||
|
||||
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
Modifier.widthIn(min = 220.dp)
|
||||
) {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
menuItems.forEach { it() }
|
||||
}
|
||||
}
|
||||
@@ -510,6 +527,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||
@@ -556,6 +574,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
stopListening = true
|
||||
}
|
||||
}
|
||||
DisposableEffectOnGone(
|
||||
whenGone = {
|
||||
VideoPlayer.releaseAll()
|
||||
}
|
||||
)
|
||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
|
||||
CompositionLocalProvider(
|
||||
@@ -575,10 +598,12 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
|
||||
LaunchedEffect(Unit) {
|
||||
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) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
if (cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -618,11 +643,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, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, 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, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
@@ -633,7 +658,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, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,36 +784,27 @@ fun BoxWithConstraintsScope.FloatingButtons(
|
||||
}
|
||||
val showButtonWithCounter = topUnreadCount > 0
|
||||
val height = with(LocalDensity.current) { maxHeight.toPx() }
|
||||
var showDropDown by remember { mutableStateOf(false) }
|
||||
val showDropDown = remember { mutableStateOf(false) }
|
||||
|
||||
TopEndFloatingButton(
|
||||
Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd),
|
||||
topUnreadCount,
|
||||
showButtonWithCounter,
|
||||
onClick = { scope.launch { listState.animateScrollBy(height) } },
|
||||
onLongClick = { showDropDown = true }
|
||||
onLongClick = { showDropDown.value = true }
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropDown,
|
||||
onDismissRequest = { showDropDown = false },
|
||||
Modifier.width(220.dp),
|
||||
offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
DefaultDropdownMenu(showDropDown, offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)) {
|
||||
ItemAction(
|
||||
generalGetString(R.string.mark_read),
|
||||
Icons.Outlined.Check,
|
||||
onClick = {
|
||||
markRead(
|
||||
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
|
||||
bottomUnreadCount
|
||||
)
|
||||
showDropDown = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
generalGetString(R.string.mark_read),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
showDropDown.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -913,21 +929,26 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ProviderMedia {
|
||||
data class Image(val uri: Uri, val image: Bitmap): ProviderMedia()
|
||||
data class Video(val uri: Uri, val preview: String): ProviderMedia()
|
||||
}
|
||||
|
||||
private fun providerForGallery(
|
||||
listStateIndex: Int,
|
||||
chatItems: List<ChatItem>,
|
||||
cItemId: Long,
|
||||
scrollTo: (Int) -> Unit
|
||||
): ImageGalleryProvider {
|
||||
fun canShowImage(item: ChatItem): Boolean =
|
||||
item.content.msgContent is MsgContent.MCImage && item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null
|
||||
fun canShowMedia(item: ChatItem): Boolean =
|
||||
(item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null)
|
||||
|
||||
fun item(skipInternalIndex: Int, initialChatId: Long): Pair<Int, ChatItem>? {
|
||||
var processedInternalIndex = -skipInternalIndex.sign
|
||||
val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId }
|
||||
for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) {
|
||||
val item = chatItems[chatItemsIndex]
|
||||
if (canShowImage(item)) {
|
||||
if (canShowMedia(item)) {
|
||||
processedInternalIndex += skipInternalIndex.sign
|
||||
}
|
||||
if (processedInternalIndex == skipInternalIndex) {
|
||||
@@ -941,16 +962,28 @@ private fun providerForGallery(
|
||||
var initialChatId = cItemId
|
||||
return object: ImageGalleryProvider {
|
||||
override val initialIndex: Int = initialIndex
|
||||
override val totalImagesSize = mutableStateOf(Int.MAX_VALUE)
|
||||
override fun getImage(index: Int): Pair<Bitmap, Uri>? {
|
||||
override val totalMediaSize = mutableStateOf(Int.MAX_VALUE)
|
||||
override fun getMedia(index: Int): ProviderMedia? {
|
||||
val internalIndex = initialIndex - index
|
||||
val file = item(internalIndex, initialChatId)?.second?.file
|
||||
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, file)
|
||||
return if (imageBitmap != null && filePath != null) {
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
imageBitmap to uri
|
||||
} else null
|
||||
val item = item(internalIndex, initialChatId)?.second ?: return null
|
||||
return when (item.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, item.file)
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, item.file)
|
||||
if (imageBitmap != null && filePath != null) {
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
ProviderMedia.Image(uri, imageBitmap)
|
||||
} else null
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, item.file)
|
||||
if (filePath != null) {
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image)
|
||||
} else null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun currentPageChanged(index: Int) {
|
||||
@@ -962,7 +995,7 @@ private fun providerForGallery(
|
||||
|
||||
override fun scrollToStart() {
|
||||
initialIndex = 0
|
||||
initialChatId = chatItems.first { canShowImage(it) }.id
|
||||
initialChatId = chatItems.first { canShowMedia(it) }.id
|
||||
}
|
||||
|
||||
override fun onDismiss(index: Int) {
|
||||
@@ -1040,6 +1073,7 @@ fun PreviewChatLayout() {
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
@@ -1099,6 +1133,7 @@ fun PreviewGroupChatLayout() {
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
|
||||
@@ -4,22 +4,29 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.DEFAULT_PADDING_HALF
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
import chat.simplex.app.views.helpers.UploadContent
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
|
||||
@Composable
|
||||
fun ComposeImageView(images: List<String>, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
||||
fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
@@ -31,13 +38,32 @@ fun ComposeImageView(images: List<String>, cancelImages: () -> Unit, cancelEnabl
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF),
|
||||
) {
|
||||
items(images.size) { index ->
|
||||
val imageBitmap = base64ToBitmap(images[index]).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"preview image",
|
||||
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||
)
|
||||
itemsIndexed(media.images) { index, item ->
|
||||
val content = media.content[index]
|
||||
if (content is UploadContent.Video) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val imageBitmap = base64ToBitmap(item).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"preview video",
|
||||
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.Videocam,
|
||||
"preview video",
|
||||
Modifier
|
||||
.size(20.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val imageBitmap = base64ToBitmap(item).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"preview image",
|
||||
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cancelEnabled) {
|
||||
|
||||
@@ -7,13 +7,12 @@ import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.ImageDecoder.DecodeException
|
||||
import android.graphics.*
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
@@ -50,7 +49,7 @@ import java.nio.file.Files
|
||||
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 class MediaPreview(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()
|
||||
}
|
||||
@@ -96,7 +95,7 @@ data class ComposeState(
|
||||
val sendEnabled: () -> Boolean
|
||||
get() = {
|
||||
val hasContent = when (preview) {
|
||||
is ComposePreview.ImagePreview -> true
|
||||
is ComposePreview.MediaPreview -> true
|
||||
is ComposePreview.VoicePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty() || liveMessage != null
|
||||
@@ -109,7 +108,7 @@ data class ComposeState(
|
||||
val linkPreviewAllowed: Boolean
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.ImagePreview -> false
|
||||
is ComposePreview.MediaPreview -> false
|
||||
is ComposePreview.VoicePreview -> false
|
||||
is ComposePreview.FilePreview -> false
|
||||
else -> useLinkPreviews
|
||||
@@ -159,7 +158,8 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
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.MCImage -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
|
||||
is MsgContent.MCVideo -> ComposePreview.MediaPreview(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.MCUnknown, null -> ComposePreview.NoPreview
|
||||
@@ -180,14 +180,16 @@ fun ComposeView(
|
||||
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
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))))
|
||||
val bitmap: Bitmap? = getBitmapFromUri(uri)
|
||||
if (bitmap != null) {
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
|
||||
}
|
||||
}
|
||||
}
|
||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
@@ -197,50 +199,56 @@ fun ComposeView(
|
||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val processPickedImage = { uris: List<Uri>, text: String? ->
|
||||
val processPickedMedia = { uris: List<Uri>, text: String? ->
|
||||
val content = ArrayList<UploadContent>()
|
||||
val imagesPreview = ArrayList<String>()
|
||||
uris.forEach { uri ->
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val drawable = try {
|
||||
ImageDecoder.decodeDrawable(source)
|
||||
} catch (e: DecodeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.image_decoding_exception_title),
|
||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||
)
|
||||
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
||||
null
|
||||
}
|
||||
var bitmap: Bitmap? = if (drawable != null) ImageDecoder.decodeBitmap(source) else null
|
||||
if (drawable is AnimatedImageDrawable) {
|
||||
// It's a gif or webp
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
||||
content.add(UploadContent.AnimatedImage(uri))
|
||||
} else {
|
||||
bitmap = null
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
||||
)
|
||||
var bitmap: Bitmap? = null
|
||||
val isImage = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(SimplexApp.context, uri)?.split(".")?.last())?.contains("image/") == true
|
||||
when {
|
||||
isImage -> {
|
||||
// Image
|
||||
val drawable = getDrawableFromUri(uri)
|
||||
bitmap = if (drawable != null) getBitmapFromUri(uri) else null
|
||||
val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable
|
||||
val isAnimOldApi = Build.VERSION.SDK_INT < 28 &&
|
||||
(getFileName(SimplexApp.context, uri)?.endsWith(".gif") == true || getFileName(SimplexApp.context, uri)?.endsWith(".webp") == true)
|
||||
if (isAnimNewApi || isAnimOldApi) {
|
||||
// It's a gif or webp
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= maxFileSize) {
|
||||
content.add(UploadContent.AnimatedImage(uri))
|
||||
} else {
|
||||
bitmap = null
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
)
|
||||
}
|
||||
} else {
|
||||
content.add(UploadContent.SimpleImage(uri))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Video
|
||||
val res = getBitmapFromVideo(uri)
|
||||
bitmap = res.preview
|
||||
val durationMs = res.duration
|
||||
content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0))
|
||||
}
|
||||
} else {
|
||||
content.add(UploadContent.SimpleImage(uri))
|
||||
}
|
||||
if (bitmap != null) {
|
||||
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
|
||||
}
|
||||
}
|
||||
|
||||
if (imagesPreview.isNotEmpty()) {
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview, content))
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content))
|
||||
}
|
||||
}
|
||||
val processPickedFile = { uri: Uri?, text: String? ->
|
||||
if (uri != null) {
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
||||
if (fileSize != null && fileSize <= maxFileSize) {
|
||||
val fileName = getFileName(SimplexApp.context, uri)
|
||||
if (fileName != null) {
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri))
|
||||
@@ -248,19 +256,22 @@ fun ComposeView(
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
|
||||
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
||||
val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedMedia(it, null) }
|
||||
val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
|
||||
val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedMedia(it, null) }
|
||||
val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
|
||||
|
||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
||||
|
||||
LaunchedEffect(attachmentOption.value) {
|
||||
when (attachmentOption.value) {
|
||||
AttachmentOption.TakePhoto -> {
|
||||
AttachmentOption.CameraPhoto -> {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||
cameraLauncher.launchWithFallback()
|
||||
@@ -271,15 +282,23 @@ fun ComposeView(
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.PickImage -> {
|
||||
AttachmentOption.GalleryImage -> {
|
||||
try {
|
||||
galleryLauncher.launch(0)
|
||||
galleryImageLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryLauncherFallback.launch("image/*")
|
||||
galleryImageLauncherFallback.launch("image/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.PickFile -> {
|
||||
AttachmentOption.GalleryVideo -> {
|
||||
try {
|
||||
galleryVideoLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryVideoLauncherFallback.launch("video/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.File -> {
|
||||
filesLauncher.launch("*/*")
|
||||
attachmentOption.value = null
|
||||
}
|
||||
@@ -396,6 +415,7 @@ fun ComposeView(
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
|
||||
is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration)
|
||||
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)
|
||||
@@ -435,15 +455,20 @@ fun ComposeView(
|
||||
when (val preview = cs.preview) {
|
||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
|
||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||
is ComposePreview.ImagePreview -> {
|
||||
is ComposePreview.MediaPreview -> {
|
||||
preview.content.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
is UploadContent.Video -> saveFileFromUri(context, it.uri)
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index]))
|
||||
if (it is UploadContent.Video) {
|
||||
msgs.add(MsgContent.MCVideo(if (preview.content.lastIndex == index) msgText else "", preview.images[index], it.duration))
|
||||
} else {
|
||||
msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,7 +502,11 @@ fun ComposeView(
|
||||
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)) {
|
||||
if (sent == null &&
|
||||
(cs.preview is ComposePreview.MediaPreview ||
|
||||
cs.preview is ComposePreview.FilePreview ||
|
||||
cs.preview is ComposePreview.VoicePreview)
|
||||
) {
|
||||
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
|
||||
}
|
||||
}
|
||||
@@ -599,8 +628,8 @@ fun ComposeView(
|
||||
when (val preview = composeState.value.preview) {
|
||||
ComposePreview.NoPreview -> {}
|
||||
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
|
||||
is ComposePreview.ImagePreview -> ComposeImageView(
|
||||
preview.images,
|
||||
is ComposePreview.MediaPreview -> ComposeImageView(
|
||||
preview,
|
||||
::cancelImages,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
@@ -638,13 +667,16 @@ fun ComposeView(
|
||||
|
||||
when (val shared = chatModel.sharedContent.value) {
|
||||
is SharedContent.Text -> onMessageChange(shared.text)
|
||||
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
|
||||
is SharedContent.Media -> processPickedMedia(shared.uris, shared.text)
|
||||
is SharedContent.File -> processPickedFile(shared.uri, shared.text)
|
||||
null -> {}
|
||||
}
|
||||
chatModel.sharedContent.value = null
|
||||
}
|
||||
|
||||
val userCanSend = rememberUpdatedState(chat.userCanSend)
|
||||
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
|
||||
|
||||
Column {
|
||||
contextItemView()
|
||||
when {
|
||||
@@ -656,11 +688,11 @@ fun ComposeView(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) {
|
||||
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
|
||||
Icon(
|
||||
Icons.Filled.AttachFile,
|
||||
contentDescription = stringResource(R.string.attach),
|
||||
tint = if (!composeState.value.attachmentDisabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
tint = if (!composeState.value.attachmentDisabled && userCanSend.value) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
@@ -698,6 +730,13 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
|
||||
if (!chat.userCanSend) {
|
||||
clearCurrentDraft()
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
|
||||
val activity = LocalContext.current as Activity
|
||||
DisposableEffect(Unit) {
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
@@ -733,6 +772,8 @@ fun ComposeView(
|
||||
needToAllowVoiceToContact,
|
||||
allowedVoiceByPrefs,
|
||||
allowVoiceToContact = ::allowVoiceToContact,
|
||||
userIsObserver = userIsObserver.value,
|
||||
userCanSend = userCanSend.value,
|
||||
sendMessage = {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
@@ -759,7 +800,7 @@ class PickFromGallery: ActivityResultContract<Int, Uri?>() {
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
|
||||
}
|
||||
|
||||
class PickMultipleFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
class PickMultipleImagesFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
@@ -784,3 +825,30 @@ class PickMultipleFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
|
||||
|
||||
class PickMultipleVideosFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Video.Media.INTERNAL_CONTENT_URI).apply {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
type = "video/*"
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
|
||||
if (intent?.data != null)
|
||||
listOf(intent.data!!)
|
||||
else if (intent?.clipData != null)
|
||||
with(intent.clipData!!) {
|
||||
val uris = ArrayList<Uri>()
|
||||
for (i in 0 until kotlin.math.min(itemCount, 10)) {
|
||||
val uri = getItemAt(i).uri
|
||||
if (uri != null) uris.add(uri)
|
||||
}
|
||||
if (itemCount > 10) {
|
||||
AlertManager.shared.showAlertMsg(R.string.videos_limit_title, R.string.videos_limit_desc)
|
||||
}
|
||||
uris
|
||||
}
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ private fun ContactPreferencesLayout(
|
||||
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))
|
||||
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl))
|
||||
}
|
||||
TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl ->
|
||||
applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL))
|
||||
@@ -104,6 +104,11 @@ private fun ContactPreferencesLayout(
|
||||
applyPrefs(featuresAllowed.copy(voice = it))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowCalls: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.calls) }
|
||||
FeatureSection(ChatFeature.Calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, allowCalls) {
|
||||
applyPrefs(featuresAllowed.copy(calls = it))
|
||||
}
|
||||
SectionSpacer()
|
||||
ResetSaveButtons(
|
||||
reset = reset,
|
||||
save = savePrefs,
|
||||
@@ -138,6 +143,7 @@ private fun FeatureSection(
|
||||
ContactFeatureAllowed.values(userDefault).map { it to it.text },
|
||||
allowFeature,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(feature != ChatFeature.Calls) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
@@ -147,7 +153,7 @@ private fun FeatureSection(
|
||||
pref.contactPreference.allow.text
|
||||
)
|
||||
}
|
||||
SectionTextFooter(feature.enabledDescription(enabled))
|
||||
SectionTextFooter(feature.enabledDescription(enabled) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else ""))
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -8,10 +8,12 @@ import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.*
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -50,6 +52,7 @@ 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.lang.reflect.Field
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
@@ -60,6 +63,8 @@ fun SendMsgView(
|
||||
liveMessageAlertShown: SharedPreference<Boolean>,
|
||||
needToAllowVoiceToContact: Boolean,
|
||||
allowedVoiceByPrefs: Boolean,
|
||||
userIsObserver: Boolean,
|
||||
userCanSend: Boolean,
|
||||
allowVoiceToContact: () -> Unit,
|
||||
sendMessage: () -> Unit,
|
||||
sendLiveMessage: (suspend () -> Unit)? = null,
|
||||
@@ -70,14 +75,22 @@ fun SendMsgView(
|
||||
) {
|
||||
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 showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || 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)
|
||||
NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
|
||||
// Disable clicks on text field
|
||||
if (cs.preview is ComposePreview.VoicePreview) {
|
||||
Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { }))
|
||||
if (cs.preview is ComposePreview.VoicePreview || !userCanSend) {
|
||||
Box(Modifier
|
||||
.matchParentSize()
|
||||
.clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.observer_cant_send_message_title),
|
||||
text = generalGetString(R.string.observer_cant_send_message_desc)
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
if (showDeleteTextButton.value) {
|
||||
DeleteTextButton(composeState)
|
||||
@@ -99,11 +112,11 @@ fun SendMsgView(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
||||
when {
|
||||
needToAllowVoiceToContact || !allowedVoiceByPrefs -> {
|
||||
DisallowedVoiceButton {
|
||||
needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> {
|
||||
DisallowedVoiceButton(userCanSend) {
|
||||
if (needToAllowVoiceToContact) {
|
||||
showNeedToAllowVoiceAlert(allowVoiceToContact)
|
||||
} else {
|
||||
} else if (!allowedVoiceByPrefs) {
|
||||
showDisabledVoiceAlert(isDirectChat)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +131,7 @@ fun SendMsgView(
|
||||
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
|
||||
&& cs.contextItem is ComposeContextItem.NoContextItem) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
StartLiveMessageButton {
|
||||
StartLiveMessageButton(userCanSend) {
|
||||
if (composeState.value.preview is ComposePreview.NoPreview) {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
}
|
||||
@@ -142,20 +155,18 @@ fun SendMsgView(
|
||||
cs.contextItem is ComposeContextItem.NoContextItem &&
|
||||
sendLiveMessage != null && updateLiveMessage != null
|
||||
) {
|
||||
var showDropdown by rememberSaveable { mutableStateOf(false) }
|
||||
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown = true }
|
||||
val showDropdown = rememberSaveable { mutableStateOf(false) }
|
||||
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true }
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropdown,
|
||||
onDismissRequest = { showDropdown = false },
|
||||
Modifier.width(220.dp),
|
||||
DefaultDropdownMenu(
|
||||
showDropdown,
|
||||
) {
|
||||
ItemAction(
|
||||
generalGetString(R.string.send_live_message),
|
||||
Icons.Filled.Bolt,
|
||||
onClick = {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
showDropdown = false
|
||||
showDropdown.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -173,6 +184,7 @@ private fun NativeKeyboard(
|
||||
composeState: MutableState<ComposeState>,
|
||||
textStyle: MutableState<TextStyle>,
|
||||
showDeleteTextButton: MutableState<Boolean>,
|
||||
userIsObserver: Boolean,
|
||||
onMessageChange: (String) -> Unit
|
||||
) {
|
||||
val cs = composeState.value
|
||||
@@ -213,7 +225,7 @@ private fun NativeKeyboard(
|
||||
} catch (e: Exception) {
|
||||
return@OnCommitContentListener false
|
||||
}
|
||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Media("", listOf(inputContentInfo.contentUri))
|
||||
true
|
||||
}
|
||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||
@@ -229,7 +241,17 @@ private fun NativeKeyboard(
|
||||
editText.background = drawable
|
||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||
editText.setText(cs.message)
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||
} else {
|
||||
try {
|
||||
val f: Field = TextView::class.java.getDeclaredField("mCursorDrawableRes")
|
||||
f.isAccessible = true
|
||||
f.set(editText, R.drawable.edit_text_cursor)
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||
editText
|
||||
@@ -253,15 +275,22 @@ private fun NativeKeyboard(
|
||||
showDeleteTextButton.value = it.lineCount >= 4
|
||||
}
|
||||
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
Text(
|
||||
generalGetString(R.string.voice_message_send_text),
|
||||
Modifier.padding(padding),
|
||||
color = HighOrLowlight,
|
||||
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||
)
|
||||
ComposeOverlay(R.string.voice_message_send_text, textStyle, padding)
|
||||
} else if (userIsObserver) {
|
||||
ComposeOverlay(R.string.you_are_observer, textStyle, padding)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComposeOverlay(textId: Int, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
|
||||
Text(
|
||||
generalGetString(textId),
|
||||
Modifier.padding(padding),
|
||||
color = HighOrLowlight,
|
||||
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>) {
|
||||
IconButton(
|
||||
@@ -322,8 +351,8 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisallowedVoiceButton(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp), enabled = enabled) {
|
||||
Icon(
|
||||
Icons.Outlined.KeyboardVoice,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
@@ -454,13 +483,13 @@ private fun SendMsgButton(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartLiveMessageButton(onClick: () -> Unit) {
|
||||
private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = Modifier.requiredSize(36.dp)
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
enabled = true,
|
||||
enabled = enabled,
|
||||
role = Role.Button,
|
||||
interactionSource = interactionSource,
|
||||
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||
@@ -470,7 +499,7 @@ private fun StartLiveMessageButton(onClick: () -> Unit) {
|
||||
Icon(
|
||||
Icons.Filled.Bolt,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
tint = if (enabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
@@ -571,6 +600,8 @@ fun PreviewSendMsgView() {
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
@@ -599,6 +630,8 @@ fun PreviewSendMsgViewEditing() {
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
@@ -627,6 +660,8 @@ fun PreviewSendMsgViewInProgress() {
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
|
||||
@@ -134,4 +134,4 @@ private fun splitToParts(s: String, length: Int): String {
|
||||
return (0..(s.length - 1) / length)
|
||||
.map { s.drop(it * length).take(length) }
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.InfoAboutIncognito
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
|
||||
@Composable
|
||||
@@ -37,6 +38,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
|
||||
var allowModifyMembers by remember { mutableStateOf(true) }
|
||||
BackHandler(onBack = close)
|
||||
AddGroupMembersLayout(
|
||||
chatModel.incognito.value,
|
||||
groupInfo = groupInfo,
|
||||
creatingGroup = creatingGroup,
|
||||
contactsToAdd = getContactsToAdd(chatModel),
|
||||
@@ -85,6 +87,7 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
|
||||
|
||||
@Composable
|
||||
fun AddGroupMembersLayout(
|
||||
chatModelIncognito: Boolean,
|
||||
groupInfo: GroupInfo,
|
||||
creatingGroup: Boolean,
|
||||
contactsToAdd: List<Contact>,
|
||||
@@ -105,6 +108,14 @@ fun AddGroupMembersLayout(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.button_add_members))
|
||||
InfoAboutIncognito(
|
||||
chatModelIncognito,
|
||||
false,
|
||||
generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
|
||||
generalGetString(R.string.group_main_profile_sent),
|
||||
true
|
||||
)
|
||||
Spacer(Modifier.size(DEFAULT_PADDING))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
@@ -318,6 +329,7 @@ fun showProhibitedToInviteIncognitoAlertDialog() {
|
||||
fun PreviewAddGroupMembersLayout() {
|
||||
SimpleXTheme {
|
||||
AddGroupMembersLayout(
|
||||
chatModelIncognito = false,
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
creatingGroup = false,
|
||||
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
||||
|
||||
@@ -34,7 +34,7 @@ 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, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
@@ -82,6 +82,9 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdat
|
||||
editGroupProfile = {
|
||||
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
||||
},
|
||||
addOrEditWelcomeMessage = {
|
||||
ModalManager.shared.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) }
|
||||
},
|
||||
openPreferences = {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
GroupPreferencesView(
|
||||
@@ -95,9 +98,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdat
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||
manageGroupLink = {
|
||||
withApi {
|
||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, onGroupLinkUpdated) }
|
||||
}
|
||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -149,6 +150,7 @@ fun GroupChatInfoLayout(
|
||||
addMembers: () -> Unit,
|
||||
showMemberInfo: (GroupMember) -> Unit,
|
||||
editGroupProfile: () -> Unit,
|
||||
addOrEditWelcomeMessage: () -> Unit,
|
||||
openPreferences: () -> Unit,
|
||||
deleteGroup: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
@@ -173,6 +175,8 @@ fun GroupChatInfoLayout(
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
|
||||
SectionDivider()
|
||||
SectionItemView(addOrEditWelcomeMessage) { AddOrEditWelcomeMessage(groupInfo.groupProfile.description) }
|
||||
SectionDivider()
|
||||
}
|
||||
GroupPreferencesButton(openPreferences)
|
||||
}
|
||||
@@ -300,6 +304,7 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
Modifier.weight(1f).padding(end = DEFAULT_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
@@ -388,6 +393,28 @@ fun EditGroupProfileButton() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddOrEditWelcomeMessage(welcomeMessage: String?) {
|
||||
val text = if (welcomeMessage == null) {
|
||||
stringResource(R.string.button_add_welcome_message)
|
||||
} else {
|
||||
stringResource(R.string.button_welcome_message)
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.MapsUgc,
|
||||
text,
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveGroupButton() {
|
||||
Row(
|
||||
@@ -433,7 +460,7 @@ fun PreviewGroupChatInfoLayout() {
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
groupLink = null,
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -15,22 +18,26 @@ import androidx.compose.ui.res.stringResource
|
||||
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.model.GroupInfo
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
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) {
|
||||
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit) {
|
||||
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
|
||||
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
|
||||
var creatingLink by rememberSaveable { mutableStateOf(false) }
|
||||
val cxt = LocalContext.current
|
||||
fun createLink() {
|
||||
creatingLink = true
|
||||
withApi {
|
||||
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||
onGroupLinkUpdated(groupLink)
|
||||
val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||
if (link != null) {
|
||||
groupLink = link.first
|
||||
groupLinkMemberRole.value = link.second
|
||||
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
|
||||
}
|
||||
creatingLink = false
|
||||
}
|
||||
}
|
||||
@@ -41,9 +48,24 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
||||
}
|
||||
GroupLinkLayout(
|
||||
groupLink = groupLink,
|
||||
groupInfo,
|
||||
groupLinkMemberRole,
|
||||
creatingLink,
|
||||
createLink = ::createLink,
|
||||
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
|
||||
updateLink = {
|
||||
val role = groupLinkMemberRole.value
|
||||
if (role != null) {
|
||||
withBGApi {
|
||||
val link = chatModel.controller.apiGroupLinkMemberRole(groupInfo.groupId, role)
|
||||
if (link != null) {
|
||||
groupLink = link.first
|
||||
groupLinkMemberRole.value = link.second
|
||||
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteLink = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.delete_link_question),
|
||||
@@ -54,7 +76,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
||||
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
|
||||
if (r) {
|
||||
groupLink = null
|
||||
onGroupLinkUpdated(null)
|
||||
onGroupLinkUpdated(null to null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,13 +91,18 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
||||
@Composable
|
||||
fun GroupLinkLayout(
|
||||
groupLink: String?,
|
||||
groupInfo: GroupInfo,
|
||||
groupLinkMemberRole: MutableState<GroupMemberRole?>,
|
||||
creatingLink: Boolean,
|
||||
createLink: () -> Unit,
|
||||
share: () -> Unit,
|
||||
updateLink: () -> Unit,
|
||||
deleteLink: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = DEFAULT_PADDING, bottom = DEFAULT_BOTTOM_PADDING, end = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
@@ -93,7 +120,17 @@ fun GroupLinkLayout(
|
||||
if (groupLink == null) {
|
||||
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink)
|
||||
} else {
|
||||
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f))
|
||||
SectionItemView(padding = PaddingValues(bottom = DEFAULT_PADDING)) {
|
||||
RoleSelectionRow(groupInfo, groupLinkMemberRole)
|
||||
}
|
||||
var initialLaunch by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(groupLinkMemberRole.value) {
|
||||
if (!initialLaunch) {
|
||||
updateLink()
|
||||
}
|
||||
initialLaunch = false
|
||||
}
|
||||
QRCode(groupLink, Modifier.aspectRatio(1f))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -116,6 +153,25 @@ fun GroupLinkLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole?>, enabled: Boolean = true) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.initial_member_role),
|
||||
values,
|
||||
selectedRole,
|
||||
icon = null,
|
||||
enabled = rememberUpdatedState(enabled),
|
||||
onSelected = { selectedRole.value = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProgressIndicator() {
|
||||
Box(
|
||||
|
||||
@@ -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,7 +21,6 @@ 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.*
|
||||
@@ -54,20 +51,15 @@ fun GroupMemberInfoView(
|
||||
developerTools,
|
||||
connectionCode,
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
knownDirectChat = {
|
||||
withApi {
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(it.chatItems)
|
||||
chatModel.chatId.value = it.chatInfo.id
|
||||
closeAll()
|
||||
}
|
||||
},
|
||||
newDirectChat = {
|
||||
openDirectChat = {
|
||||
withApi {
|
||||
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||
if (c != null) {
|
||||
chatModel.addChat(c)
|
||||
if (chatModel.getContactChat(it) == null) {
|
||||
chatModel.addChat(c)
|
||||
}
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(c.chatItems)
|
||||
chatModel.chatId.value = c.id
|
||||
closeAll()
|
||||
}
|
||||
@@ -150,8 +142,7 @@ fun GroupMemberInfoLayout(
|
||||
developerTools: Boolean,
|
||||
connectionCode: String?,
|
||||
getContactChat: (Long) -> Chat?,
|
||||
knownDirectChat: (Chat) -> Unit,
|
||||
newDirectChat: (Long) -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
removeMember: () -> Unit,
|
||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||
switchMemberAddress: () -> Unit,
|
||||
@@ -176,13 +167,8 @@ fun GroupMemberInfoLayout(
|
||||
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 ((chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) || groupInfo.fullGroupPreferences.directMessages.on) {
|
||||
OpenChatButton(onClick = { openDirectChat(contactId) })
|
||||
if (connectionCode != null) {
|
||||
SectionDivider()
|
||||
}
|
||||
@@ -364,8 +350,7 @@ fun PreviewGroupMemberInfoLayout() {
|
||||
developerTools = false,
|
||||
connectionCode = "123",
|
||||
getContactChat = { Chat.sampleData },
|
||||
knownDirectChat = {},
|
||||
newDirectChat = {},
|
||||
openDirectChat = {},
|
||||
removeMember = {},
|
||||
onRoleSelected = {},
|
||||
switchMemberAddress = {},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -14,6 +13,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -23,6 +23,7 @@ import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.ProfileNameField
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
@@ -53,14 +54,32 @@ fun GroupProfileLayout(
|
||||
saveProfile: (GroupProfile) -> Unit,
|
||||
) {
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val displayName = remember { mutableStateOf(groupProfile.displayName) }
|
||||
val fullName = remember { mutableStateOf(groupProfile.fullName) }
|
||||
val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) }
|
||||
val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) }
|
||||
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
val dataUnchanged =
|
||||
displayName.value == groupProfile.displayName &&
|
||||
fullName.value == groupProfile.fullName &&
|
||||
chosenImage.value == null
|
||||
val closeWithAlert = {
|
||||
if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) {
|
||||
close()
|
||||
} else {
|
||||
showUnsavedChangesAlert({
|
||||
saveProfile(
|
||||
groupProfile.copy(
|
||||
displayName = displayName.value,
|
||||
fullName = fullName.value,
|
||||
image = profileImage.value
|
||||
)
|
||||
)
|
||||
}, close)
|
||||
}
|
||||
}
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
@@ -76,23 +95,16 @@ fun GroupProfileLayout(
|
||||
sheetState = bottomSheetModalState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
ModalView(close = close) {
|
||||
ModalView(close = closeWithAlert) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.group_profile_is_stored_on_members_devices),
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
ReadableText(R.string.group_profile_is_stored_on_members_devices, TextAlign.Center)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -101,7 +113,7 @@ fun GroupProfileLayout(
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(192.dp, profileImage.value)
|
||||
ProfileImage(108.dp, profileImage.value, color = HighOrLowlight.copy(alpha = 0.1f))
|
||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||
}
|
||||
if (profileImage.value != null) {
|
||||
@@ -109,51 +121,54 @@ fun GroupProfileLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.group_display_name_field),
|
||||
Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
ProfileNameField(displayName, focusRequester)
|
||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
stringResource(R.string.group_full_name_field),
|
||||
Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row {
|
||||
TextButton(stringResource(R.string.cancel_verb)) {
|
||||
close.invoke()
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
if (enabled) {
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(R.string.group_display_name_field),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
if (!isValidDisplayName(displayName.value)) {
|
||||
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||
Text(
|
||||
stringResource(R.string.save_group_profile),
|
||||
modifier = Modifier.clickable {
|
||||
saveProfile(groupProfile.copy(
|
||||
displayName = displayName.value,
|
||||
fullName = fullName.value,
|
||||
image = profileImage.value
|
||||
))
|
||||
},
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.save_group_profile),
|
||||
color = HighOrLowlight
|
||||
stringResource(R.string.no_spaces),
|
||||
fontSize = 16.sp,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Text(
|
||||
stringResource(R.string.group_full_name_field),
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
if (enabled) {
|
||||
Text(
|
||||
stringResource(R.string.save_group_profile),
|
||||
modifier = Modifier.clickable {
|
||||
saveProfile(
|
||||
groupProfile.copy(
|
||||
displayName = displayName.value,
|
||||
fullName = fullName.value,
|
||||
image = profileImage.value
|
||||
)
|
||||
)
|
||||
},
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.save_group_profile),
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(DEFAULT_BOTTOM_BUTTON_PADDING))
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
@@ -164,6 +179,16 @@ fun GroupProfileLayout(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
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.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
|
||||
var groupInfo by remember { mutableStateOf(groupInfo) }
|
||||
val welcomeText = remember { mutableStateOf(groupInfo.groupProfile.description ?: "") }
|
||||
|
||||
fun save(afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
var welcome: String? = welcomeText.value.trim('\n', ' ')
|
||||
if (welcome?.length == 0) {
|
||||
welcome = null
|
||||
}
|
||||
val groupProfileUpdated = groupInfo.groupProfile.copy(description = welcome)
|
||||
val res = m.controller.apiUpdateGroup(groupInfo.groupId, groupProfileUpdated)
|
||||
if (res != null) {
|
||||
groupInfo = res
|
||||
m.updateGroup(res)
|
||||
welcomeText.value = welcome ?: ""
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
|
||||
ModalView(
|
||||
close = {
|
||||
if (welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)) close()
|
||||
else showUnsavedChangesAlert({ save(close) }, close)
|
||||
},
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
GroupWelcomeLayout(
|
||||
welcomeText,
|
||||
groupInfo,
|
||||
save = ::save
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupWelcomeLayout(
|
||||
welcomeText: MutableState<String>,
|
||||
groupInfo: GroupInfo,
|
||||
save: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.group_welcome_title))
|
||||
val welcomeText = remember { welcomeText }
|
||||
TextEditor(Modifier.padding(horizontal = DEFAULT_PADDING).height(160.dp), text = welcomeText)
|
||||
SectionSpacer()
|
||||
SaveButton(
|
||||
save = save,
|
||||
disabled = welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveButton(save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
SectionItemView(save, disabled = disabled) {
|
||||
Text(stringResource(R.string.save_and_update_group_profile), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_welcome_message_question),
|
||||
confirmText = generalGetString(R.string.save_and_update_group_profile),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
@@ -154,4 +154,4 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
||||
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//}
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.chat.item
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -16,6 +17,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -64,7 +66,7 @@ fun CIFileView(
|
||||
|
||||
fun fileSizeValid(): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= MAX_FILE_SIZE
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -72,22 +74,30 @@ fun CIFileView(
|
||||
fun fileAction() {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation -> {
|
||||
is CIFileStatus.RcvInvitation -> {
|
||||
if (fileSizeValid()) {
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
)
|
||||
}
|
||||
}
|
||||
CIFileStatus.RcvAccepted ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_file),
|
||||
String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
|
||||
)
|
||||
CIFileStatus.RcvComplete -> {
|
||||
is CIFileStatus.RcvAccepted ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_file),
|
||||
generalGetString(R.string.file_will_be_received_when_contact_completes_uploading)
|
||||
)
|
||||
FileProtocol.SMP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_file),
|
||||
generalGetString(R.string.file_will_be_received_when_contact_is_online)
|
||||
)
|
||||
}
|
||||
is CIFileStatus.RcvComplete -> {
|
||||
val filePath = getLoadedFilePath(context, file)
|
||||
if (filePath != null) {
|
||||
saveFileLauncher.launch(file.fileName)
|
||||
@@ -105,10 +115,24 @@ fun CIFileView(
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(32.dp),
|
||||
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||
strokeWidth = 4.dp
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun progressCircle(progress: Long, total: Long) {
|
||||
val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat()
|
||||
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
|
||||
val strokeColor = if (isInDarkTheme()) FileDark else FileLight
|
||||
Surface(
|
||||
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||
color = Color.Transparent,
|
||||
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||
) {
|
||||
Box(Modifier.size(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun fileIndicator() {
|
||||
Box(
|
||||
@@ -120,19 +144,34 @@ fun CIFileView(
|
||||
) {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.SndStored -> fileIcon()
|
||||
CIFileStatus.SndTransfer -> progressIndicator()
|
||||
CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
|
||||
CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
CIFileStatus.RcvInvitation ->
|
||||
is CIFileStatus.SndStored ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressIndicator()
|
||||
FileProtocol.SMP -> fileIcon()
|
||||
}
|
||||
is CIFileStatus.SndTransfer ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||
FileProtocol.SMP -> progressIndicator()
|
||||
}
|
||||
is CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
|
||||
is CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
is CIFileStatus.SndError -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
is CIFileStatus.RcvInvitation ->
|
||||
if (fileSizeValid())
|
||||
fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary)
|
||||
else
|
||||
fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
|
||||
CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
|
||||
CIFileStatus.RcvTransfer -> progressIndicator()
|
||||
CIFileStatus.RcvComplete -> fileIcon()
|
||||
CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
|
||||
is CIFileStatus.RcvTransfer ->
|
||||
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||
} else {
|
||||
progressIndicator()
|
||||
}
|
||||
is CIFileStatus.RcvComplete -> fileIcon()
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
is CIFileStatus.RcvError -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
}
|
||||
} else {
|
||||
fileIcon()
|
||||
@@ -191,7 +230,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||
ChatItem.getFileMsgContentSample(),
|
||||
ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10)),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
|
||||
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -9,8 +10,7 @@ import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ArrowDownward
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.*
|
||||
@@ -27,8 +28,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIFile
|
||||
import chat.simplex.app.model.CIFileStatus
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import coil.ImageLoader
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
@@ -45,6 +45,25 @@ fun CIImageView(
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
@Composable
|
||||
fun progressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun fileIcon(icon: ImageVector, @StringRes stringId: Int) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(stringId),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun loadingIndicator() {
|
||||
if (file != null) {
|
||||
@@ -55,39 +74,20 @@ fun CIImageView(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.SndTransfer ->
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
CIFileStatus.SndComplete ->
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
stringResource(R.string.icon_descr_image_snd_complete),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
Icon(
|
||||
Icons.Outlined.MoreHoriz,
|
||||
stringResource(R.string.icon_descr_waiting_for_image),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
CIFileStatus.RcvTransfer ->
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
CIFileStatus.RcvInvitation ->
|
||||
Icon(
|
||||
Icons.Outlined.ArrowDownward,
|
||||
stringResource(R.string.icon_descr_asked_to_receive),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
is CIFileStatus.SndStored ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressIndicator()
|
||||
FileProtocol.SMP -> {}
|
||||
}
|
||||
is CIFileStatus.SndTransfer -> progressIndicator()
|
||||
is CIFileStatus.SndComplete -> fileIcon(Icons.Filled.Check, R.string.icon_descr_image_snd_complete)
|
||||
is CIFileStatus.SndCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
is CIFileStatus.SndError -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
is CIFileStatus.RcvInvitation -> fileIcon(Icons.Outlined.ArrowDownward, R.string.icon_descr_asked_to_receive)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(Icons.Outlined.MoreHoriz, R.string.icon_descr_waiting_for_image)
|
||||
is CIFileStatus.RcvTransfer -> progressIndicator()
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
is CIFileStatus.RcvError -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ fun CIImageView(
|
||||
|
||||
fun fileSizeValid(): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= MAX_FILE_SIZE
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -179,15 +179,23 @@ fun CIImageView(
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
)
|
||||
}
|
||||
CIFileStatus.RcvAccepted ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||
)
|
||||
CIFileStatus.RcvTransfer -> {} // ?
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_completes_uploading)
|
||||
)
|
||||
FileProtocol.SMP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||
)
|
||||
}
|
||||
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||
CIFileStatus.RcvComplete -> {} // ?
|
||||
CIFileStatus.RcvCancelled -> {} // TODO
|
||||
else -> {}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -58,7 +59,7 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
|
||||
StatusIconText(Icons.Filled.Circle, Color.Transparent)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(meta.timestampText, color = color, fontSize = 13.sp)
|
||||
Text(meta.timestampText, color = color, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
// the conditions in this function should match CIMetaText
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun CIRcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean) {
|
||||
CIMsgError(ci, timedMessagesTTL, showMember) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.decryption_error),
|
||||
text = when (msgDecryptError) {
|
||||
MsgDecryptError.RatchetHeader -> String.format(generalGetString(R.string.alert_text_decryption_error_header), msgCount.toLong()) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_permanent_error_reconnect)
|
||||
MsgDecryptError.TooManySkipped -> String.format(generalGetString(R.string.alert_text_decryption_error_too_many_skipped), msgCount.toLong()) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_permanent_error_reconnect)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
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.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
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.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun CIVideoView(
|
||||
image: String,
|
||||
duration: Int,
|
||||
file: CIFile?,
|
||||
imageProvider: () -> ImageGalleryProvider,
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
Box(
|
||||
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val filePath = remember(file) { getLoadedFilePath(SimplexApp.context, file) }
|
||||
val preview = remember(image) { base64ToBitmap(image) }
|
||||
if (file != null && filePath != null) {
|
||||
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
|
||||
val view = LocalView.current
|
||||
VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = {
|
||||
hideKeyboard(view)
|
||||
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||
ImageFullScreenView(imageProvider, close)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Box {
|
||||
ImageView(preview, showMenu, onClick = {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation ->
|
||||
receiveFileIfValidSize(file, receiveFile)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_video),
|
||||
generalGetString(R.string.video_will_be_received_when_contact_completes_uploading)
|
||||
)
|
||||
|
||||
FileProtocol.SMP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_video),
|
||||
generalGetString(R.string.video_will_be_received_when_contact_is_online)
|
||||
)
|
||||
}
|
||||
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||
CIFileStatus.RcvComplete -> {} // ?
|
||||
CIFileStatus.RcvCancelled -> {} // TODO
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
})
|
||||
if (file != null) {
|
||||
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
|
||||
}
|
||||
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
|
||||
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
|
||||
}
|
||||
}
|
||||
}
|
||||
loadingIndicator(file)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true, context) }
|
||||
val videoPlaying = remember(uri.path) { player.videoPlaying }
|
||||
val progress = remember(uri.path) { player.progress }
|
||||
val duration = remember(uri.path) { player.duration }
|
||||
val preview by remember { player.preview }
|
||||
// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled }
|
||||
val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo }
|
||||
val play = {
|
||||
player.enableSound(true)
|
||||
player.play(true)
|
||||
}
|
||||
val stop = {
|
||||
player.enableSound(false)
|
||||
player.stop()
|
||||
}
|
||||
val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
Box {
|
||||
val windowWidth = LocalWindowWidth()
|
||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
StyledPlayerView(ctx).apply {
|
||||
useController = false
|
||||
resizeMode = RESIZE_MODE_FIXED_WIDTH
|
||||
this.player = player.player
|
||||
}
|
||||
},
|
||||
Modifier
|
||||
.width(width)
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = { if (player.player.playWhenReady) stop() else onClick() }
|
||||
)
|
||||
)
|
||||
if (showPreview.value) {
|
||||
ImageView(preview, showMenu, onClick)
|
||||
PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play)
|
||||
}
|
||||
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) {
|
||||
Surface(
|
||||
Modifier.align(Alignment.Center),
|
||||
color = Color.Black.copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(percent = 50)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
Modifier.size(25.dp),
|
||||
tint = if (error) WarningOrange else Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, duration: MutableState<Long>, progress: MutableState<Long>/*, soundEnabled: MutableState<Boolean>*/) {
|
||||
if (duration.value > 0L || progress.value > 0) {
|
||||
Row {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(DEFAULT_PADDING_HALF)
|
||||
.background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50))
|
||||
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||
) {
|
||||
val time = if (progress.value > 0) progress.value else duration.value
|
||||
val timeStr = durationText((time / 1000).toInt())
|
||||
val width = if (timeStr.length <= 5) 44 else 50
|
||||
Text(
|
||||
timeStr,
|
||||
Modifier.widthIn(min = with(LocalDensity.current) { width.sp.toDp() }).padding(horizontal = 4.dp),
|
||||
fontSize = 13.sp,
|
||||
color = Color.White
|
||||
)
|
||||
/*if (!soundEnabled.value) {
|
||||
Icon(Icons.Outlined.VolumeOff, null,
|
||||
Modifier.padding(start = 5.dp).size(10.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}*/
|
||||
}
|
||||
if (!playing.value) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = DEFAULT_PADDING_HALF)
|
||||
.background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50))
|
||||
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
formatBytes(file.fileSize),
|
||||
Modifier.padding(horizontal = 4.dp),
|
||||
fontSize = 13.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageView(preview: Bitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
val windowWidth = LocalWindowWidth()
|
||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||
Image(
|
||||
preview.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.video_descr),
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = onClick
|
||||
),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalWindowWidth(): Dp {
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current.density
|
||||
return remember {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
(rect.width() / density).dp
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun progressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fileIcon(icon: ImageVector, @StringRes stringId: Int) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(stringId),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun progressCircle(progress: Long, total: Long) {
|
||||
val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat()
|
||||
val strokeWidth = with(LocalDensity.current) { 2.dp.toPx() }
|
||||
val strokeColor = Color.White
|
||||
Surface(
|
||||
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||
color = Color.Transparent,
|
||||
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||
) {
|
||||
Box(Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun loadingIndicator(file: CIFile?) {
|
||||
if (file != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.size(20.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (file.fileStatus) {
|
||||
is CIFileStatus.SndStored ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressIndicator()
|
||||
FileProtocol.SMP -> {}
|
||||
}
|
||||
is CIFileStatus.SndTransfer ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||
FileProtocol.SMP -> progressIndicator()
|
||||
}
|
||||
is CIFileStatus.SndComplete -> fileIcon(Icons.Filled.Check, R.string.icon_descr_video_snd_complete)
|
||||
is CIFileStatus.SndCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
is CIFileStatus.SndError -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
is CIFileStatus.RcvInvitation -> fileIcon(Icons.Outlined.ArrowDownward, R.string.icon_descr_video_asked_to_receive)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(Icons.Outlined.MoreHoriz, R.string.icon_descr_waiting_for_video)
|
||||
is CIFileStatus.RcvTransfer ->
|
||||
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||
} else {
|
||||
progressIndicator()
|
||||
}
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
is CIFileStatus.RcvError -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileSizeValid(file: CIFile?): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
|
||||
if (fileSizeValid(file)) {
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun videoViewFullWidth(windowWidth: Dp): Dp {
|
||||
val approximatePadding = 100.dp
|
||||
return minOf(1000.dp, windowWidth - approximatePadding)
|
||||
}
|
||||
@@ -210,9 +210,9 @@ private fun VoiceMsgIndicator(
|
||||
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
|
||||
}
|
||||
} else {
|
||||
if (file?.fileStatus == CIFileStatus.RcvInvitation
|
||||
|| file?.fileStatus == CIFileStatus.RcvTransfer
|
||||
|| file?.fileStatus == CIFileStatus.RcvAccepted
|
||||
if (file?.fileStatus is CIFileStatus.RcvInvitation
|
||||
|| file?.fileStatus is CIFileStatus.RcvTransfer
|
||||
|| file?.fileStatus is CIFileStatus.RcvAccepted
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -228,7 +228,7 @@ private fun VoiceMsgIndicator(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||
fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||
val brush = Brush.linearGradient(
|
||||
0f to Color.Transparent,
|
||||
0f to color,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -20,11 +22,11 @@ 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.ui.theme.*
|
||||
import chat.simplex.app.views.chat.ComposeContextItem
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||
@@ -40,6 +42,7 @@ fun ChatItemView(
|
||||
linkMode: SimplexLinkMode,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
scrollToItem: (Long) -> Unit,
|
||||
@@ -91,13 +94,17 @@ fun ChatItemView(
|
||||
}
|
||||
}
|
||||
|
||||
fun moderateMessageQuestionText(): String {
|
||||
return if (fullDeleteAllowed) {
|
||||
generalGetString(R.string.moderate_message_will_be_deleted_warning)
|
||||
} else {
|
||||
generalGetString(R.string.moderate_message_will_be_marked_warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MsgContentItemDropdownMenu() {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (cItem.meta.itemDeleted == null && !live) {
|
||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
||||
if (composeState.value.editing) {
|
||||
@@ -120,14 +127,20 @@ fun ChatItemView(
|
||||
copyText(context, cItem.content.text)
|
||||
showMenu.value = false
|
||||
})
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||
val filePath = getLoadedFilePath(context, cItem.file)
|
||||
if (filePath != null) {
|
||||
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
||||
when (cItem.content.msgContent) {
|
||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
is MsgContent.MCImage -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
|
||||
saveImage(context, cItem.file)
|
||||
} else {
|
||||
writePermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
else -> {}
|
||||
}
|
||||
showMenu.value = false
|
||||
@@ -150,27 +163,32 @@ fun ChatItemView(
|
||||
}
|
||||
)
|
||||
}
|
||||
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
|
||||
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
|
||||
}
|
||||
if (!(live && cItem.meta.isLive)) {
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
val groupInfo = cItem.memberToModerate(cInfo)?.first
|
||||
if (groupInfo != null) {
|
||||
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkedDeletedItemDropdownMenu() {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.reveal_verb),
|
||||
Icons.Outlined.Visibility,
|
||||
onClick = {
|
||||
revealed.value = true
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (!cItem.isDeletedContent) {
|
||||
ItemAction(
|
||||
stringResource(R.string.reveal_verb),
|
||||
Icons.Outlined.Visibility,
|
||||
onClick = {
|
||||
revealed.value = true
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
}
|
||||
@@ -200,11 +218,7 @@ fun ChatItemView(
|
||||
|
||||
@Composable fun DeletedItem() {
|
||||
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +234,8 @@ 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(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, 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)
|
||||
@@ -238,14 +253,32 @@ fun ChatItemView(
|
||||
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.SndModerated -> DeletedItem()
|
||||
is CIContent.RcvModerated -> DeletedItem()
|
||||
is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CancelFileItemAction(
|
||||
fileId: Long,
|
||||
showMenu: MutableState<Boolean>,
|
||||
cancelFile: (Long) -> Unit,
|
||||
cancelAction: CancelAction
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(cancelAction.uiActionId),
|
||||
Icons.Outlined.Close,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
cancelFileAlertDialog(fileId, cancelFile = cancelFile, cancelAction = cancelAction)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItemAction(
|
||||
cItem: ChatItem,
|
||||
@@ -265,22 +298,55 @@ fun DeleteItemAction(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
|
||||
DropdownMenuItem(onClick) {
|
||||
Row {
|
||||
fun ModerateItemAction(
|
||||
cItem: ChatItem,
|
||||
questionText: String,
|
||||
showMenu: MutableState<Boolean>,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.moderate_verb),
|
||||
Icons.Outlined.Flag,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||
val finalColor = if (color == Color.Unspecified) {
|
||||
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||
} else color
|
||||
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = color
|
||||
color = finalColor
|
||||
)
|
||||
Icon(icon, text, tint = color)
|
||||
Icon(icon, text, tint = finalColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(cancelAction.alert.titleId),
|
||||
text = generalGetString(cancelAction.alert.messageId),
|
||||
confirmText = generalGetString(cancelAction.alert.confirmId),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
cancelFile(fileId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(R.string.delete_message__question),
|
||||
@@ -290,7 +356,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
@@ -308,6 +374,18 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
|
||||
)
|
||||
}
|
||||
|
||||
fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.delete_member_message__question),
|
||||
text = questionText,
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun showMsgDeliveryErrorAlert(description: String) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.message_delivery_error_title),
|
||||
@@ -329,6 +407,7 @@ fun PreviewChatItemView() {
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
@@ -349,6 +428,7 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.outlined.Flag
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -20,6 +21,7 @@ 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.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.util.fastMap
|
||||
@@ -75,10 +77,7 @@ fun FramedItemView(
|
||||
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),
|
||||
.padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
@@ -96,6 +95,8 @@ fun FramedItemView(
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -124,6 +125,18 @@ fun FramedItemView(
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
)
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
contentDescription = stringResource(R.string.video_descr),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
)
|
||||
}
|
||||
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
ciQuotedMsgView(qi)
|
||||
@@ -150,7 +163,8 @@ fun FramedItemView(
|
||||
}
|
||||
}
|
||||
|
||||
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.MCVideo) &&
|
||||
!ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
|
||||
Box(Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
@@ -166,7 +180,11 @@ fun FramedItemView(
|
||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||
if (ci.meta.itemDeleted != null) {
|
||||
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete)
|
||||
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||
FramedItemHeader(String.format(stringResource(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, Icons.Outlined.Flag)
|
||||
} else {
|
||||
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete)
|
||||
}
|
||||
} else if (ci.meta.isLive) {
|
||||
FramedItemHeader(stringResource(R.string.live), false)
|
||||
}
|
||||
@@ -193,6 +211,14 @@ fun FramedItemView(
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVoice -> {
|
||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
|
||||
if (mc.text != "") {
|
||||
|
||||
@@ -3,21 +3,28 @@ package chat.simplex.app.views.chat.item
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.isVisible
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.chat.ProviderMedia
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import coil.ImageLoader
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
@@ -26,13 +33,16 @@ import coil.decode.ImageDecoderDecoder
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import com.google.accompanist.pager.*
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
interface ImageGalleryProvider {
|
||||
val initialIndex: Int
|
||||
val totalImagesSize: MutableState<Int>
|
||||
fun getImage(index: Int): Pair<Bitmap, Uri>?
|
||||
val totalMediaSize: MutableState<Int>
|
||||
fun getMedia(index: Int): ProviderMedia?
|
||||
fun currentPageChanged(index: Int)
|
||||
fun scrollToStart()
|
||||
fun onDismiss(index: Int)
|
||||
@@ -48,13 +58,17 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
|
||||
// which makes this blank page visible for a moment. Prevent it by doing the check ourselves
|
||||
LaunchedEffect(Unit) {
|
||||
if (provider.getImage(provider.initialIndex - 1) == null) {
|
||||
if (provider.getMedia(provider.initialIndex - 1) == null) {
|
||||
provider.scrollToStart()
|
||||
pagerState.scrollToPage(0)
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
HorizontalPager(count = remember { provider.totalImagesSize }.value, state = pagerState) { index ->
|
||||
val playersToRelease = rememberSaveable { mutableSetOf<Uri>() }
|
||||
DisposableEffectOnGone(
|
||||
whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } }
|
||||
)
|
||||
HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -74,13 +88,13 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
if (settledCurrentPage != provider.initialIndex)
|
||||
provider.currentPageChanged(index)
|
||||
}
|
||||
val image = provider.getImage(index)
|
||||
if (image == null) {
|
||||
val media = provider.getMedia(index)
|
||||
if (media == 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.totalMediaSize.value = settledCurrentPage + 1
|
||||
index + 1 -> {
|
||||
provider.scrollToStart()
|
||||
pagerState.scrollToPage(0)
|
||||
@@ -89,7 +103,6 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val (imageBitmap: Bitmap, uri: Uri) = image
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var translationX by remember { mutableStateOf(0f) }
|
||||
var translationY by remember { mutableStateOf(0f) }
|
||||
@@ -100,54 +113,106 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
val modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
viewWidth = it.size.width
|
||||
}
|
||||
.build()
|
||||
Image(
|
||||
rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
viewWidth = it.size.width
|
||||
}
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = translationX,
|
||||
translationY = translationY,
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures(
|
||||
{ allowTranslate },
|
||||
onGesture = { _, pan, gestureZoom, _ ->
|
||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||
allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0
|
||||
if (scale > 1 && allowTranslate) {
|
||||
translationX += pan.x * scale
|
||||
translationY += pan.y * scale
|
||||
} else if (allowTranslate) {
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = translationX,
|
||||
translationY = translationY,
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures(
|
||||
{ allowTranslate },
|
||||
onGesture = { _, pan, gestureZoom, _ ->
|
||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||
allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0
|
||||
if (scale > 1 && allowTranslate) {
|
||||
translationX += pan.x * scale
|
||||
translationY += pan.y * scale
|
||||
} else if (allowTranslate) {
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
.fillMaxSize()
|
||||
if (media is ProviderMedia.Image) {
|
||||
val (uri: Uri, imageBitmap: Bitmap) = media
|
||||
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.fillMaxSize(),
|
||||
)
|
||||
.build()
|
||||
Image(
|
||||
rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else if (media is ProviderMedia.Video) {
|
||||
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
|
||||
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { playersToRelease.add(media.uri) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true, context) }
|
||||
val isCurrentPage = rememberUpdatedState(currentPage)
|
||||
val play = {
|
||||
player.play(true)
|
||||
}
|
||||
val stop = {
|
||||
player.stop()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
player.enableSound(true)
|
||||
snapshotFlow { isCurrentPage.value }
|
||||
.distinctUntilChanged()
|
||||
.collect { if (it) play() else stop() }
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
StyledPlayerView(ctx).apply {
|
||||
resizeMode = if (ctx.resources.configuration.screenWidthDp > ctx.resources.configuration.screenHeightDp) {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
setShowPreviousButton(false)
|
||||
setShowNextButton(false)
|
||||
setShowSubtitleButton(false)
|
||||
setShowVrButton(false)
|
||||
controllerAutoShow = false
|
||||
findViewById<View>(com.google.android.exoplayer2.R.id.exo_controls_background).setBackgroundColor(Color.Black.copy(alpha = 0.3f).toArgb())
|
||||
findViewById<View>(com.google.android.exoplayer2.R.id.exo_settings).isVisible = false
|
||||
this.player = player.player
|
||||
}
|
||||
},
|
||||
modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,19 +17,41 @@ 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.model.MsgErrorType
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
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(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
CIMsgError(ci, timedMessagesTTL, showMember) {
|
||||
when (msgError) {
|
||||
is MsgErrorType.MsgSkipped ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.alert_title_skipped_messages),
|
||||
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
||||
)
|
||||
is MsgErrorType.MsgBadHash ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.alert_title_msg_bad_hash),
|
||||
text = generalGetString(R.string.alert_text_msg_bad_hash) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_please_report_to_developers)
|
||||
)
|
||||
is MsgErrorType.MsgBadId, is MsgErrorType.MsgDuplicate ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.alert_title_msg_bad_id),
|
||||
text = generalGetString(R.string.alert_text_msg_bad_id) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_please_report_to_developers)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false, onClick: () -> Unit) {
|
||||
Surface(
|
||||
Modifier.clickable(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.alert_title_skipped_messages),
|
||||
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
||||
)
|
||||
}),
|
||||
Modifier.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = ReceivedColorLight,
|
||||
) {
|
||||
@@ -59,6 +81,7 @@ fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boo
|
||||
fun IntegrityErrorItemViewPreview() {
|
||||
SimpleXTheme {
|
||||
IntegrityErrorItemView(
|
||||
MsgErrorType.MsgBadHash(),
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
null
|
||||
)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -10,6 +9,7 @@ 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.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -30,19 +30,32 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Bool
|
||||
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)
|
||||
)
|
||||
Box(Modifier.weight(1f, false)) {
|
||||
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||
MarkedDeletedText(String.format(generalGetString(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName))
|
||||
} else {
|
||||
MarkedDeletedText(generalGetString(R.string.marked_deleted_description))
|
||||
}
|
||||
}
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkedDeletedText(text: String) {
|
||||
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(text) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.helpers.openUriCatching
|
||||
import chat.simplex.app.views.usersettings.MarkdownHelpView
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
|
||||
@@ -36,7 +37,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
||||
Text(
|
||||
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
uriHandler.openUri(simplexTeamUri)
|
||||
uriHandler.openUriCatching(simplexTeamUri)
|
||||
}),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
|
||||
@@ -194,26 +194,14 @@ fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<
|
||||
|
||||
@Composable
|
||||
fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
DropdownMenuItem({
|
||||
markChatUnread(chat, chatModel)
|
||||
showMenu.value = false
|
||||
}) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(R.string.mark_unread),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
Icons.Outlined.MarkChatUnread,
|
||||
stringResource(R.string.mark_unread),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
ItemAction(
|
||||
stringResource(R.string.mark_unread),
|
||||
Icons.Outlined.MarkChatUnread,
|
||||
onClick = {
|
||||
markChatUnread(chat, chatModel)
|
||||
showMenu.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -447,7 +435,7 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
AlertManager.shared.hideAlert()
|
||||
@@ -589,15 +577,7 @@ fun ChatListNavLinkLayout(
|
||||
chatLinkPreview()
|
||||
}
|
||||
if (dropdownMenuItems != null) {
|
||||
Box(Modifier.padding(horizontal = 16.dp)) {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
dropdownMenuItems()
|
||||
}
|
||||
}
|
||||
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
@@ -36,7 +37,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean, FragmentActivity) -> 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 showNewChatSheet = {
|
||||
@@ -132,7 +133,7 @@ private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
ConnectButton(generalGetString(R.string.chat_with_developers)) {
|
||||
uriHandler.openUri(simplexTeamUri)
|
||||
uriHandler.openUriCatching(simplexTeamUri)
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatSheet)
|
||||
@@ -208,9 +209,9 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
||||
} else if (chatModel.users.isEmpty()) {
|
||||
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
|
||||
} else {
|
||||
val users by remember { derivedStateOf { chatModel.users.toList() } }
|
||||
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||
val allRead = users
|
||||
.filter { !it.user.activeUser }
|
||||
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||
.all { u -> u.unreadCount == 0 }
|
||||
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||
if (users.size == 1) {
|
||||
@@ -247,7 +248,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
|
||||
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
|
||||
IconButton(onClick = onButtonClicked) {
|
||||
Box {
|
||||
ProfileImage(
|
||||
|
||||
@@ -86,7 +86,7 @@ fun ChatPreviewView(
|
||||
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.MediaPreview -> Icons.Outlined.Image to null
|
||||
is ComposePreview.VoicePreview -> Icons.Filled.PlayArrow to durationText(draft.preview.durationMs / 1000)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -24,12 +24,15 @@ import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.Indigo
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
||||
Scaffold(
|
||||
topBar = { Column { ShareListToolbar(chatModel, stopped) { searchInList = it.trim() } } },
|
||||
topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
|
||||
) {
|
||||
Box(Modifier.padding(it)) {
|
||||
Column(
|
||||
@@ -45,23 +48,41 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = {
|
||||
chatModel.sharedContent.value = null
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyList() {
|
||||
Box {
|
||||
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.you_have_no_chats), color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||
private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
||||
if (showSearch) {
|
||||
BackHandler(onBack = hideSearchOnBack)
|
||||
}
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||
val navButton: @Composable RowScope.() -> Unit = {
|
||||
when {
|
||||
showSearch -> NavigationButtonBack(hideSearchOnBack)
|
||||
users.size > 1 -> {
|
||||
val allRead = users
|
||||
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||
.all { u -> u.unreadCount == 0 }
|
||||
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||
userPickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
}
|
||||
else -> NavigationButtonBack { chatModel.sharedContent.value = null }
|
||||
}
|
||||
}
|
||||
if (chatModel.chats.size >= 8) {
|
||||
barButtons.add {
|
||||
IconButton({ showSearch = true }) {
|
||||
@@ -87,13 +108,13 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
||||
}
|
||||
|
||||
DefaultTopAppBar(
|
||||
navigationButton = { if (showSearch) NavigationButtonBack(hideSearchOnBack) else NavigationButtonBack { chatModel.sharedContent.value = null } },
|
||||
navigationButton = navButton,
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
when (chatModel.sharedContent.value) {
|
||||
is SharedContent.Text -> stringResource(R.string.share_message)
|
||||
is SharedContent.Images -> stringResource(R.string.share_image)
|
||||
is SharedContent.Media -> stringResource(R.string.share_image)
|
||||
is SharedContent.File -> stringResource(R.string.share_file)
|
||||
else -> stringResource(R.string.share_message)
|
||||
},
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
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.foundation.shape.*
|
||||
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.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -33,10 +35,24 @@ import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, switchingUsers: MutableState<Boolean>, openSettings: () -> Unit) {
|
||||
fun UserPicker(
|
||||
chatModel: ChatModel,
|
||||
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
switchingUsers: MutableState<Boolean>,
|
||||
showSettings: Boolean = true,
|
||||
showCancel: Boolean = false,
|
||||
cancelClicked: () -> Unit = {},
|
||||
settingsClicked: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var newChat by remember { mutableStateOf(userPickerState.value) }
|
||||
val users by remember { derivedStateOf { chatModel.users.sortedByDescending { it.user.activeUser } } }
|
||||
val users by remember {
|
||||
derivedStateOf {
|
||||
chatModel.users
|
||||
.filter { u -> u.user.activeUser || !u.user.hidden }
|
||||
.sortedByDescending { it.user.activeUser }
|
||||
}
|
||||
}
|
||||
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
@@ -90,27 +106,27 @@ fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedV
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.widthIn(min = 220.dp)
|
||||
.widthIn(min = 260.dp)
|
||||
.width(IntrinsicSize.Min)
|
||||
.height(IntrinsicSize.Min)
|
||||
.shadow(8.dp, MaterialTheme.shapes.medium, clip = false)
|
||||
.background(MaterialTheme.colors.background, MaterialTheme.shapes.medium)
|
||||
.shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true)
|
||||
.background(if (isInDarkTheme()) Color(0xff222222) else MaterialTheme.colors.background, RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||
.clip(RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||
) {
|
||||
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||
users.forEach { u ->
|
||||
UserProfilePickerItem(u.user, u.unreadCount, openSettings = {
|
||||
openSettings()
|
||||
UserProfilePickerItem(u.user, u.unreadCount, PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING * 2), openSettings = {
|
||||
settingsClicked()
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}) {
|
||||
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)
|
||||
chatModel.controller.changeActiveUser(u.user.userId, null)
|
||||
job.cancel()
|
||||
switchingUsers.value = false
|
||||
}
|
||||
@@ -120,16 +136,24 @@ fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedV
|
||||
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
|
||||
}
|
||||
}
|
||||
SettingsPickerItem {
|
||||
openSettings()
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
if (showSettings) {
|
||||
SettingsPickerItem {
|
||||
settingsClicked()
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
}
|
||||
if (showCancel) {
|
||||
CancelPickerItem {
|
||||
cancelClicked()
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) {
|
||||
fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues = PaddingValues(start = 8.dp, end = DEFAULT_PADDING), onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -140,37 +164,23 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = if (!u.activeUser) LocalIndication.current else null
|
||||
)
|
||||
.padding(PaddingValues(start = 8.dp, end = DEFAULT_PADDING)),
|
||||
.padding(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
|
||||
)
|
||||
}
|
||||
UserProfileRow(u)
|
||||
if (u.activeUser) {
|
||||
Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
} else if (u.hidden) {
|
||||
Icon(Icons.Outlined.Lock, null, Modifier.size(20.dp), tint = HighOrLowlight)
|
||||
} else if (unreadCount > 0) {
|
||||
Row {
|
||||
Text(
|
||||
unreadCountStr(unreadCount),
|
||||
color = MaterialTheme.colors.onPrimary,
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.background(if (u.showNtfs) MaterialTheme.colors.primary else HighOrLowlight, shape = CircleShape)
|
||||
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp),
|
||||
@@ -179,20 +189,58 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
}
|
||||
} else {
|
||||
} else if (!u.showNtfs) {
|
||||
Icon(Icons.Outlined.NotificationsOff, null, Modifier.size(20.dp), tint = HighOrLowlight)
|
||||
} 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,
|
||||
fun UserProfileRow(u: User) {
|
||||
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),
|
||||
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING * 2.2f, end = DEFAULT_PADDING * 2), minHeight = 68.dp) {
|
||||
val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||
Icon(Icons.Outlined.Settings, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING * 1.5f))
|
||||
Text(
|
||||
text,
|
||||
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CancelPickerItem(onClick: () -> Unit) {
|
||||
SectionItemViewSpaceBetween(onClick, padding = PaddingValues(start = DEFAULT_PADDING * 2.2f, end = DEFAULT_PADDING * 2), minHeight = 68.dp) {
|
||||
val text = generalGetString(R.string.cancel_verb)
|
||||
Icon(Icons.Outlined.Close, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING * 1.5f))
|
||||
Text(
|
||||
text,
|
||||
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||
)
|
||||
Icon(Icons.Outlined.Settings, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionTextFooter
|
||||
@@ -25,13 +26,13 @@ import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.log2
|
||||
|
||||
@@ -41,9 +42,9 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
||||
val prefs = m.controller.appPrefs
|
||||
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
||||
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
|
||||
val storedKey = remember { val key = DatabaseUtils.getDatabaseKey(); mutableStateOf(key != null && key != "") }
|
||||
val storedKey = remember { val key = DatabaseUtils.ksDatabasePassword.get(); mutableStateOf(key != null && key != "") }
|
||||
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
|
||||
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.getDatabaseKey() ?: "" else "") }
|
||||
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") }
|
||||
val newKey = rememberSaveable { mutableStateOf("") }
|
||||
val confirmNewKey = rememberSaveable { mutableStateOf("") }
|
||||
|
||||
@@ -88,7 +89,7 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
||||
prefs.initialRandomDBPassphrase.set(false)
|
||||
initialRandomDBPassphrase.value = false
|
||||
if (useKeychain.value) {
|
||||
DatabaseUtils.setDatabaseKey(newKey.value)
|
||||
DatabaseUtils.ksDatabasePassword.set(newKey.value)
|
||||
}
|
||||
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
|
||||
operationEnded(m, progressIndicator) {
|
||||
@@ -149,7 +150,7 @@ fun DatabaseEncryptionLayout(
|
||||
text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
|
||||
confirmText = generalGetString(R.string.remove_passphrase),
|
||||
onConfirm = {
|
||||
DatabaseUtils.removeDatabaseKey()
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
setUseKeychain(false, useKeychain, prefs)
|
||||
storedKey.value = false
|
||||
},
|
||||
@@ -161,7 +162,9 @@ fun DatabaseEncryptionLayout(
|
||||
}
|
||||
|
||||
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
|
||||
DatabaseKeyField(
|
||||
SectionDivider()
|
||||
|
||||
PassphraseField(
|
||||
currentKey,
|
||||
generalGetString(R.string.current_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
@@ -170,7 +173,9 @@ fun DatabaseEncryptionLayout(
|
||||
)
|
||||
}
|
||||
|
||||
DatabaseKeyField(
|
||||
SectionDivider()
|
||||
|
||||
PassphraseField(
|
||||
newKey,
|
||||
generalGetString(R.string.new_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
@@ -201,7 +206,9 @@ fun DatabaseEncryptionLayout(
|
||||
!validKey(newKey.value) ||
|
||||
progressIndicator.value
|
||||
|
||||
DatabaseKeyField(
|
||||
SectionDivider()
|
||||
|
||||
PassphraseField(
|
||||
confirmNewKey,
|
||||
generalGetString(R.string.confirm_new_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
@@ -212,7 +219,9 @@ fun DatabaseEncryptionLayout(
|
||||
}),
|
||||
)
|
||||
|
||||
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled) {
|
||||
SectionDivider()
|
||||
|
||||
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) {
|
||||
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
@@ -285,9 +294,10 @@ fun SavePassphraseSetting(
|
||||
initialRandomDBPassphrase: Boolean,
|
||||
storedKey: Boolean,
|
||||
progressIndicator: Boolean,
|
||||
minHeight: Dp = TextFieldDefaults.MinHeight,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
SectionItemView {
|
||||
SectionItemView(minHeight = minHeight) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff,
|
||||
@@ -349,13 +359,14 @@ private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun DatabaseKeyField(
|
||||
fun PassphraseField(
|
||||
key: MutableState<String>,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showStrength: Boolean = false,
|
||||
isValid: (String) -> Boolean,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
dependsOn: State<Any?>? = null,
|
||||
) {
|
||||
var valid by remember { mutableStateOf(validKey(key.value)) }
|
||||
var showKey by remember { mutableStateOf(false) }
|
||||
@@ -436,6 +447,13 @@ fun DatabaseKeyField(
|
||||
)
|
||||
}
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { dependsOn?.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
valid = isValid(state.value.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// based on https://generatepasswords.org/how-to-calculate-entropy/
|
||||
@@ -461,7 +479,7 @@ private fun passphraseEntropy(s: String): Double {
|
||||
return s.length * log2(poolSize.toDouble())
|
||||
}
|
||||
|
||||
private enum class PassphraseStrength(val color: Color) {
|
||||
enum class PassphraseStrength(val color: Color) {
|
||||
VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
|
||||
|
||||
companion object {
|
||||
@@ -504,4 +522,4 @@ fun PreviewDatabaseEncryptionLayout() {
|
||||
onConfirmEncrypt = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import SectionSpacer
|
||||
import SectionView
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -13,6 +14,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.*
|
||||
@@ -20,6 +22,7 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.AppVersionText
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
@@ -35,28 +38,43 @@ fun DatabaseErrorView(
|
||||
) {
|
||||
val progressIndicator = remember { mutableStateOf(false) }
|
||||
val dbKey = remember { mutableStateOf("") }
|
||||
var storedDBKey by remember { mutableStateOf(DatabaseUtils.getDatabaseKey()) }
|
||||
var storedDBKey by remember { mutableStateOf(DatabaseUtils.ksDatabasePassword.get()) }
|
||||
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
|
||||
val context = LocalContext.current
|
||||
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
|
||||
val saveAndRunChatOnClick: () -> Unit = {
|
||||
DatabaseUtils.setDatabaseKey(dbKey.value)
|
||||
|
||||
fun callRunChat(confirmMigrations: MigrationConfirmation? = null) {
|
||||
val useKey = if (useKeychain) null else dbKey.value
|
||||
runChat(useKey, confirmMigrations, chatDbStatus, progressIndicator, appPreferences)
|
||||
}
|
||||
|
||||
fun saveAndRunChatOnClick() {
|
||||
DatabaseUtils.ksDatabasePassword.set(dbKey.value)
|
||||
storedDBKey = dbKey.value
|
||||
appPreferences.storeDBPassphrase.set(true)
|
||||
useKeychain = true
|
||||
appPreferences.initialRandomDBPassphrase.set(false)
|
||||
runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
|
||||
callRunChat()
|
||||
}
|
||||
val title = when (chatDbStatus.value) {
|
||||
is DBMigrationResult.OK -> ""
|
||||
is DBMigrationResult.ErrorNotADatabase -> if (useKeychain && !storedDBKey.isNullOrEmpty())
|
||||
generalGetString(R.string.wrong_passphrase)
|
||||
else
|
||||
generalGetString(R.string.encrypted_database)
|
||||
is DBMigrationResult.Error -> generalGetString(R.string.database_error)
|
||||
is DBMigrationResult.ErrorKeychain -> generalGetString(R.string.keychain_error)
|
||||
is DBMigrationResult.Unknown -> generalGetString(R.string.database_error)
|
||||
null -> "" // should never be here
|
||||
|
||||
@Composable
|
||||
fun DatabaseErrorDetails(@StringRes title: Int, content: @Composable ColumnScope.() -> Unit) {
|
||||
Text(
|
||||
generalGetString(title),
|
||||
Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileNameText(dbFile: String) {
|
||||
Text(String.format(generalGetString(R.string.file_with_path), dbFile.split("/").lastOrNull() ?: dbFile))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MigrationsText(ms: List<String>) {
|
||||
Text(String.format(generalGetString(R.string.database_migrations), ms.joinToString(", ")))
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -64,63 +82,91 @@ fun DatabaseErrorView(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
Modifier.padding(start = 16.dp, top = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
|
||||
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
|
||||
when (val status = chatDbStatus.value) {
|
||||
is DBMigrationResult.ErrorNotADatabase -> {
|
||||
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
|
||||
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
|
||||
when (val status = chatDbStatus.value) {
|
||||
is DBMigrationResult.ErrorNotADatabase ->
|
||||
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
|
||||
DatabaseErrorDetails(R.string.wrong_passphrase) {
|
||||
Text(generalGetString(R.string.passphrase_is_different))
|
||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
||||
saveAndRunChatOnClick()
|
||||
}
|
||||
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
|
||||
SaveAndOpenButton(buttonEnabled, ::saveAndRunChatOnClick)
|
||||
SectionSpacer()
|
||||
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
|
||||
} else {
|
||||
FileNameText(status.dbFile)
|
||||
}
|
||||
} else {
|
||||
DatabaseErrorDetails(R.string.encrypted_database) {
|
||||
Text(generalGetString(R.string.database_passphrase_is_required))
|
||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
||||
if (useKeychain) saveAndRunChatOnClick() else runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
|
||||
}
|
||||
if (useKeychain) {
|
||||
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
|
||||
DatabaseKeyField(dbKey, buttonEnabled, ::saveAndRunChatOnClick)
|
||||
SaveAndOpenButton(buttonEnabled, ::saveAndRunChatOnClick)
|
||||
} else {
|
||||
OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) }
|
||||
DatabaseKeyField(dbKey, buttonEnabled) { callRunChat() }
|
||||
OpenChatButton(buttonEnabled) { callRunChat() }
|
||||
}
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.Error -> {
|
||||
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
|
||||
Text(String.format(generalGetString(R.string.error_with_info), status.migrationError))
|
||||
is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) {
|
||||
is MigrationError.Upgrade ->
|
||||
DatabaseErrorDetails(R.string.database_upgrade) {
|
||||
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUp) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||
Text(generalGetString(R.string.upgrade_and_open_chat))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
FileNameText(status.dbFile)
|
||||
MigrationsText(err.upMigrations.map { it.upName })
|
||||
AppVersionText()
|
||||
}
|
||||
is MigrationError.Downgrade ->
|
||||
DatabaseErrorDetails(R.string.database_downgrade) {
|
||||
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUpDown) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||
Text(generalGetString(R.string.downgrade_and_open_chat))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(generalGetString(R.string.database_downgrade_warning), fontWeight = FontWeight.Bold)
|
||||
FileNameText(status.dbFile)
|
||||
MigrationsText(err.downMigrations)
|
||||
AppVersionText()
|
||||
}
|
||||
is MigrationError.Error ->
|
||||
DatabaseErrorDetails(R.string.incompatible_database_version) {
|
||||
FileNameText(status.dbFile)
|
||||
Text(String.format(generalGetString(R.string.error_with_info), mtrErrorDescription(err.mtrError)))
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.ErrorSQL ->
|
||||
DatabaseErrorDetails(R.string.database_error) {
|
||||
FileNameText(status.dbFile)
|
||||
Text(String.format(generalGetString(R.string.error_with_info), status.migrationSQLError))
|
||||
}
|
||||
is DBMigrationResult.ErrorKeychain -> {
|
||||
is DBMigrationResult.ErrorKeychain ->
|
||||
DatabaseErrorDetails(R.string.keychain_error) {
|
||||
Text(generalGetString(R.string.cannot_access_keychain))
|
||||
}
|
||||
is DBMigrationResult.Unknown -> {
|
||||
is DBMigrationResult.InvalidConfirmation ->
|
||||
DatabaseErrorDetails(R.string.invalid_migration_confirmation) {
|
||||
// this can only happen if incorrect parameter is passed
|
||||
}
|
||||
is DBMigrationResult.Unknown ->
|
||||
DatabaseErrorDetails(R.string.database_error) {
|
||||
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
|
||||
}
|
||||
is DBMigrationResult.OK -> {
|
||||
}
|
||||
null -> {
|
||||
}
|
||||
}
|
||||
if (restoreDbFromBackup.value) {
|
||||
SectionSpacer()
|
||||
Text(generalGetString(R.string.database_backup_can_be_restored))
|
||||
Spacer(Modifier.size(16.dp))
|
||||
RestoreDbButton {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.restore_database_alert_title),
|
||||
text = generalGetString(R.string.restore_database_alert_desc),
|
||||
confirmText = generalGetString(R.string.restore_database_alert_confirm),
|
||||
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
is DBMigrationResult.OK -> {}
|
||||
null -> {}
|
||||
}
|
||||
if (restoreDbFromBackup.value) {
|
||||
SectionSpacer()
|
||||
Text(generalGetString(R.string.database_backup_can_be_restored))
|
||||
Spacer(Modifier.size(16.dp))
|
||||
RestoreDbButton {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.restore_database_alert_title),
|
||||
text = generalGetString(R.string.restore_database_alert_desc),
|
||||
confirmText = generalGetString(R.string.restore_database_alert_confirm),
|
||||
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +187,8 @@ fun DatabaseErrorView(
|
||||
}
|
||||
|
||||
private fun runChat(
|
||||
dbKey: String,
|
||||
dbKey: String? = null,
|
||||
confirmMigrations: MigrationConfirmation? = null,
|
||||
chatDbStatus: State<DBMigrationResult?>,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
prefs: AppPreferences
|
||||
@@ -150,7 +197,7 @@ private fun runChat(
|
||||
if (progressIndicator.value) return@launch
|
||||
progressIndicator.value = true
|
||||
try {
|
||||
SimplexApp.context.initChatController(dbKey)
|
||||
SimplexApp.context.initChatController(dbKey, confirmMigrations)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
|
||||
}
|
||||
@@ -163,18 +210,17 @@ private fun runChat(
|
||||
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.ErrorNotADatabase -> {
|
||||
is DBMigrationResult.ErrorNotADatabase ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
|
||||
}
|
||||
is DBMigrationResult.Error -> {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationError)
|
||||
}
|
||||
is DBMigrationResult.ErrorKeychain -> {
|
||||
is DBMigrationResult.ErrorSQL ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationSQLError)
|
||||
is DBMigrationResult.ErrorKeychain ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.keychain_error))
|
||||
}
|
||||
is DBMigrationResult.Unknown -> {
|
||||
is DBMigrationResult.Unknown ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), status.json)
|
||||
}
|
||||
is DBMigrationResult.InvalidConfirmation ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.invalid_migration_confirmation))
|
||||
is DBMigrationResult.ErrorMigration -> {}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
@@ -204,9 +250,17 @@ private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPref
|
||||
}
|
||||
}
|
||||
|
||||
private fun mtrErrorDescription(err: MTRError): String =
|
||||
when (err) {
|
||||
is MTRError.NoDown ->
|
||||
String.format(generalGetString(R.string.mtr_error_no_down_migration), err.dbMigrations.joinToString(", "))
|
||||
is MTRError.Different ->
|
||||
String.format(generalGetString(R.string.mtr_error_different), err.appMigration, err.dbMigration)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
|
||||
DatabaseKeyField(
|
||||
PassphraseField(
|
||||
text,
|
||||
generalGetString(R.string.enter_passphrase),
|
||||
isValid = ::validKey,
|
||||
|
||||
@@ -8,7 +8,6 @@ import SectionView
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.FileUtils
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
@@ -40,6 +39,7 @@ import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.*
|
||||
import org.apache.commons.io.IOUtils
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -52,7 +52,7 @@ fun DatabaseView(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val progressIndicator = remember { mutableStateOf(false) }
|
||||
val runChat = remember { mutableStateOf(m.chatRunning.value ?: true) }
|
||||
val runChat = remember { m.chatRunning }
|
||||
val prefs = m.controller.appPrefs
|
||||
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
||||
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
|
||||
@@ -76,7 +76,7 @@ fun DatabaseView(
|
||||
) {
|
||||
DatabaseLayout(
|
||||
progressIndicator.value,
|
||||
runChat.value,
|
||||
runChat.value != false,
|
||||
m.chatDbChanged.value,
|
||||
useKeychain.value,
|
||||
m.chatDbEncrypted.value,
|
||||
@@ -154,13 +154,13 @@ fun DatabaseLayout(
|
||||
val operationsDisabled = !stopped || progressIndicator
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 48.dp),
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = DEFAULT_BOTTOM_PADDING),
|
||||
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) }
|
||||
SectionItemView { TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected) }
|
||||
}
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
@@ -190,7 +190,7 @@ fun DatabaseLayout(
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.Backup, stringResource(R.string.full_backup), privacyFullBackup)
|
||||
AppDataBackupPreference(privacyFullBackup, initialRandomDBPassphrase)
|
||||
SectionDivider()
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.IosShare,
|
||||
@@ -270,6 +270,36 @@ fun DatabaseLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppDataBackupPreference(privacyFullBackup: SharedPreference<Boolean>, initialRandomDBPassphrase: SharedPreference<Boolean>) {
|
||||
SectionItemView {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Outlined.Backup, stringResource(R.string.full_backup), tint = HighOrLowlight)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
val prefState = remember { mutableStateOf(privacyFullBackup.get()) }
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.full_backup), Modifier.padding(end = 24.dp))
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
checked = prefState.value,
|
||||
onCheckedChange = {
|
||||
if (initialRandomDBPassphrase.get()) {
|
||||
exportProhibitedAlert()
|
||||
} else {
|
||||
privacyFullBackup.set(it)
|
||||
prefState.value = it
|
||||
}
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setChatItemTTLAlert(
|
||||
m: ChatModel, selectedChatItemTTL: MutableState<ChatItemTTL>,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
@@ -358,7 +388,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
|
||||
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
|
||||
}
|
||||
|
||||
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
|
||||
private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
|
||||
withApi {
|
||||
try {
|
||||
if (chatDbChanged.value) {
|
||||
@@ -387,7 +417,7 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStar
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
||||
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.stop_chat_question),
|
||||
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
|
||||
@@ -404,7 +434,7 @@ private fun exportProhibitedAlert() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
||||
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
|
||||
if (m.controller.appPrefs.performLA.get()) {
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_stop_chat),
|
||||
@@ -412,12 +442,13 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context:
|
||||
context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success, LAResult.Unavailable -> {
|
||||
LAResult.Success, is LAResult.Unavailable -> {
|
||||
stopChat(m, runChat, context)
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
runChat.value = true
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
is LAResult.Failed -> {
|
||||
runChat.value = true
|
||||
}
|
||||
}
|
||||
@@ -428,7 +459,7 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context:
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
||||
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
|
||||
withApi {
|
||||
try {
|
||||
m.controller.apiStopChat()
|
||||
@@ -562,7 +593,7 @@ private fun importArchive(
|
||||
try {
|
||||
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
|
||||
m.controller.apiImportArchive(config)
|
||||
DatabaseUtils.removeDatabaseKey()
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
|
||||
@@ -590,7 +621,7 @@ private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): Strin
|
||||
if (inputStream != null && archiveName != null) {
|
||||
val archivePath = "${context.cacheDir}/$archiveName"
|
||||
val destFile = File(archivePath)
|
||||
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
IOUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
archivePath
|
||||
} else {
|
||||
Log.e(TAG, "saveArchiveFromUri null inputStream")
|
||||
@@ -617,7 +648,7 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
|
||||
try {
|
||||
m.controller.apiDeleteStorage()
|
||||
m.chatDbDeleted.value = true
|
||||
DatabaseUtils.removeDatabaseKey()
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
m.controller.appPrefs.storeDBPassphrase.set(true)
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
|
||||
|
||||
@@ -3,13 +3,15 @@ package chat.simplex.app.views.helpers
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import chat.simplex.app.R
|
||||
@@ -33,13 +35,14 @@ class AlertManager {
|
||||
text: String? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
backgroundColor = if (isInDarkTheme()) Color(0xff222222) else MaterialTheme.colors.background,
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
buttons = buttons
|
||||
title = alertTitle(title),
|
||||
text = alertText(text),
|
||||
buttons = buttons,
|
||||
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -51,22 +54,21 @@ class AlertManager {
|
||||
) {
|
||||
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
|
||||
Column(
|
||||
Modifier
|
||||
.background(if (isInDarkTheme()) Color(0xff222222) else MaterialTheme.colors.background, RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||
.padding(bottom = DEFAULT_PADDING)
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
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) {
|
||||
if (text != null) {
|
||||
Text(text, Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), fontSize = 16.sp, textAlign = TextAlign.Center, color = HighOrLowlight)
|
||||
}
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
@@ -84,24 +86,28 @@ class AlertManager {
|
||||
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,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
title = alertTitle(title),
|
||||
text = alertText(text),
|
||||
buttons = {
|
||||
Row (
|
||||
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
}
|
||||
backgroundColor = if (isInDarkTheme()) Color(0xff222222) else MaterialTheme.colors.background,
|
||||
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,16 +122,15 @@ class AlertManager {
|
||||
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,
|
||||
title = alertTitle(title),
|
||||
text = alertText(text),
|
||||
buttons = {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp).padding(top = 16.dp, bottom = 2.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
@@ -134,9 +139,11 @@ class AlertManager {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
}) { Text(confirmText, color = if (destructive) Color.Red else Color.Unspecified, textAlign = TextAlign.End) }
|
||||
}
|
||||
},
|
||||
backgroundColor = if (isInDarkTheme()) Color(0xff222222) else MaterialTheme.colors.background,
|
||||
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -145,18 +152,24 @@ class AlertManager {
|
||||
title: String, text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
}
|
||||
title = alertTitle(title),
|
||||
text = alertText(text),
|
||||
buttons = {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText, color = Color.Unspecified) }
|
||||
}
|
||||
},
|
||||
backgroundColor = if (isInDarkTheme()) Color(0xff222222) else MaterialTheme.colors.background,
|
||||
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -177,3 +190,30 @@ class AlertManager {
|
||||
val shared = AlertManager()
|
||||
}
|
||||
}
|
||||
|
||||
private fun alertTitle(title: String): (@Composable () -> Unit)? {
|
||||
return {
|
||||
Text(
|
||||
title,
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun alertText(text: String?): (@Composable () -> Unit)? {
|
||||
return if (text == null) {
|
||||
null
|
||||
} else {
|
||||
({
|
||||
Text(
|
||||
text,
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 16.sp,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,26 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.newchat.ActionButton
|
||||
|
||||
sealed class AttachmentOption {
|
||||
object TakePhoto: AttachmentOption()
|
||||
object PickImage: AttachmentOption()
|
||||
object PickFile: AttachmentOption()
|
||||
object CameraPhoto: AttachmentOption()
|
||||
object GalleryImage: AttachmentOption()
|
||||
object GalleryVideo: AttachmentOption()
|
||||
object File: AttachmentOption()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -37,16 +42,20 @@ fun ChooseAttachmentView(
|
||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
||||
attachmentOption.value = AttachmentOption.TakePhoto
|
||||
ActionButton(Modifier.fillMaxWidth(0.25f), null, stringResource(R.string.use_camera_button), icon = painterResource(R.drawable.ic_camera_enhance)) {
|
||||
attachmentOption.value = AttachmentOption.CameraPhoto
|
||||
hide()
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
||||
attachmentOption.value = AttachmentOption.PickImage
|
||||
ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(R.string.gallery_image_button), icon = painterResource(R.drawable.ic_add_photo)) {
|
||||
attachmentOption.value = AttachmentOption.GalleryImage
|
||||
hide()
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
||||
attachmentOption.value = AttachmentOption.PickFile
|
||||
ActionButton(Modifier.fillMaxWidth(0.50f), null, stringResource(R.string.gallery_video_button), icon = painterResource(R.drawable.ic_smart_display)) {
|
||||
attachmentOption.value = AttachmentOption.GalleryVideo
|
||||
hide()
|
||||
}
|
||||
ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(R.string.choose_file), icon = painterResource(R.drawable.ic_note_add)) {
|
||||
attachmentOption.value = AttachmentOption.File
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ import android.content.res.Configuration
|
||||
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.res.stringResource
|
||||
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.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit = {}) {
|
||||
fun CloseSheetBar(close: (() -> Unit)?, endButtons: @Composable RowScope.() -> Unit = {}) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -22,8 +25,12 @@ fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit
|
||||
Modifier
|
||||
.padding(top = 4.dp), // Like in DefaultAppBar
|
||||
content = {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
NavigationButtonBack(close)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(TextFieldDefaults.MinHeight),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
NavigationButtonBack(onButtonClicked = close)
|
||||
Row {
|
||||
endButtons()
|
||||
}
|
||||
@@ -49,6 +56,19 @@ fun AppBarTitle(title: String, withPadding: Boolean = true) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColumnScope.AppBarTitleCentered(title: String, withPadding: Boolean = true) {
|
||||
Text(
|
||||
title,
|
||||
Modifier
|
||||
.padding(bottom = if (withPadding) DEFAULT_PADDING * 1.5f else 0.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h1,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
|
||||
import android.util.Log
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.model.SharedPreference
|
||||
import chat.simplex.app.views.usersettings.Cryptor
|
||||
import kotlinx.serialization.*
|
||||
import java.io.File
|
||||
@@ -16,30 +17,36 @@ object DatabaseUtils {
|
||||
}
|
||||
|
||||
private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
|
||||
private const val APP_PASSWORD_ALIAS: String = "appPassword"
|
||||
|
||||
val ksDatabasePassword = KeyStoreItem(DATABASE_PASSWORD_ALIAS, appPreferences.encryptedDBPassphrase, appPreferences.initializationVectorDBPassphrase)
|
||||
val ksAppPassword = KeyStoreItem(APP_PASSWORD_ALIAS, appPreferences.encryptedAppPassphrase, appPreferences.initializationVectorAppPassphrase)
|
||||
|
||||
class KeyStoreItem(private val alias: String, val passphrase: SharedPreference<String?>, val initVector: SharedPreference<String?>) {
|
||||
fun get(): String? {
|
||||
return cryptor.decryptData(
|
||||
passphrase.get()?.toByteArrayFromBase64() ?: return null,
|
||||
initVector.get()?.toByteArrayFromBase64() ?: return null,
|
||||
alias,
|
||||
)
|
||||
}
|
||||
|
||||
fun set(key: String) {
|
||||
val data = cryptor.encryptText(key, alias)
|
||||
passphrase.set(data.first.toBase64String())
|
||||
initVector.set(data.second.toBase64String())
|
||||
}
|
||||
|
||||
fun remove() {
|
||||
cryptor.deleteKey(alias)
|
||||
passphrase.set(null)
|
||||
initVector.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasDatabase(rootDir: String): Boolean =
|
||||
File(rootDir + File.separator + "files_chat.db").exists() && File(rootDir + File.separator + "files_agent.db").exists()
|
||||
|
||||
fun getDatabaseKey(): String? {
|
||||
return cryptor.decryptData(
|
||||
appPreferences.encryptedDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
|
||||
appPreferences.initializationVectorDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
|
||||
DATABASE_PASSWORD_ALIAS,
|
||||
)
|
||||
}
|
||||
|
||||
fun setDatabaseKey(key: String) {
|
||||
val data = cryptor.encryptText(key, DATABASE_PASSWORD_ALIAS)
|
||||
appPreferences.encryptedDBPassphrase.set(data.first.toBase64String())
|
||||
appPreferences.initializationVectorDBPassphrase.set(data.second.toBase64String())
|
||||
}
|
||||
|
||||
fun removeDatabaseKey() {
|
||||
cryptor.deleteKey(DATABASE_PASSWORD_ALIAS)
|
||||
appPreferences.encryptedDBPassphrase.set(null)
|
||||
appPreferences.initializationVectorDBPassphrase.set(null)
|
||||
}
|
||||
|
||||
fun useDatabaseKey(): String {
|
||||
Log.d(TAG, "useDatabaseKey ${appPreferences.storeDBPassphrase.get()}")
|
||||
var dbKey = ""
|
||||
@@ -47,10 +54,10 @@ object DatabaseUtils {
|
||||
if (useKeychain) {
|
||||
if (!hasDatabase(SimplexApp.context.dataDir.absolutePath)) {
|
||||
dbKey = randomDatabasePassword()
|
||||
setDatabaseKey(dbKey)
|
||||
ksDatabasePassword.set(dbKey)
|
||||
appPreferences.initialRandomDBPassphrase.set(true)
|
||||
} else {
|
||||
dbKey = getDatabaseKey() ?: ""
|
||||
dbKey = ksDatabasePassword.get() ?: ""
|
||||
}
|
||||
}
|
||||
return dbKey
|
||||
@@ -66,8 +73,39 @@ object DatabaseUtils {
|
||||
@Serializable
|
||||
sealed class DBMigrationResult {
|
||||
@Serializable @SerialName("ok") object OK: DBMigrationResult()
|
||||
@Serializable @SerialName("invalidConfirmation") object InvalidConfirmation: DBMigrationResult()
|
||||
@Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult()
|
||||
@Serializable @SerialName("error") class Error(val dbFile: String, val migrationError: String): DBMigrationResult()
|
||||
@Serializable @SerialName("errorMigration") class ErrorMigration(val dbFile: String, val migrationError: MigrationError): DBMigrationResult()
|
||||
@Serializable @SerialName("errorSQL") class ErrorSQL(val dbFile: String, val migrationSQLError: String): DBMigrationResult()
|
||||
@Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult()
|
||||
@Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum class MigrationConfirmation(val value: String) {
|
||||
YesUp("yesUp"),
|
||||
YesUpDown ("yesUpDown"),
|
||||
Error("error")
|
||||
}
|
||||
|
||||
fun defaultMigrationConfirmation(appPrefs: AppPreferences): MigrationConfirmation =
|
||||
if (appPrefs.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||
|
||||
@Serializable
|
||||
sealed class MigrationError {
|
||||
@Serializable @SerialName("upgrade") class Upgrade(val upMigrations: List<UpMigration>): MigrationError()
|
||||
@Serializable @SerialName("downgrade") class Downgrade(val downMigrations: List<String>): MigrationError()
|
||||
@Serializable @SerialName("migrationError") class Error(val mtrError: MTRError): MigrationError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UpMigration(
|
||||
val upName: String,
|
||||
// val withDown: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class MTRError {
|
||||
@Serializable @SerialName("noDown") class NoDown(val dbMigrations: List<String>): MTRError()
|
||||
@Serializable @SerialName("different") class Different(val appMigration: String, val dbMigration: String): MTRError()
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ package chat.simplex.app.views.helpers
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.outlined.Error
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -14,13 +19,18 @@ import androidx.compose.ui.focus.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.database.PassphraseStrength
|
||||
import chat.simplex.app.views.database.validKey
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@@ -110,3 +120,109 @@ fun DefaultBasicTextField(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun DefaultConfigurableTextField(
|
||||
state: MutableState<TextFieldValue>,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showPasswordStrength: Boolean = false,
|
||||
isValid: (String) -> Boolean,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
dependsOn: State<Any?>? = null,
|
||||
) {
|
||||
var valid by remember { mutableStateOf(validKey(state.value.text)) }
|
||||
var showKey by remember { mutableStateOf(false) }
|
||||
val icon = if (valid) {
|
||||
if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
|
||||
} else Icons.Outlined.Error
|
||||
val iconColor = if (valid) {
|
||||
if (showPasswordStrength && state.value.text.isNotEmpty()) PassphraseStrength.check(state.value.text).color else HighOrLowlight
|
||||
} else Color.Red
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val keyboardOptions = KeyboardOptions(
|
||||
imeAction = if (keyboardActions.onNext != null) ImeAction.Next else ImeAction.Done,
|
||||
autoCorrect = keyboardType != KeyboardType.Password,
|
||||
keyboardType = keyboardType
|
||||
)
|
||||
val enabled = true
|
||||
val colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Unspecified,
|
||||
textColor = MaterialTheme.colors.onBackground,
|
||||
focusedIndicatorColor = Color.Unspecified,
|
||||
unfocusedIndicatorColor = Color.Unspecified,
|
||||
)
|
||||
val color = MaterialTheme.colors.onBackground
|
||||
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
BasicTextField(
|
||||
value = state.value,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.backgroundColor(enabled).value, shape)
|
||||
.indicatorLine(enabled, false, interactionSource, colors)
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = TextFieldDefaults.MinHeight
|
||||
),
|
||||
onValueChange = {
|
||||
state.value = it
|
||||
},
|
||||
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||
visualTransformation = if (showKey || keyboardType != KeyboardType.Password)
|
||||
VisualTransformation.None
|
||||
else
|
||||
VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) },
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboard?.hide()
|
||||
keyboardActions.onDone?.invoke(this)
|
||||
}),
|
||||
singleLine = true,
|
||||
textStyle = TextStyle.Default.copy(
|
||||
color = color,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = state.value.text,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = { Text(placeholder, color = HighOrLowlight) },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
isError = !valid,
|
||||
trailingIcon = {
|
||||
if (keyboardType == KeyboardType.Password || !valid) {
|
||||
IconButton({ showKey = !showKey }) {
|
||||
Icon(icon, null, tint = iconColor)
|
||||
}
|
||||
}
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = colors
|
||||
)
|
||||
}
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
snapshotFlow { state.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
valid = isValid(it.text)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
snapshotFlow { dependsOn?.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
valid = isValid(state.value.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun DefaultDropdownMenu(
|
||||
showMenu: MutableState<Boolean>,
|
||||
offset: DpOffset = DpOffset(0.dp, 0.dp),
|
||||
dropdownMenuItems: (@Composable () -> Unit)?
|
||||
) {
|
||||
MaterialTheme(
|
||||
colors = MaterialTheme.colors.copy(surface = if (isInDarkTheme()) Color(0xFF080808) else MaterialTheme.colors.background),
|
||||
shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier
|
||||
.widthIn(min = 250.dp)
|
||||
.padding(vertical = 4.dp),
|
||||
offset = offset,
|
||||
) {
|
||||
dropdownMenuItems?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu(
|
||||
expanded: MutableState<Boolean>,
|
||||
modifier: Modifier = Modifier,
|
||||
dropdownMenuItems: (@Composable () -> Unit)?
|
||||
) {
|
||||
MaterialTheme(
|
||||
colors = MaterialTheme.colors.copy(surface = if (isInDarkTheme()) Color(0xFF080808) else MaterialTheme.colors.background),
|
||||
shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||
) {
|
||||
ExposedDropdownMenu(
|
||||
modifier = Modifier.widthIn(min = 200.dp).then(modifier),
|
||||
expanded = expanded.value,
|
||||
onDismissRequest = {
|
||||
expanded.value = false
|
||||
}
|
||||
) {
|
||||
dropdownMenuItems?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ fun DefaultTopAppBar(
|
||||
if (!showSearch) {
|
||||
title?.invoke()
|
||||
} else {
|
||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
|
||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), alwaysVisible = false, onSearchValueChanged)
|
||||
}
|
||||
},
|
||||
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
|
||||
@@ -45,10 +45,10 @@ fun DefaultTopAppBar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationButtonBack(onButtonClicked: () -> Unit) {
|
||||
IconButton(onButtonClicked) {
|
||||
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?) {
|
||||
IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) {
|
||||
Icon(
|
||||
Icons.Outlined.ArrowBackIos, stringResource(R.string.back), tint = MaterialTheme.colors.primary
|
||||
Icons.Outlined.ArrowBackIos, stringResource(R.string.back), tint = if (onButtonClicked != null) MaterialTheme.colors.primary else HighOrLowlight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
sealed class SharedContent {
|
||||
data class Text(val text: String): SharedContent()
|
||||
data class Images(val text: String, val uris: List<Uri>): SharedContent()
|
||||
data class Media(val text: String, val uris: List<Uri>): SharedContent()
|
||||
data class File(val text: String, val uri: Uri): SharedContent()
|
||||
}
|
||||
|
||||
@@ -48,4 +48,5 @@ object UriSerializer : KSerializer<Uri> {
|
||||
sealed class UploadContent {
|
||||
@Serializable data class SimpleImage(val uri: Uri): UploadContent()
|
||||
@Serializable data class AnimatedImage(val uri: Uri): UploadContent()
|
||||
@Serializable data class Video(val uri: Uri, val duration: Int): UploadContent()
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ 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
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun <T> ExposedDropDownSettingRow(
|
||||
@@ -33,7 +32,7 @@ fun <T> ExposedDropDownSettingRow(
|
||||
Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
@@ -46,9 +45,9 @@ fun <T> ExposedDropDownSettingRow(
|
||||
Text(title, Modifier.weight(1f), color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
expanded = expanded.value,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded && enabled.value
|
||||
expanded.value = !expanded.value && enabled.value
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
@@ -66,29 +65,28 @@ fun <T> ExposedDropDownSettingRow(
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Icon(
|
||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
if (!expanded.value) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
generalGetString(R.string.icon_descr_more_button),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
DefaultExposedDropdownMenu(
|
||||
modifier = Modifier.widthIn(min = 200.dp),
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
values.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onSelected(selectionOption.first)
|
||||
expanded = false
|
||||
}
|
||||
expanded.value = false
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)
|
||||
) {
|
||||
Text(
|
||||
selectionOption.second + (if (label != null) " $label" else ""),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.*
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.*
|
||||
import android.graphics.ImageDecoder.DecodeException
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
@@ -19,8 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
@@ -114,7 +112,7 @@ fun base64ToBitmap(base64ImageString: String): Bitmap {
|
||||
class CustomTakePicturePreview(var uri: Uri?, var tmpFile: File?): ActivityResultContract<Void?, Uri?>() {
|
||||
@CallSuper
|
||||
override fun createIntent(context: Context, input: Void?): Intent {
|
||||
tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
|
||||
tmpFile = File.createTempFile("image", ".bmp", File(getAppFilesDirectory(SimplexApp.context)))
|
||||
// Since the class should return Uri, the file should be deleted somewhere else. And in order to be sure, delegate this to system
|
||||
tmpFile?.deleteOnExit()
|
||||
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
|
||||
@@ -175,7 +173,18 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
|
||||
|
||||
@Composable
|
||||
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
|
||||
rememberLauncherForActivityResult(contract = GetMultipleContentsAndMimeTypes(), cb)
|
||||
|
||||
class GetMultipleContentsAndMimeTypes: ActivityResultContracts.GetMultipleContents() {
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
val mimeTypes = input.split(";")
|
||||
return super.createIntent(context, mimeTypes[0]).apply {
|
||||
if (mimeTypes.isNotEmpty()) {
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
|
||||
try {
|
||||
@@ -205,17 +214,10 @@ fun GetImageBottomSheet(
|
||||
val context = LocalContext.current
|
||||
val processPickedImage = { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
try {
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
val bitmap = getBitmapFromUri(uri)
|
||||
if (bitmap != null) {
|
||||
imageBitmap.value = uri
|
||||
onImageChange(bitmap)
|
||||
} catch (e: DecodeException) {
|
||||
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.image_decoding_exception_title),
|
||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,7 +258,7 @@ fun GetImageBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Image) {
|
||||
try {
|
||||
galleryLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
|
||||
@@ -1,36 +1,66 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.*
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.localauth.LocalAuthView
|
||||
import chat.simplex.app.views.usersettings.LAMode
|
||||
|
||||
sealed class LAResult {
|
||||
object Success: LAResult()
|
||||
class Error(val errString: CharSequence): LAResult()
|
||||
object Failed: LAResult()
|
||||
object Unavailable: LAResult()
|
||||
class Failed(val errString: CharSequence? = null): LAResult()
|
||||
class Unavailable(val errString: CharSequence? = null): LAResult()
|
||||
}
|
||||
|
||||
data class LocalAuthRequest (
|
||||
val title: String?,
|
||||
val reason: String,
|
||||
val password: String,
|
||||
val completed: (LAResult) -> Unit
|
||||
) {
|
||||
companion object {
|
||||
val sample = LocalAuthRequest(generalGetString(R.string.la_enter_app_passcode), generalGetString(R.string.la_authenticate), "") { }
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
activity: FragmentActivity,
|
||||
usingLAMode: LAMode = SimplexApp.context.chatModel.controller.appPrefs.laMode.get(),
|
||||
completed: (LAResult) -> Unit
|
||||
) {
|
||||
when {
|
||||
SDK_INT in 28..29 ->
|
||||
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
SDK_INT > 29 ->
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
else ->
|
||||
completed(LAResult.Unavailable)
|
||||
when (usingLAMode) {
|
||||
LAMode.SYSTEM -> when {
|
||||
SDK_INT in 28..29 ->
|
||||
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
SDK_INT > 29 ->
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
else -> completed(LAResult.Unavailable())
|
||||
}
|
||||
LAMode.PASSCODE -> {
|
||||
val password = ksAppPassword.get() ?: return completed(LAResult.Unavailable(generalGetString(R.string.la_no_app_password)))
|
||||
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password) {
|
||||
close()
|
||||
completed(it)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +96,7 @@ private fun authenticateWithBiometricManager(
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
completed(LAResult.Failed)
|
||||
completed(LAResult.Failed())
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -78,9 +108,7 @@ private fun authenticateWithBiometricManager(
|
||||
.build()
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
else -> {
|
||||
completed(LAResult.Unavailable)
|
||||
}
|
||||
else -> completed(LAResult.Unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +117,18 @@ fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
|
||||
)
|
||||
|
||||
fun laPasscodeNotSetAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.lock_not_enabled),
|
||||
generalGetString(R.string.you_can_turn_on_lock)
|
||||
)
|
||||
|
||||
fun laFailedAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.la_auth_failed),
|
||||
text = generalGetString(R.string.la_could_not_be_verified)
|
||||
)
|
||||
}
|
||||
|
||||
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_unavailable),
|
||||
generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
|
||||
|
||||
@@ -39,6 +39,7 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||
}
|
||||
|
||||
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
|
||||
VideoPlayer.stopAll()
|
||||
AudioPlayer.stop()
|
||||
val rec: MediaRecorder
|
||||
recorder = initRecorder().also { rec = it }
|
||||
@@ -152,6 +153,7 @@ object AudioPlayer {
|
||||
return null
|
||||
}
|
||||
|
||||
VideoPlayer.stopAll()
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
val current = currentlyPlaying.value
|
||||
if (current == null || current.first != filePath) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -29,15 +30,18 @@ import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) {
|
||||
fun SearchTextField(modifier: Modifier, placeholder: String, alwaysVisible: Boolean, onValueChange: (String) -> Unit) {
|
||||
var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
delay(200)
|
||||
keyboard?.show()
|
||||
if (!alwaysVisible) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
delay(200)
|
||||
keyboard?.show()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -87,7 +91,14 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str
|
||||
Text(placeholder)
|
||||
},
|
||||
trailingIcon = if (searchText.text.isNotEmpty()) {{
|
||||
IconButton({ searchText = TextFieldValue(""); onValueChange("") }) {
|
||||
IconButton({
|
||||
if (alwaysVisible) {
|
||||
keyboard?.hide()
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
searchText = TextFieldValue("");
|
||||
onValueChange("")
|
||||
}) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
|
||||
}
|
||||
}} else null,
|
||||
|
||||
@@ -85,13 +85,14 @@ fun SectionItemView(
|
||||
click: (() -> Unit)? = null,
|
||||
minHeight: Dp = 46.dp,
|
||||
disabled: Boolean = false,
|
||||
padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING),
|
||||
content: (@Composable RowScope.() -> Unit)
|
||||
) {
|
||||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = minHeight)
|
||||
Row(
|
||||
if (click == null || disabled) modifier.padding(horizontal = DEFAULT_PADDING) else modifier.clickable(onClick = click).padding(horizontal = DEFAULT_PADDING),
|
||||
if (click == null || disabled) modifier.padding(padding) else modifier.clickable(onClick = click).padding(padding),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
@@ -81,6 +82,7 @@ fun imageMimeType(fileName: String): String {
|
||||
}
|
||||
}
|
||||
|
||||
/** Before calling, make sure the user allows to write to external storage [Manifest.permission.WRITE_EXTERNAL_STORAGE] */
|
||||
fun saveImage(cxt: Context, ciFile: CIFile?) {
|
||||
val filePath = getLoadedFilePath(cxt, ciFile)
|
||||
val fileName = ciFile?.fileName
|
||||
|
||||
@@ -12,6 +12,8 @@ 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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -28,6 +30,21 @@ fun SimpleButton(text: String, icon: ImageVector,
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleButtonDecorated(text: String, icon: ImageVector,
|
||||
color: Color = MaterialTheme.colors.primary,
|
||||
textDecoration: TextDecoration = TextDecoration.Underline,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
click: () -> Unit) {
|
||||
SimpleButtonFrame(click) {
|
||||
Icon(
|
||||
icon, text, tint = color,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(text, style = MaterialTheme.typography.caption, fontWeight = fontWeight, color = color, textDecoration = textDecoration)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleButton(
|
||||
text: String, icon: ImageVector,
|
||||
@@ -61,9 +78,9 @@ fun SimpleButtonIconEnded(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleButtonFrame(click: () -> Unit, disabled: Boolean = false, content: @Composable () -> Unit) {
|
||||
fun SimpleButtonFrame(click: () -> Unit, modifier: Modifier = Modifier, disabled: Boolean = false, content: @Composable () -> Unit) {
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
val modifier = if (disabled) Modifier else Modifier.clickable { click() }
|
||||
val modifier = if (disabled) modifier else modifier.clickable { click() }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.padding(8.dp)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
//import android.app.LocaleManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.*
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.FileUtils
|
||||
import android.os.*
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.Spanned
|
||||
import android.text.SpannedString
|
||||
@@ -29,11 +35,12 @@ import androidx.compose.ui.unit.*
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.text.HtmlCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.CIFile
|
||||
import chat.simplex.app.model.json
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.apache.commons.io.IOUtils
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -231,12 +238,18 @@ 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_FILE_SIZE: Long = 8000000
|
||||
const val MAX_FILE_SIZE_SMP: Long = 8000000
|
||||
|
||||
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824
|
||||
|
||||
fun getFilesDirectory(context: Context): String {
|
||||
return context.filesDir.toString()
|
||||
}
|
||||
|
||||
fun getTempFilesDirectory(context: Context): String {
|
||||
return "${getFilesDirectory(context)}/temp_files"
|
||||
}
|
||||
|
||||
fun getAppFilesDirectory(context: Context): String {
|
||||
return "${getFilesDirectory(context)}/app_files"
|
||||
}
|
||||
@@ -319,6 +332,14 @@ fun getFileName(context: Context, uri: Uri): String? {
|
||||
}
|
||||
}
|
||||
|
||||
fun getAppFilePath(context: Context, uri: Uri): String? {
|
||||
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
getAppFilePath(context, cursor.getString(nameIndex))
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileSize(context: Context, uri: Uri): Long? {
|
||||
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
@@ -327,9 +348,48 @@ fun getFileSize(context: Context, uri: Uri): Long? {
|
||||
}
|
||||
}
|
||||
|
||||
fun getBitmapFromUri(uri: Uri, withAlertOnException: Boolean = true): Bitmap? {
|
||||
return if (Build.VERSION.SDK_INT >= 28) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
try {
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
} catch (e: android.graphics.ImageDecoder.DecodeException) {
|
||||
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
|
||||
if (withAlertOnException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.image_decoding_exception_title),
|
||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
} else {
|
||||
BitmapFactory.decodeFile(getAppFilePath(SimplexApp.context, uri))
|
||||
}
|
||||
}
|
||||
|
||||
fun getDrawableFromUri(uri: Uri, withAlertOnException: Boolean = true): Drawable? {
|
||||
return if (Build.VERSION.SDK_INT >= 28) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
try {
|
||||
ImageDecoder.decodeDrawable(source)
|
||||
} catch (e: android.graphics.ImageDecoder.DecodeException) {
|
||||
if (withAlertOnException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.image_decoding_exception_title),
|
||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||
)
|
||||
}
|
||||
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Drawable.createFromPath(getAppFilePath(SimplexApp.context, uri))
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImage(context: Context, uri: Uri): String? {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
val bitmap = getBitmapFromUri(uri) ?: return null
|
||||
return saveImage(context, bitmap)
|
||||
}
|
||||
|
||||
@@ -400,7 +460,7 @@ fun saveFileFromUri(context: Context, uri: Uri): String? {
|
||||
if (inputStream != null && fileToSave != null) {
|
||||
val destFileName = uniqueCombine(context, fileToSave)
|
||||
val destFile = File(getAppFilePath(context, destFileName))
|
||||
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
IOUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
destFileName
|
||||
} else {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
|
||||
@@ -436,9 +496,9 @@ fun formatBytes(bytes: Long): String {
|
||||
return "0 bytes"
|
||||
}
|
||||
val bytesDouble = bytes.toDouble()
|
||||
val k = 1000.toDouble()
|
||||
val k = 1024.toDouble()
|
||||
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||
val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
|
||||
val i = floor(log2(bytesDouble) / log2(k))
|
||||
val size = bytesDouble / k.pow(i)
|
||||
val unit = units[i.toInt()]
|
||||
|
||||
@@ -483,6 +543,26 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
|
||||
return fileCount to bytes
|
||||
}
|
||||
|
||||
fun getMaxFileSize(fileProtocol: FileProtocol): Long {
|
||||
return when (fileProtocol) {
|
||||
FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP
|
||||
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
|
||||
}
|
||||
}
|
||||
|
||||
fun getBitmapFromVideo(uri: Uri, timestamp: Long? = null, random: Boolean = true): VideoPlayer.PreviewAndDuration {
|
||||
val mmr = MediaMetadataRetriever()
|
||||
mmr.setDataSource(SimplexApp.context, uri)
|
||||
val durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
|
||||
val image = when {
|
||||
timestamp != null -> mmr.getFrameAtTime(timestamp * 1000, MediaMetadataRetriever.OPTION_CLOSEST)
|
||||
random -> mmr.frameAtTime
|
||||
else -> mmr.getFrameAtIndex(0)
|
||||
}
|
||||
mmr.release()
|
||||
return VideoPlayer.PreviewAndDuration(image, durationMs, timestamp ?: 0)
|
||||
}
|
||||
|
||||
fun Color.darker(factor: Float = 0.1f): Color =
|
||||
Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha)
|
||||
|
||||
@@ -501,3 +581,79 @@ inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
|
||||
save = { json.encodeToString(it) },
|
||||
restore = { json.decodeFromString(it) }
|
||||
)
|
||||
|
||||
fun saveAppLocale(pref: SharedPreference<String?>, activity: Activity, languageCode: String? = null) {
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// val localeManager = SimplexApp.context.getSystemService(LocaleManager::class.java)
|
||||
// localeManager.applicationLocales = LocaleList(Locale.forLanguageTag(languageCode ?: return))
|
||||
// } else {
|
||||
pref.set(languageCode)
|
||||
if (languageCode == null) {
|
||||
activity.applyLocale(SimplexApp.context.defaultLocale)
|
||||
}
|
||||
activity.recreate()
|
||||
// }
|
||||
}
|
||||
|
||||
fun Activity.applyAppLocale(pref: SharedPreference<String?>) {
|
||||
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
val lang = pref.get()
|
||||
if (lang == null || lang == Locale.getDefault().language) return
|
||||
applyLocale(Locale.forLanguageTag(lang))
|
||||
// }
|
||||
}
|
||||
|
||||
private fun Activity.applyLocale(locale: Locale) {
|
||||
Locale.setDefault(locale)
|
||||
val appConf = Configuration(SimplexApp.context.resources.configuration).apply { setLocale(locale) }
|
||||
val activityConf = Configuration(resources.configuration).apply { setLocale(locale) }
|
||||
@Suppress("DEPRECATION")
|
||||
SimplexApp.context.resources.updateConfiguration(appConf, resources.displayMetrics)
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(activityConf, resources.displayMetrics)
|
||||
}
|
||||
|
||||
fun UriHandler.openUriCatching(uri: String) {
|
||||
try {
|
||||
openUri(uri)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
fun IntSize.Companion.Saver(): Saver<IntSize, *> = Saver(
|
||||
save = { it.width to it.height },
|
||||
restore = { IntSize(it.first, it.second) }
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
always()
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
whenDispose()
|
||||
if (orientation == activity.resources.configuration.orientation) {
|
||||
whenGone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisposableEffectOnRotate(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenRotate: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
always()
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
whenDispose()
|
||||
if (orientation != activity.resources.configuration.orientation) {
|
||||
whenRotate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.C.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
|
||||
class VideoPlayer private constructor(
|
||||
private val uri: Uri,
|
||||
private val gallery: Boolean,
|
||||
private val defaultPreview: Bitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean,
|
||||
context: Context
|
||||
) {
|
||||
companion object {
|
||||
private val players: MutableMap<Pair<Uri, Boolean>, VideoPlayer> = mutableMapOf()
|
||||
private val previewsAndDurations: MutableMap<Uri, PreviewAndDuration> = mutableMapOf()
|
||||
|
||||
fun getOrCreate(
|
||||
uri: Uri,
|
||||
gallery: Boolean,
|
||||
defaultPreview: Bitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean,
|
||||
context: Context
|
||||
): VideoPlayer =
|
||||
players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled, context) }
|
||||
|
||||
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean =
|
||||
player(fileName, gallery)?.enableSound(enable) == true
|
||||
|
||||
private fun player(fileName: String?, gallery: Boolean): VideoPlayer? {
|
||||
fileName ?: return null
|
||||
return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery }
|
||||
}
|
||||
|
||||
fun release(uri: Uri, gallery: Boolean, remove: Boolean) =
|
||||
player(uri.path, gallery)?.release(remove)
|
||||
|
||||
fun stopAll() {
|
||||
players.values.forEach { it.stop() }
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
players.values.forEach { it.release(false) }
|
||||
players.clear()
|
||||
previewsAndDurations.clear()
|
||||
}
|
||||
}
|
||||
|
||||
data class PreviewAndDuration(val preview: Bitmap?, val duration: Long?, val timestamp: Long)
|
||||
|
||||
private val currentVolume: Float
|
||||
val soundEnabled: MutableState<Boolean> = mutableStateOf(soundEnabled)
|
||||
val brokenVideo: MutableState<Boolean> = mutableStateOf(false)
|
||||
val videoPlaying: MutableState<Boolean> = mutableStateOf(false)
|
||||
val progress: MutableState<Long> = mutableStateOf(0L)
|
||||
val duration: MutableState<Long> = mutableStateOf(defaultDuration)
|
||||
val preview: MutableState<Bitmap> = mutableStateOf(defaultPreview)
|
||||
|
||||
init {
|
||||
setPreviewAndDuration()
|
||||
}
|
||||
|
||||
val player = ExoPlayer.Builder(context,
|
||||
DefaultRenderersFactory(context))
|
||||
/*.setLoadControl(DefaultLoadControl.Builder()
|
||||
.setPrioritizeTimeOverSizeThresholds(false) // Could probably save some megabytes in memory in case it will be needed
|
||||
.createDefaultLoadControl())*/
|
||||
.setSeekBackIncrementMs(10_000)
|
||||
.setSeekForwardIncrementMs(10_000)
|
||||
.build()
|
||||
.apply {
|
||||
// Repeat the same track endlessly
|
||||
repeatMode = 1
|
||||
currentVolume = volume
|
||||
if (!soundEnabled) {
|
||||
volume = 0f
|
||||
}
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(CONTENT_TYPE_MUSIC)
|
||||
.setUsage(USAGE_MEDIA)
|
||||
.build(),
|
||||
true // disallow to play multiple instances simultaneously
|
||||
)
|
||||
}
|
||||
|
||||
private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null)
|
||||
private var progressJob: Job? = null
|
||||
|
||||
enum class TrackState {
|
||||
PLAYING, PAUSED, STOPPED
|
||||
}
|
||||
|
||||
private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean {
|
||||
val filepath = getAppFilePath(SimplexApp.context, uri)
|
||||
if (filepath == null || !File(filepath).exists()) {
|
||||
Log.e(TAG, "No such file: $uri")
|
||||
brokenVideo.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
if (soundEnabled.value) {
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
}
|
||||
AudioPlayer.stop()
|
||||
stopAll()
|
||||
if (listener.value == null) {
|
||||
runCatching {
|
||||
val dataSourceFactory = DefaultDataSource.Factory(SimplexApp.context, DefaultHttpDataSource.Factory())
|
||||
val source = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(uri))
|
||||
player.setMediaSource(source, seek ?: 0L)
|
||||
}.onFailure {
|
||||
Log.e(TAG, it.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||
brokenVideo.value = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (player.playbackState == PlaybackState.STATE_NONE || player.playbackState == PlaybackState.STATE_STOPPED) {
|
||||
runCatching { player.prepare() }.onFailure {
|
||||
// Can happen when video file is broken
|
||||
Log.e(TAG, it.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||
brokenVideo.value = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (seek != null) player.seekTo(seek)
|
||||
player.play()
|
||||
listener.value = onProgressUpdate
|
||||
// Player can only be accessed in one specific thread
|
||||
progressJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||
while (isActive && player.playbackState != Player.STATE_IDLE && player.playWhenReady) {
|
||||
// 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)
|
||||
}
|
||||
player.addListener(object: Player.Listener{
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
// Produce non-ideal transition from stopped to playing state while showing preview image in ChatView
|
||||
// videoPlaying.value = isPlaying
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
player.stop()
|
||||
stopListener()
|
||||
}
|
||||
|
||||
private fun stopListener() {
|
||||
val afterCoroutineCancel: CompletionHandler = {
|
||||
// Notify prev video listener about stop
|
||||
listener.value?.invoke(null, TrackState.STOPPED)
|
||||
}
|
||||
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
|
||||
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.STOPPED] (in this order)
|
||||
* */
|
||||
if (progressJob != null) {
|
||||
progressJob?.invokeOnCompletion(afterCoroutineCancel)
|
||||
} else {
|
||||
afterCoroutineCancel(null)
|
||||
}
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
}
|
||||
|
||||
fun play(resetOnEnd: Boolean) {
|
||||
if (progress.value == duration.value) {
|
||||
progress.value = 0
|
||||
}
|
||||
videoPlaying.value = start(progress.value) { pro, _ ->
|
||||
if (pro != null) {
|
||||
progress.value = pro
|
||||
}
|
||||
if (pro == null || pro == duration.value) {
|
||||
videoPlaying.value = false
|
||||
if (pro == duration.value) {
|
||||
progress.value = if (resetOnEnd) 0 else duration.value
|
||||
}/* else if (state == TrackState.STOPPED) {
|
||||
progress.value = 0 //
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableSound(enable: Boolean): Boolean {
|
||||
if (soundEnabled.value == enable) return false
|
||||
soundEnabled.value = enable
|
||||
player.volume = if (enable) currentVolume else 0f
|
||||
return true
|
||||
}
|
||||
|
||||
fun release(remove: Boolean) {
|
||||
player.release()
|
||||
if (remove) {
|
||||
players.remove(uri to gallery)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPreviewAndDuration() {
|
||||
// It freezes main thread, doing it in IO thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val previewAndDuration = previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) }
|
||||
withContext(Dispatchers.Main) {
|
||||
preview.value = previewAndDuration.preview ?: defaultPreview
|
||||
duration.value = (previewAndDuration.duration ?: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package chat.simplex.app.views.localauth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) {
|
||||
val passcode = rememberSaveable { mutableStateOf("") }
|
||||
PasscodeView(passcode, authRequest.title ?: stringResource(R.string.la_enter_app_passcode), authRequest.reason, stringResource(R.string.submit_passcode),
|
||||
submit = {
|
||||
val r: LAResult = if (passcode.value == authRequest.password) LAResult.Success else LAResult.Error(generalGetString(R.string.incorrect_passcode))
|
||||
authRequest.completed(r)
|
||||
},
|
||||
cancel = {
|
||||
authRequest.completed(LAResult.Error(generalGetString(R.string.authentication_cancelled)))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package chat.simplex.app.views.localauth
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun PasscodeView(
|
||||
passcode: MutableState<String>,
|
||||
title: String,
|
||||
reason: String? = null,
|
||||
submitLabel: String,
|
||||
submitEnabled: ((String) -> Boolean)? = null,
|
||||
submit: () -> Unit,
|
||||
cancel: () -> Unit,
|
||||
) {
|
||||
@Composable
|
||||
fun VerticalLayout() {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.h1)
|
||||
if (reason != null) {
|
||||
Text(reason, Modifier.padding(top = 5.dp), style = MaterialTheme.typography.subtitle1)
|
||||
}
|
||||
}
|
||||
PasscodeEntry(passcode, true)
|
||||
Row {
|
||||
SimpleButton(generalGetString(R.string.cancel_verb), icon = Icons.Default.Close, click = cancel)
|
||||
Spacer(Modifier.size(20.dp))
|
||||
SimpleButton(submitLabel, icon = Icons.Default.Done, disabled = submitEnabled?.invoke(passcode.value) == false || passcode.value.length < 4, click = submit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HorizontalLayout() {
|
||||
Row(Modifier.padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.Center) {
|
||||
Column(
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING * 4),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.h1)
|
||||
if (reason != null) {
|
||||
Text(reason, Modifier.padding(top = 5.dp), style = MaterialTheme.typography.subtitle1)
|
||||
}
|
||||
}
|
||||
PasscodeEntry(passcode, false)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING * 4),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Just to fill space to correctly calculate the height
|
||||
Column {
|
||||
Text("", style = MaterialTheme.typography.h1)
|
||||
if (reason != null) {
|
||||
Text("", Modifier.padding(top = 5.dp), style = MaterialTheme.typography.subtitle1)
|
||||
}
|
||||
PasscodeView(remember { mutableStateOf("") })
|
||||
}
|
||||
BoxWithConstraints {
|
||||
val s = minOf(maxWidth, maxHeight) / 3.5f
|
||||
Column(
|
||||
Modifier.padding(start = 30.dp).height(s * 3),
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
SimpleButton(generalGetString(R.string.cancel_verb), icon = Icons.Default.Close, click = cancel)
|
||||
SimpleButton(submitLabel, icon = Icons.Default.Done, disabled = submitEnabled?.invoke(passcode.value) == false || passcode.value.length < 4, click = submit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (LocalContext.current.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) {
|
||||
VerticalLayout()
|
||||
} else {
|
||||
HorizontalLayout()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package chat.simplex.app.views.localauth
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.filled.Close
|
||||
import androidx.compose.material.icons.outlined.Backspace
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
|
||||
@Composable
|
||||
fun PasscodeEntry(
|
||||
password: MutableState<String>,
|
||||
vertical: Boolean,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
PasscodeView(password)
|
||||
BoxWithConstraints {
|
||||
if (vertical) {
|
||||
VerticalPasswordGrid(password)
|
||||
} else {
|
||||
HorizontalPasswordGrid(password)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PasscodeView(password: MutableState<String>) {
|
||||
var showPasscode by rememberSaveable { mutableStateOf(false) }
|
||||
Text(
|
||||
if (password.value.isEmpty()) " " else remember(password.value, showPasscode) { splitPassword(showPasscode, password.value) },
|
||||
Modifier.padding(vertical = 10.dp).clickable { showPasscode = !showPasscode },
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxWithConstraintsScope.VerticalPasswordGrid(password: MutableState<String>) {
|
||||
val s = minOf(maxWidth, maxHeight) / 4 - 1.dp
|
||||
Column(Modifier.width(IntrinsicSize.Min)) {
|
||||
DigitsRow(s, 1, 2, 3, password)
|
||||
Divider()
|
||||
DigitsRow(s, 4, 5, 6, password)
|
||||
Divider()
|
||||
DigitsRow(s, 7, 8, 9, password)
|
||||
Divider()
|
||||
Row(Modifier.requiredHeight(s)) {
|
||||
PasswordEdit(s, Icons.Default.Close) {
|
||||
password.value = ""
|
||||
}
|
||||
VerticalDivider()
|
||||
PasswordDigit(s, 0, password)
|
||||
VerticalDivider()
|
||||
PasswordEdit(s, Icons.Outlined.Backspace) {
|
||||
password.value = password.value.dropLast(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxWithConstraintsScope.HorizontalPasswordGrid(password: MutableState<String>) {
|
||||
val s = minOf(maxWidth, maxHeight) / 3.5f - 1.dp
|
||||
Column(Modifier.width(IntrinsicSize.Min)) {
|
||||
Row(Modifier.height(IntrinsicSize.Min)) {
|
||||
DigitsRow(s, 1, 2, 3, password);
|
||||
VerticalDivider()
|
||||
PasswordEdit(s, Icons.Default.Close) {
|
||||
password.value = ""
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Row(Modifier.height(IntrinsicSize.Min)) {
|
||||
DigitsRow(s, 4, 5, 6, password)
|
||||
VerticalDivider()
|
||||
PasswordDigit(s, 0, password)
|
||||
}
|
||||
Divider()
|
||||
Row(Modifier.height(IntrinsicSize.Min)) {
|
||||
DigitsRow(s, 7, 8, 9, password)
|
||||
VerticalDivider()
|
||||
PasswordEdit(s, Icons.Outlined.Backspace) {
|
||||
password.value = password.value.dropLast(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun splitPassword(showPassword: Boolean, password: String): String {
|
||||
val n = if (password.length < 8) 8 else 4
|
||||
return password.mapIndexed { index, c -> (if (showPassword) c.toString() else "●") + (if ((index + 1) % n == 0) " " else "") }.joinToString("")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DigitsRow(size: Dp, d1: Int, d2: Int, d3: Int, password: MutableState<String>) {
|
||||
Row(Modifier.height(size)) {
|
||||
PasswordDigit(size, d1, password)
|
||||
VerticalDivider()
|
||||
PasswordDigit(size, d2, password)
|
||||
VerticalDivider()
|
||||
PasswordDigit(size, d3, password)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordDigit(size: Dp, d: Int, password: MutableState<String>) {
|
||||
val s = d.toString()
|
||||
return PasswordButton(size, action = {
|
||||
if (password.value.length < 16) {
|
||||
password.value += s
|
||||
}
|
||||
}) {
|
||||
Text(
|
||||
s,
|
||||
style = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 30.sp,
|
||||
letterSpacing = (-0.5).sp
|
||||
),
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordEdit(size: Dp, image: ImageVector, action: () -> Unit) {
|
||||
PasswordButton(size, action) {
|
||||
Icon(image, null, tint = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordButton(size: Dp, action: () -> Unit, content: @Composable BoxScope.() -> Unit) {
|
||||
return Box(
|
||||
Modifier.size(size)
|
||||
.background(MaterialTheme.colors.background, RoundedCornerShape(50))
|
||||
.clickable { action() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalDivider(
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colors.onSurface.copy(alpha = DividerAlpha),
|
||||
thickness: Dp = 1.dp,
|
||||
startIndent: Dp = 0.dp
|
||||
) {
|
||||
val indentMod = if (startIndent.value != 0f) {
|
||||
Modifier.padding(top = startIndent)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
val targetThickness = if (thickness == Dp.Hairline) {
|
||||
(1f / LocalDensity.current.density).dp
|
||||
} else {
|
||||
thickness
|
||||
}
|
||||
Box(
|
||||
modifier.then(indentMod)
|
||||
.fillMaxHeight()
|
||||
.width(targetThickness)
|
||||
.background(color = color)
|
||||
)
|
||||
}
|
||||
|
||||
private const val DividerAlpha = 0.12f
|
||||
@@ -0,0 +1,48 @@
|
||||
package chat.simplex.app.views.localauth
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun SetAppPasscodeView(
|
||||
submit: () -> Unit,
|
||||
cancel: () -> Unit,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val passcode = rememberSaveable { mutableStateOf("") }
|
||||
var enteredPassword by rememberSaveable { mutableStateOf("") }
|
||||
var confirming by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
@Composable
|
||||
fun SetPasswordView(title: String, submitLabel: String, submitEnabled: (((String) -> Boolean))? = null, submit: () -> Unit) {
|
||||
PasscodeView(passcode, title = title, submitLabel = submitLabel, submitEnabled = submitEnabled, submit = submit) {
|
||||
close()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
SetPasswordView(
|
||||
generalGetString(R.string.confirm_passcode),
|
||||
generalGetString(R.string.confirm_verb),
|
||||
submitEnabled = { pwd -> pwd == enteredPassword }
|
||||
) {
|
||||
if (passcode.value == enteredPassword) {
|
||||
ksAppPassword.set(passcode.value)
|
||||
enteredPassword = ""
|
||||
passcode.value = ""
|
||||
close()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SetPasswordView(generalGetString(R.string.new_passcode), generalGetString(R.string.save_verb)) {
|
||||
enteredPassword = passcode.value
|
||||
passcode.value = ""
|
||||
confirming = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,13 +88,14 @@ fun AddContactLayout(connReq: String, connIncognito: Boolean, share: () -> Unit)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String) {
|
||||
fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String, centered: Boolean = false) {
|
||||
if (chatModelIncognito) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start
|
||||
) {
|
||||
Icon(
|
||||
if (supportedIncognito) Icons.Filled.TheaterComedy else Icons.Outlined.Info,
|
||||
@@ -102,14 +103,15 @@ fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean
|
||||
tint = if (supportedIncognito) Indigo else WarningOrange,
|
||||
modifier = Modifier.padding(end = 10.dp).size(20.dp)
|
||||
)
|
||||
Text(onText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2)
|
||||
Text(onText, textAlign = if (centered) TextAlign.Center else TextAlign.Left, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Info,
|
||||
@@ -117,7 +119,7 @@ fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean
|
||||
tint = HighOrLowlight,
|
||||
modifier = Modifier.padding(end = 10.dp).size(20.dp)
|
||||
)
|
||||
Text(offText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2)
|
||||
Text(offText, textAlign = if (centered) TextAlign.Center else TextAlign.Left, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -15,6 +14,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -26,6 +27,7 @@ import chat.simplex.app.views.chat.group.AddGroupMembersView
|
||||
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import chat.simplex.app.views.usersettings.DeleteImageButton
|
||||
import chat.simplex.app.views.usersettings.EditImageButton
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
@@ -36,7 +38,6 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
|
||||
AddGroupLayout(
|
||||
chatModel.incognito.value,
|
||||
createGroup = { groupProfile ->
|
||||
withApi {
|
||||
val groupInfo = chatModel.controller.apiNewGroup(groupProfile)
|
||||
@@ -57,11 +58,11 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
|
||||
fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
val displayName = remember { mutableStateOf("") }
|
||||
val fullName = remember { mutableStateOf("") }
|
||||
val displayName = rememberSaveable { mutableStateOf("") }
|
||||
val fullName = rememberSaveable { mutableStateOf("") }
|
||||
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val profileImage = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
@@ -88,14 +89,8 @@ fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> U
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.create_secret_group_title), false)
|
||||
Text(stringResource(R.string.group_is_decentralized))
|
||||
InfoAboutIncognito(
|
||||
chatModelIncognito,
|
||||
false,
|
||||
generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
|
||||
generalGetString(R.string.group_main_profile_sent)
|
||||
)
|
||||
AppBarTitleCentered(stringResource(R.string.create_secret_group_title))
|
||||
ReadableText(R.string.group_is_decentralized, TextAlign.Center)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -104,7 +99,7 @@ fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> U
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(size = 192.dp, image = profileImage.value)
|
||||
ProfileImage(108.dp, image = profileImage.value)
|
||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||
}
|
||||
if (profileImage.value != null) {
|
||||
@@ -112,24 +107,28 @@ fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> U
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.group_display_name_field),
|
||||
Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
ProfileNameField(displayName, focusRequester)
|
||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(R.string.group_display_name_field),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
if (!isValidDisplayName(displayName.value)) {
|
||||
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||
Text(
|
||||
stringResource(R.string.no_spaces),
|
||||
fontSize = 16.sp,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Text(
|
||||
stringResource(R.string.group_full_name_field),
|
||||
Modifier.padding(bottom = 5.dp)
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
|
||||
ProfileNameField(fullName, "")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
if (enabled) {
|
||||
@@ -163,7 +162,7 @@ fun CreateGroupButton(color: Color, modifier: Modifier) {
|
||||
) {
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = color)
|
||||
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = color, fontWeight = FontWeight.Bold)
|
||||
Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = color)
|
||||
}
|
||||
}
|
||||
@@ -175,7 +174,6 @@ fun CreateGroupButton(color: Color, modifier: Modifier) {
|
||||
fun PreviewAddGroupLayout() {
|
||||
SimpleXTheme {
|
||||
AddGroupLayout(
|
||||
chatModelIncognito = false,
|
||||
createGroup = {},
|
||||
close = {}
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -211,6 +212,52 @@ fun ActionButton(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(
|
||||
modifier: Modifier,
|
||||
text: String?,
|
||||
comment: String?,
|
||||
icon: Painter,
|
||||
tint: Color = MaterialTheme.colors.primary,
|
||||
disabled: Boolean = false,
|
||||
click: () -> Unit = {}
|
||||
) {
|
||||
Surface(modifier, shape = RoundedCornerShape(18.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = click)
|
||||
.padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val tint = if (disabled) HighOrLowlight else tint
|
||||
Icon(
|
||||
icon, text,
|
||||
tint = tint,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
if (text != null) {
|
||||
Text(
|
||||
text,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = tint,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
if (comment != null) {
|
||||
Text(
|
||||
comment,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PreviewNewChatSheet() {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -36,7 +37,7 @@ fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = n
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Text(
|
||||
annotatedStringResource(R.string.read_more_in_github_with_link),
|
||||
modifier = Modifier.padding(bottom = 12.dp).clickable { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat#readme") },
|
||||
modifier = Modifier.padding(bottom = 12.dp).clickable { uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat#readme") },
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
} else {
|
||||
@@ -55,8 +56,13 @@ fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = n
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReadableText(@StringRes stringResId: Int) {
|
||||
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(bottom = 12.dp), lineHeight = 22.sp)
|
||||
fun ReadableText(@StringRes stringResId: Int, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp)) {
|
||||
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReadableText(text: String, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp)) {
|
||||
Text(text, modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@@ -70,4 +76,4 @@ fun PreviewHowItWorks() {
|
||||
SimpleXTheme {
|
||||
HowItWorks(user = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
@@ -23,22 +25,25 @@ 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))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 14.dp)
|
||||
) {
|
||||
//CloseSheetBar(null)
|
||||
AppBarTitleCentered(stringResource(R.string.onboarding_notifications_mode_title))
|
||||
val currentMode = rememberSaveable { mutableStateOf(NotificationsMode.default) }
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING * 1f)) {
|
||||
Text(stringResource(R.string.onboarding_notifications_mode_subtitle), Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING * 2f))
|
||||
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) {
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
|
||||
OnboardingActionButton(R.string.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage, false) {
|
||||
changeNotificationsMode(currentMode.value, m)
|
||||
}
|
||||
}
|
||||
@@ -51,18 +56,24 @@ private fun NotificationButton(currentMode: MutableState<NotificationsMode>, mod
|
||||
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),
|
||||
shape = RoundedCornerShape(35.dp),
|
||||
) {
|
||||
Column(Modifier.padding(bottom = 6.dp).padding(horizontal = 8.dp)) {
|
||||
Column(Modifier.padding(horizontal = 14.dp).padding(top = 4.dp, bottom = 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)
|
||||
modifier = Modifier.padding(bottom = 8.dp).align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(annotatedStringResource(description),
|
||||
Modifier.align(Alignment.CenterHorizontally),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
lineHeight = 24.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(annotatedStringResource(description), color = MaterialTheme.colors.onBackground, lineHeight = 24.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Spacer(Modifier.height(14.dp))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.res.Configuration
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
@@ -18,8 +19,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.User
|
||||
@@ -45,17 +45,17 @@ fun SimpleXInfoLayout(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
.padding(start = DEFAULT_PADDING * 1.5f, end = DEFAULT_PADDING * 1.5f, top = DEFAULT_PADDING * 4,/* bottom = DEFAULT_PADDING * 4*/),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 8.dp), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 10.dp), contentAlignment = Alignment.Center) {
|
||||
SimpleXLogo()
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 24.dp), textAlign = TextAlign.Center)
|
||||
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 60.dp).padding(horizontal = 48.dp), textAlign = TextAlign.Center)
|
||||
|
||||
InfoRow(painterResource(R.drawable.privacy), R.string.privacy_redefined, R.string.first_platform_without_user_ids)
|
||||
InfoRow(painterResource(R.drawable.privacy), R.string.privacy_redefined, R.string.first_platform_without_user_ids, width = 80.dp)
|
||||
InfoRow(painterResource(R.drawable.shield), R.string.immune_to_spam_and_abuse, R.string.people_can_connect_only_via_links_you_share)
|
||||
InfoRow(painterResource(R.drawable.decentralized), R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers)
|
||||
InfoRow(painterResource(if (isInDarkTheme()) R.drawable.decentralized_light else R.drawable.decentralized), R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers)
|
||||
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
|
||||
@@ -69,11 +69,12 @@ fun SimpleXInfoLayout(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp), contentAlignment = Alignment.Center
|
||||
.padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING), contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(text = stringResource(R.string.how_it_works), icon = Icons.Outlined.Info,
|
||||
SimpleButtonDecorated(text = stringResource(R.string.how_it_works), icon = Icons.Outlined.Info,
|
||||
click = showModal { HowItWorks(user, onboardingStage) })
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,19 +85,19 @@ fun SimpleXLogo() {
|
||||
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||
modifier = Modifier
|
||||
.padding(vertical = DEFAULT_PADDING)
|
||||
.fillMaxWidth(0.80f)
|
||||
.fillMaxWidth(0.60f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(icon: Painter, @StringRes titleId: Int, @StringRes textId: Int) {
|
||||
Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) {
|
||||
private fun InfoRow(icon: Painter, @StringRes titleId: Int, @StringRes textId: Int, width: Dp = 76.dp) {
|
||||
Row(Modifier.padding(bottom = 27.dp), verticalAlignment = Alignment.Top) {
|
||||
Image(icon, contentDescription = null, modifier = Modifier
|
||||
.width(60.dp)
|
||||
.padding(top = 8.dp, end = 16.dp))
|
||||
.width(width)
|
||||
.padding(top = 8.dp, start = 8.dp, end = 24.dp))
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Text(stringResource(titleId), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h3, lineHeight = 24.sp)
|
||||
Text(stringResource(textId), lineHeight = 24.sp, style = MaterialTheme.typography.caption)
|
||||
Text(stringResource(textId), lineHeight = 24.sp, style = MaterialTheme.typography.body1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,9 +105,9 @@ 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)
|
||||
OnboardingActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, true, onclick)
|
||||
} else {
|
||||
OnboardingActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
|
||||
OnboardingActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, true, onclick)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,16 +116,30 @@ fun OnboardingActionButton(
|
||||
@StringRes labelId: Int,
|
||||
onboarding: OnboardingStage?,
|
||||
onboardingStage: MutableState<OnboardingStage?>,
|
||||
border: Boolean,
|
||||
onclick: (() -> Unit)?
|
||||
) {
|
||||
val modifier = if (border) {
|
||||
Modifier
|
||||
.border(border = BorderStroke(1.dp, MaterialTheme.colors.primary), shape = RoundedCornerShape(50))
|
||||
.padding(
|
||||
horizontal = DEFAULT_PADDING * 3,
|
||||
vertical = 4.dp
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
SimpleButtonFrame(click = {
|
||||
onclick?.invoke()
|
||||
onboardingStage.value = onboarding
|
||||
}) {
|
||||
Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary)
|
||||
}, modifier) {
|
||||
Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary, fontSize = 20.sp)
|
||||
Icon(
|
||||
Icons.Outlined.ArrowForwardIos, "next stage", tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, top = 5.dp)
|
||||
.size(15.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@@ -35,16 +35,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
||||
Icon(
|
||||
Icons.Outlined.OpenInNew, stringResource(titleId), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable { uriHandler.openUri(link) }
|
||||
.clickable { uriHandler.openUriCatching(link) }
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
Icon(icon, stringResource(titleId), tint = HighOrLowlight)
|
||||
Text(
|
||||
@@ -107,8 +109,9 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
||||
ModalView(close = close) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
@@ -270,8 +273,66 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#translate-the-apps"
|
||||
)
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v4.6",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Lock,
|
||||
titleId = R.string.v4_6_hidden_chat_profiles,
|
||||
descrId = R.string.v4_6_hidden_chat_profiles_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Flag,
|
||||
titleId = R.string.v4_6_group_moderation,
|
||||
descrId = R.string.v4_6_group_moderation_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.MapsUgc,
|
||||
titleId = R.string.v4_6_group_welcome_message,
|
||||
descrId = R.string.v4_6_group_welcome_message_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Call,
|
||||
titleId = R.string.v4_6_audio_video_calls,
|
||||
descrId = R.string.v4_6_audio_video_calls_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Battery3Bar,
|
||||
titleId = R.string.v4_6_reduced_battery_usage,
|
||||
descrId = R.string.v4_6_reduced_battery_usage_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Translate,
|
||||
titleId = R.string.v4_6_chinese_spanish_interface,
|
||||
descrId = R.string.v4_6_chinese_spanish_interface_descr,
|
||||
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#translate-the-apps"
|
||||
)
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v5.0",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.UploadFile,
|
||||
titleId = R.string.v5_0_large_files_support,
|
||||
descrId = R.string.v5_0_large_files_support_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Lock,
|
||||
titleId = R.string.v5_0_app_passcode,
|
||||
descrId = R.string.v5_0_app_passcode_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Translate,
|
||||
titleId = R.string.v5_0_polish_interface,
|
||||
descrId = R.string.v5_0_polish_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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -102,15 +103,6 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
saveCfg(newCfg)
|
||||
}
|
||||
|
||||
fun updateSettingsDialog(action: () -> Unit) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.update_network_settings_question),
|
||||
text = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
|
||||
confirmText = generalGetString(R.string.update_network_settings_confirmation),
|
||||
onConfirm = action
|
||||
)
|
||||
}
|
||||
|
||||
AdvancedNetworkSettingsLayout(
|
||||
networkTCPConnectTimeout,
|
||||
networkTCPTimeout,
|
||||
@@ -121,10 +113,10 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
networkTCPKeepIntvl,
|
||||
networkTCPKeepCnt,
|
||||
resetDisabled = if (currentCfg.value.useSocksProxy) currentCfg.value == NetCfg.proxyDefaults else currentCfg.value == NetCfg.defaults,
|
||||
reset = { updateSettingsDialog(::reset) },
|
||||
reset = { showUpdateNetworkSettingsDialog(::reset) },
|
||||
footerDisabled = buildCfg() == currentCfg.value,
|
||||
revert = { updateView(currentCfg.value) },
|
||||
save = { updateSettingsDialog { saveCfg(buildCfg()) } }
|
||||
save = { showUpdateNetworkSettingsDialog { saveCfg(buildCfg()) } }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -262,14 +254,14 @@ fun IntSettingRow(title: String, selection: MutableState<Int>, values: List<Int>
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val expanded = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Text(title)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
expanded = expanded.value,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
@@ -285,24 +277,22 @@ fun IntSettingRow(title: String, selection: MutableState<Int>, values: List<Int>
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Icon(
|
||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
if (!expanded.value) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
generalGetString(R.string.invite_to_group_button),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
DefaultExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
values.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
selection.value = selectionOption
|
||||
expanded = false
|
||||
}
|
||||
expanded.value = false
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)
|
||||
) {
|
||||
Text(
|
||||
"$selectionOption $label",
|
||||
@@ -323,14 +313,14 @@ fun TimeoutSettingRow(title: String, selection: MutableState<Long>, values: List
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
|
||||
Text(title)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
expanded = expanded.value,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
) {
|
||||
val df = DecimalFormat("#.#")
|
||||
@@ -348,24 +338,22 @@ fun TimeoutSettingRow(title: String, selection: MutableState<Long>, values: List
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Icon(
|
||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
if (!expanded.value) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
generalGetString(R.string.invite_to_group_button),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
}
|
||||
DefaultExposedDropdownMenu(
|
||||
expanded = expanded
|
||||
) {
|
||||
values.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
selection.value = selectionOption
|
||||
expanded = false
|
||||
}
|
||||
expanded.value = false
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)
|
||||
) {
|
||||
Text(
|
||||
"${df.format(selectionOption / 1_000_000.toDouble())} $label",
|
||||
@@ -415,6 +403,15 @@ fun FooterButton(icon: ImageVector, title: String, action: () -> Unit, disabled:
|
||||
}
|
||||
}
|
||||
|
||||
fun showUpdateNetworkSettingsDialog(action: () -> Unit) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.update_network_settings_question),
|
||||
text = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
|
||||
confirmText = generalGetString(R.string.update_network_settings_confirmation),
|
||||
onConfirm = action
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewAdvancedNetworkSettingsLayout() {
|
||||
|
||||
@@ -2,13 +2,20 @@ package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionCustomFooter
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionItemWithValue
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
@@ -17,6 +24,7 @@ import androidx.compose.material.MaterialTheme.colors
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
@@ -30,9 +38,13 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.SharedPreference
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.godaddy.android.colorpicker.*
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.*
|
||||
|
||||
enum class AppIcon(val resId: Int) {
|
||||
DEFAULT(R.mipmap.icon),
|
||||
@@ -40,7 +52,7 @@ enum class AppIcon(val resId: Int) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppearanceView() {
|
||||
fun AppearanceView(m: ChatModel) {
|
||||
val appIcon = remember { mutableStateOf(findEnabledIcon()) }
|
||||
|
||||
fun setAppIcon(newIcon: AppIcon) {
|
||||
@@ -62,6 +74,7 @@ fun AppearanceView() {
|
||||
|
||||
AppearanceLayout(
|
||||
appIcon,
|
||||
m.controller.appPrefs.appLanguage,
|
||||
changeIcon = ::setAppIcon,
|
||||
editPrimaryColor = { primary ->
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
@@ -73,6 +86,7 @@ fun AppearanceView() {
|
||||
|
||||
@Composable fun AppearanceLayout(
|
||||
icon: MutableState<AppIcon>,
|
||||
languagePref: SharedPreference<String?>,
|
||||
changeIcon: (AppIcon) -> Unit,
|
||||
editPrimaryColor: (Color) -> Unit,
|
||||
) {
|
||||
@@ -81,6 +95,37 @@ fun AppearanceView() {
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.appearance_settings))
|
||||
SectionView(stringResource(R.string.settings_section_title_language), padding = PaddingValues()) {
|
||||
val context = LocalContext.current
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// SectionItemWithValue(
|
||||
// generalGetString(R.string.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
|
||||
// remember { mutableStateOf("system") },
|
||||
// listOf(ValueTitleDesc("system", generalGetString(R.string.change_verb), "")),
|
||||
// onSelected = { openSystemLangPicker(context as? Activity ?: return@SectionItemWithValue) }
|
||||
// )
|
||||
// } else {
|
||||
val state = rememberSaveable { mutableStateOf(languagePref.get() ?: "system") }
|
||||
SectionItemView {
|
||||
LangSelector(state) {
|
||||
state.value = it
|
||||
withApi {
|
||||
delay(200)
|
||||
val activity = context as? Activity
|
||||
if (activity != null) {
|
||||
if (it == "system") {
|
||||
saveAppLocale(languagePref, activity)
|
||||
} else {
|
||||
saveAppLocale(languagePref, activity, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_icon), padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
|
||||
LazyRow {
|
||||
items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index ->
|
||||
@@ -179,6 +224,33 @@ fun ColorPicker(initialColor: Color, onColorChanged: (Color) -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LangSelector(state: State<String>, onSelected: (String) -> Unit) {
|
||||
// Should be the same as in app/build.gradle's `android.defaultConfig.resConfigs`
|
||||
val supportedLanguages = mapOf(
|
||||
"system" to generalGetString(R.string.language_system),
|
||||
"en" to "English",
|
||||
"cs" to "Čeština",
|
||||
"de" to "Deutsch",
|
||||
"es" to "Español",
|
||||
"fr" to "Français",
|
||||
"it" to "Italiano",
|
||||
"nl" to "Nederlands",
|
||||
"pl" to "Polski",
|
||||
"ru" to "Русский",
|
||||
"zh-CN" to "简体中文"
|
||||
)
|
||||
val values by remember { mutableStateOf(supportedLanguages.map { it.key to it.value }) }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
|
||||
values,
|
||||
state,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeSelector(state: State<DefaultTheme>, onSelected: (DefaultTheme) -> Unit) {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
@@ -193,6 +265,9 @@ private fun ThemeSelector(state: State<DefaultTheme>, onSelected: (DefaultTheme)
|
||||
)
|
||||
}
|
||||
|
||||
//private fun openSystemLangPicker(activity: Activity) {
|
||||
// activity.startActivity(Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.parse("package:" + SimplexApp.context.packageName)))
|
||||
//}
|
||||
|
||||
private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon ->
|
||||
SimplexApp.context.packageManager.getComponentEnabledSetting(
|
||||
@@ -206,6 +281,7 @@ fun PreviewAppearanceSettings() {
|
||||
SimpleXTheme {
|
||||
AppearanceLayout(
|
||||
icon = remember { mutableStateOf(AppIcon.DARK_BLUE) },
|
||||
languagePref = SharedPreference({ null }, {}),
|
||||
changeIcon = {},
|
||||
editPrimaryColor = {},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -43,16 +44,23 @@ fun CallSettingsLayout(
|
||||
AppBarTitle(stringResource(R.string.your_calls))
|
||||
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
|
||||
SectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
SectionItemView() {
|
||||
SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay)
|
||||
}
|
||||
SectionItemView(editIceServers) { Text(stringResource(R.string.webrtc_ice_servers)) }
|
||||
SectionDivider()
|
||||
|
||||
val enabled = remember { mutableStateOf(true) }
|
||||
SectionItemView { LockscreenOpts(lockCallState, enabled, onSelected = { callOnLockScreen.set(it); lockCallState.value = it }) }
|
||||
SectionDivider()
|
||||
SectionItemView(editIceServers) { Text(stringResource(R.string.webrtc_ice_servers)) }
|
||||
SectionItemView() {
|
||||
SharedPreferenceToggle(stringResource(R.string.always_use_relay), webrtcPolicyRelay)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(
|
||||
if (remember { webrtcPolicyRelay.state }.value) {
|
||||
generalGetString(R.string.relay_server_protects_ip)
|
||||
} else {
|
||||
generalGetString(R.string.relay_server_if_necessary)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.TerminalView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun DeveloperView(
|
||||
m: ChatModel,
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
withAuth: (block: () -> Unit) -> Unit
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
AppBarTitle(stringResource(R.string.settings_developer_tools))
|
||||
val developerTools = m.controller.appPrefs.developerTools
|
||||
val devTools = remember { mutableStateOf(developerTools.get()) }
|
||||
SectionView() {
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
SectionDivider()
|
||||
ChatConsoleItem { withAuth(showCustomModal { it, close -> TerminalView(it, close) }) }
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.DriveFolderUpload, stringResource(R.string.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.Code, stringResource(R.string.show_developer_options), developerTools, devTools)
|
||||
}
|
||||
SectionTextFooter(
|
||||
generalGetString(if (devTools.value) R.string.show_dev_options else R.string.hide_dev_options) + " " +
|
||||
generalGetString(R.string.developer_options)
|
||||
)
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Videocam
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
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.ChatModel
|
||||
|
||||
@Composable
|
||||
fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boolean>) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_experimental_features),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||
)
|
||||
SectionView("") {
|
||||
SettingsPreferenceItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), chatModel.controller.appPrefs.experimentalCalls, enableCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chatlist.UserProfileRow
|
||||
import chat.simplex.app.views.database.PassphraseField
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun HiddenProfileView(
|
||||
m: ChatModel,
|
||||
user: User,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
HiddenProfileLayout(
|
||||
user,
|
||||
saveProfilePassword = { hidePassword ->
|
||||
withBGApi {
|
||||
try {
|
||||
val u = m.controller.apiHideUser(user.userId, hidePassword)
|
||||
m.updateUser(u)
|
||||
close()
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.error_saving_user_password),
|
||||
text = e.stackTraceToString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HiddenProfileLayout(
|
||||
user: User,
|
||||
saveProfilePassword: (String) -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_BOTTOM_PADDING),
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.hide_profile))
|
||||
SectionView(padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
|
||||
UserProfileRow(user)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
val hidePassword = rememberSaveable { mutableStateOf("") }
|
||||
val confirmHidePassword = rememberSaveable { mutableStateOf("") }
|
||||
val passwordValid by remember { derivedStateOf { hidePassword.value == hidePassword.value.trim() } }
|
||||
val confirmValid by remember { derivedStateOf { confirmHidePassword.value == "" || hidePassword.value == confirmHidePassword.value } }
|
||||
val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || !passwordValid || confirmHidePassword.value == "" || !confirmValid } }
|
||||
SectionView(stringResource(R.string.hidden_profile_password).uppercase()) {
|
||||
SectionItemView {
|
||||
PassphraseField(hidePassword, generalGetString(R.string.password_to_show), isValid = { passwordValid }, showStrength = true)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
PassphraseField(confirmHidePassword, stringResource(R.string.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemViewSpaceBetween({ saveProfilePassword(hidePassword.value) }, disabled = saveDisabled, minHeight = TextFieldDefaults.MinHeight) {
|
||||
Text(generalGetString(R.string.save_profile_password), color = if (saveDisabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(R.string.to_reveal_profile_enter_password))
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,26 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionCustomFooter
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemWithValue
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import SectionViewSelectable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
@@ -25,6 +33,7 @@ fun NetworkAndServersView(
|
||||
chatModel: ChatModel,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
) {
|
||||
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
|
||||
val netCfg = remember { chatModel.controller.getNetCfg() }
|
||||
@@ -42,8 +51,10 @@ fun NetworkAndServersView(
|
||||
networkUseSocksProxy = networkUseSocksProxy,
|
||||
onionHosts = onionHosts,
|
||||
sessionMode = sessionMode,
|
||||
proxyPort = remember { derivedStateOf { chatModel.controller.appPrefs.networkProxyHostPort.state.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } },
|
||||
showModal = showModal,
|
||||
showSettingsModal = showSettingsModal,
|
||||
showCustomModal = showCustomModal,
|
||||
toggleSocksProxy = { enable ->
|
||||
if (enable) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -84,7 +95,7 @@ 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(
|
||||
showUpdateNetworkSettingsDialog(
|
||||
title = generalGetString(R.string.update_onion_hosts_settings_question),
|
||||
startsWith,
|
||||
onDismiss = {
|
||||
@@ -111,7 +122,7 @@ fun NetworkAndServersView(
|
||||
TransportSessionMode.User -> generalGetString(R.string.network_session_mode_user_description)
|
||||
TransportSessionMode.Entity -> generalGetString(R.string.network_session_mode_entity_description)
|
||||
}
|
||||
updateNetworkSettingsDialog(
|
||||
showUpdateNetworkSettingsDialog(
|
||||
title = generalGetString(R.string.update_network_session_mode_question),
|
||||
startsWith,
|
||||
onDismiss = { sessionMode.value = prevValue }
|
||||
@@ -136,8 +147,10 @@ fun NetworkAndServersView(
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
sessionMode: MutableState<TransportSessionMode>,
|
||||
proxyPort: State<Int>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
toggleSocksProxy: (Boolean) -> Unit,
|
||||
useOnion: (OnionHosts) -> Unit,
|
||||
updateSessionMode: (TransportSessionMode) -> Unit,
|
||||
@@ -149,10 +162,14 @@ 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), showCustomModal { m, close -> ProtocolServersView(m, ServerProtocol.SMP, close) })
|
||||
SectionDivider()
|
||||
|
||||
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, ServerProtocol.XFTP, close) })
|
||||
SectionDivider()
|
||||
|
||||
SectionItemView {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal)
|
||||
}
|
||||
SectionDivider()
|
||||
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
|
||||
@@ -163,7 +180,10 @@ fun NetworkAndServersView(
|
||||
}
|
||||
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (networkUseSocksProxy.value) {
|
||||
SectionCustomFooter { Text(annotatedStringResource(R.string.disable_onion_hosts_when_not_supported)) }
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionView(generalGetString(R.string.settings_section_title_calls)) {
|
||||
SettingsActionItem(Icons.Outlined.ElectricalServices, stringResource(R.string.webrtc_ice_servers), showModal { RTCServersView(it) })
|
||||
}
|
||||
@@ -173,7 +193,9 @@ fun NetworkAndServersView(
|
||||
@Composable
|
||||
fun UseSocksProxySwitch(
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
toggleSocksProxy: (Boolean) -> Unit
|
||||
proxyPort: State<Int>,
|
||||
toggleSocksProxy: (Boolean) -> Unit,
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -190,7 +212,19 @@ fun UseSocksProxySwitch(
|
||||
stringResource(R.string.network_socks_toggle),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Text(stringResource(R.string.network_socks_toggle))
|
||||
if (networkUseSocksProxy.value) {
|
||||
Row {
|
||||
Text(generalGetString(R.string.network_socks_toggle_use_socks_proxy) + " (")
|
||||
Text(
|
||||
generalGetString(R.string.network_proxy_port).format(proxyPort.value),
|
||||
Modifier.clickable { showSettingsModal { SockProxySettings(it) }() },
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
Text(")")
|
||||
}
|
||||
} else {
|
||||
Text(stringResource(R.string.network_socks_toggle))
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = networkUseSocksProxy.value,
|
||||
@@ -203,6 +237,83 @@ fun UseSocksProxySwitch(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SockProxySettings(m: ChatModel) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_BOTTOM_PADDING),
|
||||
) {
|
||||
val defaultHostPort = remember { "localhost:9050" }
|
||||
AppBarTitle(generalGetString(R.string.network_socks_proxy_settings))
|
||||
val hostPort by remember { m.controller.appPrefs.networkProxyHostPort.state }
|
||||
val hostUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(hostPort?.split(":")?.firstOrNull() ?: "localhost"))
|
||||
}
|
||||
val portUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(hostPort?.split(":")?.lastOrNull() ?: "9050"))
|
||||
}
|
||||
val save = {
|
||||
withBGApi {
|
||||
m.controller.appPrefs.networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text)
|
||||
m.controller.apiSetNetworkConfig(m.controller.getNetCfg())
|
||||
}
|
||||
}
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
ResetToDefaultsButton({
|
||||
showUpdateNetworkSettingsDialog {
|
||||
m.controller.appPrefs.networkProxyHostPort.set(defaultHostPort)
|
||||
val newHost = defaultHostPort.split(":").first()
|
||||
val newPort = defaultHostPort.split(":").last()
|
||||
hostUnsaved.value = hostUnsaved.value.copy(newHost, TextRange(newHost.length))
|
||||
portUnsaved.value = portUnsaved.value.copy(newPort, TextRange(newPort.length))
|
||||
save()
|
||||
}
|
||||
}, disabled = hostPort == defaultHostPort)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
DefaultConfigurableTextField(
|
||||
hostUnsaved,
|
||||
stringResource(R.string.host_verb),
|
||||
modifier = Modifier,
|
||||
isValid = ::validHost,
|
||||
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
|
||||
keyboardType = KeyboardType.Text,
|
||||
)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
DefaultConfigurableTextField(
|
||||
portUnsaved,
|
||||
stringResource(R.string.port_verb),
|
||||
modifier = Modifier,
|
||||
isValid = ::validPort,
|
||||
keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done); save() }),
|
||||
keyboardType = KeyboardType.Number,
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionCustomFooter {
|
||||
NetworkSectionFooter(
|
||||
revert = {
|
||||
val prevHost = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.firstOrNull() ?: "localhost"
|
||||
val prevPort = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.lastOrNull() ?: "9050"
|
||||
hostUnsaved.value = hostUnsaved.value.copy(prevHost, TextRange(prevHost.length))
|
||||
portUnsaved.value = portUnsaved.value.copy(prevPort, TextRange(prevPort.length))
|
||||
},
|
||||
save = { showUpdateNetworkSettingsDialog { save() } },
|
||||
revertDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text),
|
||||
saveDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text) ||
|
||||
remember { derivedStateOf { !validHost(hostUnsaved.value.text) } }.value ||
|
||||
remember { derivedStateOf { !validPort(portUnsaved.value.text) } }.value
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UseOnionHosts(
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
@@ -271,7 +382,32 @@ private fun SessionModePicker(
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateNetworkSettingsDialog(
|
||||
@Composable
|
||||
private fun NetworkSectionFooter(revert: () -> Unit, save: () -> Unit, revertDisabled: Boolean, saveDisabled: Boolean) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FooterButton(Icons.Outlined.Replay, stringResource(R.string.network_options_revert), revert, revertDisabled)
|
||||
FooterButton(Icons.Outlined.Check, stringResource(R.string.network_options_save), save, saveDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/106223
|
||||
private fun validHost(s: String): Boolean {
|
||||
val validIp = Regex("^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])[.]){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$")
|
||||
val validHostname = Regex("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])[.])*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$");
|
||||
return s.matches(validIp) || s.matches(validHostname)
|
||||
}
|
||||
|
||||
// https://ihateregex.io/expr/port/
|
||||
private fun validPort(s: String): Boolean {
|
||||
val validPort = Regex("^(6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4})$")
|
||||
return s.isNotBlank() && s.matches(validPort)
|
||||
}
|
||||
|
||||
private fun showUpdateNetworkSettingsDialog(
|
||||
title: String,
|
||||
startsWith: String = "",
|
||||
message: String = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
|
||||
@@ -295,8 +431,10 @@ fun PreviewNetworkAndServersLayout() {
|
||||
NetworkAndServersLayout(
|
||||
developerTools = true,
|
||||
networkUseSocksProxy = remember { mutableStateOf(true) },
|
||||
proxyPort = remember { mutableStateOf(9050) },
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showCustomModal = { {} },
|
||||
toggleSocksProxy = {},
|
||||
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
|
||||
sessionMode = remember { mutableStateOf(TransportSessionMode.User) },
|
||||
|
||||
@@ -80,6 +80,11 @@ private fun PreferencesLayout(
|
||||
applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowCalls = remember(preferences) { mutableStateOf(preferences.calls.allow) }
|
||||
FeatureSection(ChatFeature.Calls, allowCalls) {
|
||||
applyPrefs(preferences.copy(calls = SimpleChatPreference(allow = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
ResetSaveButtons(
|
||||
reset = reset,
|
||||
save = savePrefs,
|
||||
@@ -97,11 +102,12 @@ private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllo
|
||||
FeatureAllowed.values().map { it to it.text },
|
||||
allowFeature,
|
||||
icon = feature.icon,
|
||||
onSelected = onSelected
|
||||
enabled = remember { mutableStateOf(feature != ChatFeature.Calls) },
|
||||
onSelected = onSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(feature.allowDescription(allowFeature.value))
|
||||
SectionTextFooter(feature.allowDescription(allowFeature.value) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else ""))
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -7,23 +7,41 @@ import SectionTextFooter
|
||||
import SectionView
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
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.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimplexGreen
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.localauth.SetAppPasscodeView
|
||||
|
||||
enum class LAMode {
|
||||
SYSTEM,
|
||||
PASSCODE;
|
||||
|
||||
val text: String
|
||||
get() = when (this) {
|
||||
SYSTEM -> generalGetString(R.string.la_mode_system)
|
||||
PASSCODE -> generalGetString(R.string.la_mode_passcode)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrivacySettingsView(
|
||||
chatModel: ChatModel,
|
||||
setPerformLA: (Boolean) -> Unit
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
setPerformLA: (Boolean, FragmentActivity) -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -32,7 +50,7 @@ fun PrivacySettingsView(
|
||||
val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode
|
||||
AppBarTitle(stringResource(R.string.your_privacy))
|
||||
SectionView(stringResource(R.string.settings_section_title_device)) {
|
||||
ChatLockItem(chatModel.performLA, setPerformLA)
|
||||
ChatLockItem(chatModel, showSettingsModal, setPerformLA)
|
||||
SectionDivider()
|
||||
val context = LocalContext.current
|
||||
SettingsPreferenceItem(Icons.Outlined.VisibilityOff, stringResource(R.string.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen) { on ->
|
||||
@@ -53,10 +71,12 @@ fun PrivacySettingsView(
|
||||
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
|
||||
}) }
|
||||
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))
|
||||
@@ -84,3 +104,236 @@ private fun SimpleXLinkOptions(simplexLinkModeState: State<SimplexLinkMode>, onS
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
private val laDelays = listOf(10, 30, 60, 180, 0)
|
||||
|
||||
@Composable
|
||||
fun SimplexLockView(
|
||||
chatModel: ChatModel,
|
||||
currentLAMode: SharedPreference<LAMode>,
|
||||
setPerformLA: (Boolean, FragmentActivity) -> Unit
|
||||
) {
|
||||
val performLA = remember { chatModel.performLA }
|
||||
val laMode = remember { chatModel.controller.appPrefs.laMode.state }
|
||||
val laLockDelay = remember { chatModel.controller.appPrefs.laLockDelay }
|
||||
val showChangePasscode = remember { derivedStateOf { performLA.value && currentLAMode.state.value == LAMode.PASSCODE } }
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
|
||||
fun resetLAEnabled(onOff: Boolean) {
|
||||
chatModel.controller.appPrefs.performLA.set(onOff)
|
||||
chatModel.performLA.value = onOff
|
||||
}
|
||||
|
||||
fun disableUnavailableLA() {
|
||||
resetLAEnabled(false)
|
||||
currentLAMode.set(LAMode.SYSTEM)
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
|
||||
fun toggleLAMode(toLAMode: LAMode) {
|
||||
authenticate(
|
||||
if (toLAMode == LAMode.SYSTEM) {
|
||||
generalGetString(R.string.la_enter_app_passcode)
|
||||
} else {
|
||||
generalGetString(R.string.chat_lock)
|
||||
},
|
||||
generalGetString(R.string.change_lock_mode), activity
|
||||
) { laResult ->
|
||||
when (laResult) {
|
||||
is LAResult.Error -> {
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
LAResult.Success -> {
|
||||
when (toLAMode) {
|
||||
LAMode.SYSTEM -> {
|
||||
authenticate(generalGetString(R.string.auth_enable_simplex_lock), promptSubtitle = "", activity, toLAMode) { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
currentLAMode.set(toLAMode)
|
||||
ksAppPassword.remove()
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Unavailable, is LAResult.Error -> {
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
LAMode.PASSCODE -> {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
SetAppPasscodeView(
|
||||
submit = {
|
||||
laLockDelay.set(30)
|
||||
currentLAMode.set(toLAMode)
|
||||
passcodeAlert(generalGetString(R.string.passcode_set))
|
||||
},
|
||||
cancel = {},
|
||||
close
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is LAResult.Unavailable -> disableUnavailableLA()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeLAPassword() {
|
||||
authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.la_change_app_passcode), activity) { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
SetAppPasscodeView(
|
||||
submit = {
|
||||
passcodeAlert(generalGetString(R.string.passcode_changed))
|
||||
}, cancel = {
|
||||
passcodeAlert(generalGetString(R.string.passcode_not_changed))
|
||||
}, close
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is LAResult.Error -> laFailedAlert()
|
||||
is LAResult.Failed -> {}
|
||||
is LAResult.Unavailable -> disableUnavailableLA()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.chat_lock))
|
||||
SectionView {
|
||||
EnableLock(performLA) { performLAToggle ->
|
||||
performLA.value = performLAToggle
|
||||
chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (performLAToggle) {
|
||||
when (currentLAMode.state.value) {
|
||||
LAMode.SYSTEM -> {
|
||||
setPerformLA(true, activity)
|
||||
}
|
||||
LAMode.PASSCODE -> {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
SetAppPasscodeView(
|
||||
submit = {
|
||||
laLockDelay.set(30)
|
||||
chatModel.controller.appPrefs.performLA.set(true)
|
||||
passcodeAlert(generalGetString(R.string.passcode_set))
|
||||
},
|
||||
cancel = {
|
||||
resetLAEnabled(false)
|
||||
}, close
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setPerformLA(false, activity)
|
||||
}
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
LockModeSelector(laMode) { newLAMode ->
|
||||
if (laMode.value == newLAMode) return@LockModeSelector
|
||||
if (chatModel.controller.appPrefs.performLA.get()) {
|
||||
toggleLAMode(newLAMode)
|
||||
} else {
|
||||
currentLAMode.set(newLAMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (performLA.value) {
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
LockDelaySelector(remember { laLockDelay.state }) { laLockDelay.set(it) }
|
||||
}
|
||||
if (showChangePasscode.value && laMode.value == LAMode.PASSCODE) {
|
||||
SectionDivider()
|
||||
SectionItemView({ changeLAPassword() }) {
|
||||
Text(generalGetString(R.string.la_change_app_passcode))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnableLock(performLA: MutableState<Boolean>, onCheckedChange: (Boolean) -> Unit) {
|
||||
SectionItemView {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(R.string.enable_lock), Modifier
|
||||
.padding(end = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
)
|
||||
Switch(
|
||||
checked = performLA.value,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockModeSelector(state: State<LAMode>, onSelected: (LAMode) -> Unit) {
|
||||
val values by remember { mutableStateOf(LAMode.values().map { it to it.text }) }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.lock_mode),
|
||||
values,
|
||||
state,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockDelaySelector(state: State<Int>, onSelected: (Int) -> Unit) {
|
||||
val delays = remember { if (laDelays.contains(state.value)) laDelays else listOf(state.value) + laDelays }
|
||||
val values by remember { mutableStateOf(delays.map { it to laDelayText(it) }) }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.lock_after),
|
||||
values,
|
||||
state,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
private fun laDelayText(t: Int): String {
|
||||
val m = t / 60
|
||||
val s = t % 60
|
||||
return if (t == 0) {
|
||||
generalGetString(R.string.la_immediately)
|
||||
} else if (m == 0 || s != 0) {
|
||||
// there are no options where both minutes and seconds are needed
|
||||
generalGetString(R.string.la_seconds).format(s)
|
||||
} else {
|
||||
generalGetString(R.string.la_minutes).format(m)
|
||||
}
|
||||
}
|
||||
|
||||
private fun passcodeAlert(title: String) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = title,
|
||||
text = generalGetString(R.string.la_please_remember_to_store_password)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,12 +32,13 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SMPServerView(m: ChatModel, server: ServerCfg, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
|
||||
fun ProtocolServerView(m: ChatModel, server: ServerCfg, serverProtocol: ServerProtocol, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
SMPServerLayout(
|
||||
ProtocolServerLayout(
|
||||
testing,
|
||||
server,
|
||||
serverProtocol,
|
||||
testServer = {
|
||||
testing = true
|
||||
scope.launch {
|
||||
@@ -68,9 +69,10 @@ fun SMPServerView(m: ChatModel, server: ServerCfg, onUpdate: (ServerCfg) -> Unit
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SMPServerLayout(
|
||||
private fun ProtocolServerLayout(
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
serverProtocol: ServerProtocol,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
@@ -86,7 +88,7 @@ private fun SMPServerLayout(
|
||||
if (server.preset) {
|
||||
PresetServer(testing, server, testServer, onUpdate, onDelete)
|
||||
} else {
|
||||
CustomServer(testing, server, testServer, onUpdate, onDelete)
|
||||
CustomServer(testing, server, serverProtocol, testServer, onUpdate, onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,12 +121,19 @@ private fun PresetServer(
|
||||
private fun CustomServer(
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
serverProtocol: ServerProtocol,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val serverAddress = remember { mutableStateOf(server.server) }
|
||||
val valid = remember { derivedStateOf { parseServerAddress(serverAddress.value)?.valid == true } }
|
||||
val valid = remember {
|
||||
derivedStateOf {
|
||||
with(parseServerAddress(serverAddress.value)) {
|
||||
this?.valid == true && this.serverProtocol == serverProtocol
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionView(
|
||||
stringResource(R.string.smp_servers_your_server_address).uppercase(),
|
||||
icon = Icons.Outlined.ErrorOutline,
|
||||
@@ -187,9 +196,9 @@ fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) =
|
||||
else -> Icon(Icons.Outlined.Check, null, modifier, tint = Color.Transparent)
|
||||
}
|
||||
|
||||
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, SMPTestFailure?> =
|
||||
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, ProtocolTestFailure?> =
|
||||
try {
|
||||
val r = m.controller.testSMPServer(server.server)
|
||||
val r = m.controller.testProtoServer(server.server)
|
||||
server.copy(tested = r == null) to r
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "testServerConnection ${e.stackTraceToString()}")
|
||||
@@ -18,6 +18,7 @@ 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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
@@ -27,17 +28,19 @@ import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SMPServersView(m: ChatModel) {
|
||||
fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () -> Unit) {
|
||||
var presetServers by remember { mutableStateOf(emptyList<String>()) }
|
||||
var servers by remember {
|
||||
mutableStateOf(m.userSMPServersUnsaved.value ?: m.userSMPServers.value ?: emptyList())
|
||||
mutableStateOf(m.userSMPServersUnsaved.value ?: emptyList())
|
||||
}
|
||||
val currServers = remember { mutableStateOf(servers) }
|
||||
val testing = rememberSaveable { mutableStateOf(false) }
|
||||
val serversUnchanged = remember { derivedStateOf { servers == m.userSMPServers.value || testing.value } }
|
||||
val serversUnchanged = remember { derivedStateOf { servers == currServers.value || testing.value } }
|
||||
val allServersDisabled = remember { derivedStateOf { servers.all { !it.enabled } } }
|
||||
val saveDisabled = remember {
|
||||
derivedStateOf {
|
||||
servers.isEmpty() ||
|
||||
servers == m.userSMPServers.value ||
|
||||
servers == currServers.value ||
|
||||
testing.value ||
|
||||
!servers.all { srv ->
|
||||
val address = parseServerAddress(srv.server)
|
||||
@@ -47,13 +50,25 @@ fun SMPServersView(m: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val res = m.controller.getUserProtoServers(serverProtocol)
|
||||
if (res != null) {
|
||||
currServers.value = res.protoServers
|
||||
presetServers = res.presetServers
|
||||
if (servers.isEmpty()) {
|
||||
servers = currServers.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showServer(server: ServerCfg) {
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
var old by remember { mutableStateOf(server) }
|
||||
val index = servers.indexOf(old)
|
||||
SMPServerView(
|
||||
ProtocolServerView(
|
||||
m,
|
||||
old,
|
||||
serverProtocol,
|
||||
onUpdate = { updated ->
|
||||
val newServers = ArrayList(servers)
|
||||
newServers.removeAt(index)
|
||||
@@ -72,89 +87,98 @@ fun SMPServersView(m: ChatModel) {
|
||||
}
|
||||
}
|
||||
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
|
||||
ModalView(
|
||||
close = {
|
||||
if (saveDisabled.value) close()
|
||||
else showUnsavedChangesAlert({ saveServers(serverProtocol, currServers, servers, m, close) }, close)
|
||||
},
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
ProtocolServersLayout(
|
||||
serverProtocol,
|
||||
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), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
ScanProtocolServer {
|
||||
close()
|
||||
servers = servers + it
|
||||
m.userSMPServersUnsaved.value = servers
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
val hasAllPresets = hasAllPresets(presetServers, servers, m)
|
||||
if (!hasAllPresets) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
servers = (servers + addAllPresets(presetServers, servers, m)).sortedByDescending { it.preset }
|
||||
}) {
|
||||
Text(stringResource(R.string.smp_servers_preset_add), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
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,
|
||||
)
|
||||
},
|
||||
resetServers = {
|
||||
servers = currServers.value ?: emptyList()
|
||||
m.userSMPServersUnsaved.value = null
|
||||
},
|
||||
saveSMPServers = {
|
||||
saveServers(serverProtocol, currServers, 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
|
||||
)
|
||||
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(
|
||||
private fun ProtocolServersLayout(
|
||||
serverProtocol: ServerProtocol,
|
||||
testing: Boolean,
|
||||
servers: List<ServerCfg>,
|
||||
serversUnchanged: Boolean,
|
||||
@@ -173,12 +197,12 @@ private fun SMPServersLayout(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_PADDING),
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.your_SMP_servers))
|
||||
AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.SMP) R.string.your_SMP_servers else R.string.your_XFTP_servers))
|
||||
|
||||
SectionView(stringResource(R.string.smp_servers).uppercase()) {
|
||||
SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) R.string.smp_servers else R.string.xftp_servers).uppercase()) {
|
||||
for (srv in servers) {
|
||||
SectionItemView({ showServer(srv) }, disabled = testing) {
|
||||
SmpServerView(srv, servers, testing)
|
||||
ProtocolServerView(serverProtocol, srv, servers, testing)
|
||||
}
|
||||
SectionDivider()
|
||||
}
|
||||
@@ -225,10 +249,10 @@ private fun SMPServersLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmpServerView(srv: ServerCfg, servers: List<ServerCfg>, disabled: Boolean) {
|
||||
private fun ProtocolServerView(serverProtocol: ServerProtocol, srv: ServerCfg, servers: List<ServerCfg>, disabled: Boolean) {
|
||||
val address = parseServerAddress(srv.server)
|
||||
when {
|
||||
address == null || !address.valid || !uniqueAddress(srv, address, servers) -> InvalidServer()
|
||||
address == null || !address.valid || address.serverProtocol != serverProtocol || !uniqueAddress(srv, address, servers) -> InvalidServer()
|
||||
!srv.enabled -> Icon(Icons.Outlined.DoNotDisturb, null, tint = HighOrLowlight)
|
||||
else -> ShowTestStatus(srv)
|
||||
}
|
||||
@@ -247,7 +271,7 @@ private fun HowToButton() {
|
||||
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") },
|
||||
{ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SERVER.md") },
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary
|
||||
)
|
||||
@@ -264,12 +288,12 @@ private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List<Se
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasAllPresets(servers: List<ServerCfg>, m: ChatModel): Boolean =
|
||||
m.presetSMPServers.value?.all { hasPreset(it, servers) } ?: true
|
||||
private fun hasAllPresets(presetServers: List<String>, servers: List<ServerCfg>, m: ChatModel): Boolean =
|
||||
presetServers.all { hasPreset(it, servers) } ?: true
|
||||
|
||||
private fun addAllPresets(servers: List<ServerCfg>, m: ChatModel): List<ServerCfg> {
|
||||
private fun addAllPresets(presetServers: List<String>, servers: List<ServerCfg>, m: ChatModel): List<ServerCfg> {
|
||||
val toAdd = ArrayList<ServerCfg>()
|
||||
for (srv in m.presetSMPServers.value ?: emptyList()) {
|
||||
for (srv in presetServers) {
|
||||
if (!hasPreset(srv, servers)) {
|
||||
toAdd.add(ServerCfg(srv, preset = true, tested = null, enabled = true))
|
||||
}
|
||||
@@ -306,8 +330,8 @@ private fun resetTestStatus(servers: List<ServerCfg>): List<ServerCfg> {
|
||||
return copy
|
||||
}
|
||||
|
||||
private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit): Map<String, SMPTestFailure> {
|
||||
val fs: MutableMap<String, SMPTestFailure> = mutableMapOf()
|
||||
private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit): Map<String, ProtocolTestFailure> {
|
||||
val fs: MutableMap<String, ProtocolTestFailure> = mutableMapOf()
|
||||
val updatedServers = ArrayList<ServerCfg>(servers)
|
||||
for ((index, server) in servers.withIndex()) {
|
||||
if (server.enabled) {
|
||||
@@ -324,12 +348,22 @@ private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpd
|
||||
return fs
|
||||
}
|
||||
|
||||
private fun saveSMPServers(servers: List<ServerCfg>, m: ChatModel) {
|
||||
private fun saveServers(protocol: ServerProtocol, currServers: MutableState<List<ServerCfg>>, servers: List<ServerCfg>, m: ChatModel, afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
if (m.controller.setUserSMPServers(servers)) {
|
||||
m.userSMPServers.value = servers
|
||||
if (m.controller.setUserProtoServers(protocol, servers)) {
|
||||
currServers.value = servers
|
||||
m.userSMPServersUnsaved.value = null
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.smp_save_servers_question),
|
||||
confirmText = generalGetString(R.string.save_verb),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
@@ -198,7 +198,7 @@ private fun howToButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/WEBRTC.md#configure-mobile-apps") }
|
||||
modifier = Modifier.clickable { uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/WEBRTC.md#configure-mobile-apps") }
|
||||
) {
|
||||
Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary)
|
||||
Icon(
|
||||
|
||||
@@ -2,7 +2,6 @@ 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
|
||||
@@ -17,16 +16,16 @@ import chat.simplex.app.views.newchat.QRCodeScanner
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
fun ScanSMPServer(onNext: (ServerCfg) -> Unit) {
|
||||
fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
ScanSMPServerLayout(onNext)
|
||||
ScanProtocolServerLayout(onNext)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScanSMPServerLayout(onNext: (ServerCfg) -> Unit) {
|
||||
private fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -6,6 +6,7 @@ import SectionSpacer
|
||||
import SectionView
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@@ -13,6 +14,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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
|
||||
@@ -20,7 +22,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
@@ -38,7 +42,7 @@ import chat.simplex.app.views.onboarding.SimpleXInfo
|
||||
import chat.simplex.app.views.onboarding.WhatsNewView
|
||||
|
||||
@Composable
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean, FragmentActivity) -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
|
||||
@@ -53,11 +57,22 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
chatModel.chatDbEncrypted.value == true,
|
||||
chatModel.incognito,
|
||||
chatModel.controller.appPrefs.incognito,
|
||||
developerTools = chatModel.controller.appPrefs.developerTools,
|
||||
user.displayName,
|
||||
setPerformLA = setPerformLA,
|
||||
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
|
||||
showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { modalView(chatModel) } } },
|
||||
showSettingsModalWithSearch = { modalView ->
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
ModalView(
|
||||
{ close() },
|
||||
if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight,
|
||||
endButtons = {
|
||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), alwaysVisible = true) { search.value = it }
|
||||
},
|
||||
content = { modalView(chatModel, search) })
|
||||
}
|
||||
},
|
||||
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
|
||||
showVersion = {
|
||||
withApi {
|
||||
@@ -110,11 +125,11 @@ fun SettingsLayout(
|
||||
encrypted: Boolean,
|
||||
incognito: MutableState<Boolean>,
|
||||
incognitoPref: SharedPreference<Boolean>,
|
||||
developerTools: SharedPreference<Boolean>,
|
||||
userDisplayName: String,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
setPerformLA: (Boolean, FragmentActivity) -> Unit,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModalWithSearch: (@Composable (ChatModel, MutableState<String>) -> Unit) -> Unit,
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
showVersion: () -> Unit,
|
||||
withAuth: (block: () -> Unit) -> Unit
|
||||
@@ -141,7 +156,8 @@ fun SettingsLayout(
|
||||
ProfilePreview(profile, stopped = stopped)
|
||||
}
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.ManageAccounts, stringResource(R.string.your_chat_profiles), { withAuth { showSettingsModal { UserProfilesView(it) }() } }, disabled = stopped)
|
||||
val profileHidden = rememberSaveable { mutableStateOf(false) }
|
||||
SettingsActionItem(Icons.Outlined.ManageAccounts, stringResource(R.string.your_chat_profiles), { withAuth { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() }
|
||||
SectionDivider()
|
||||
@@ -154,13 +170,13 @@ fun SettingsLayout(
|
||||
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)
|
||||
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal, showCustomModal) }, 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)
|
||||
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView() }, disabled = stopped)
|
||||
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView(it) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
|
||||
}
|
||||
@@ -173,9 +189,9 @@ fun SettingsLayout(
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
|
||||
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUriCatching(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)
|
||||
SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
@@ -189,17 +205,8 @@ fun SettingsLayout(
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_develop)) {
|
||||
val devTools = remember { mutableStateOf(developerTools.get()) }
|
||||
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools, devTools)
|
||||
SettingsActionItem(Icons.Outlined.Code, stringResource(R.string.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) })
|
||||
SectionDivider()
|
||||
if (devTools.value) {
|
||||
ChatConsoleItem { withAuth(showCustomModal { it, close -> TerminalView(it, close) }) }
|
||||
SectionDivider()
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
SectionDivider()
|
||||
}
|
||||
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
|
||||
// SectionDivider()
|
||||
AppVersionItem(showVersion)
|
||||
}
|
||||
}
|
||||
@@ -285,13 +292,20 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
|
||||
SectionItemView() {
|
||||
@Composable
|
||||
fun ChatLockItem(
|
||||
chatModel: ChatModel,
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
setPerformLA: (Boolean, FragmentActivity) -> Unit
|
||||
) {
|
||||
val performLA = remember { chatModel.performLA }
|
||||
val currentLAMode = remember { chatModel.controller.appPrefs.laMode }
|
||||
SectionItemView(showSettingsModal { SimplexLockView(chatModel, currentLAMode, setPerformLA) }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Lock,
|
||||
if (performLA.value) Icons.Filled.Lock else Icons.Outlined.Lock,
|
||||
contentDescription = stringResource(R.string.chat_lock),
|
||||
tint = HighOrLowlight,
|
||||
tint = if (performLA.value) SimplexGreen else HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
@@ -300,20 +314,13 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
)
|
||||
Switch(
|
||||
checked = performLA.value,
|
||||
onCheckedChange = { setPerformLA(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
)
|
||||
)
|
||||
Text(if (performLA.value) remember { currentLAMode.state }.value.text else generalGetString(androidx.compose.ui.R.string.off), color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun ContributeItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat#contribute") }) {
|
||||
SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat#contribute") }) {
|
||||
Icon(
|
||||
Icons.Outlined.Keyboard,
|
||||
contentDescription = "GitHub",
|
||||
@@ -326,8 +333,8 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
|
||||
@Composable private fun RateAppItem(uriHandler: UriHandler) {
|
||||
SectionItemView({
|
||||
runCatching { uriHandler.openUri("market://details?id=chat.simplex.app") }
|
||||
.onFailure { uriHandler.openUri("https://play.google.com/store/apps/details?id=chat.simplex.app") }
|
||||
runCatching { uriHandler.openUriCatching("market://details?id=chat.simplex.app") }
|
||||
.onFailure { uriHandler.openUriCatching("https://play.google.com/store/apps/details?id=chat.simplex.app") }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
@@ -341,7 +348,7 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
@Composable private fun StarOnGithubItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
@@ -352,7 +359,7 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) {
|
||||
@Composable fun ChatConsoleItem(showTerminal: () -> Unit) {
|
||||
SectionItemView(showTerminal) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
||||
@@ -364,8 +371,8 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun InstallTerminalAppItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
@Composable fun InstallTerminalAppItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
@@ -377,9 +384,11 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
@Composable private fun AppVersionItem(showVersion: () -> Unit) {
|
||||
SectionItemView(showVersion) {
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
}
|
||||
SectionItemView(showVersion) { AppVersionText() }
|
||||
}
|
||||
|
||||
@Composable fun AppVersionText() {
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
}
|
||||
|
||||
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary, stopped: Boolean = false) {
|
||||
@@ -506,7 +515,7 @@ private fun runAuth(context: Context, onFinish: (success: Boolean) -> Unit) {
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
onFinish(laResult == LAResult.Success || laResult == LAResult.Unavailable)
|
||||
onFinish(laResult == LAResult.Success || laResult is LAResult.Unavailable)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -526,11 +535,11 @@ fun PreviewSettingsLayout() {
|
||||
encrypted = false,
|
||||
incognito = remember { mutableStateOf(false) },
|
||||
incognitoPref = SharedPreference({ false }, {}),
|
||||
developerTools = SharedPreference({ false }, {}),
|
||||
userDisplayName = "Alice",
|
||||
setPerformLA = {},
|
||||
setPerformLA = { _, _ -> },
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showSettingsModalWithSearch = { },
|
||||
showCustomModal = { {} },
|
||||
showVersion = {},
|
||||
withAuth = {},
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
@@ -16,18 +12,20 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.ProfileNameField
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -36,10 +34,8 @@ import kotlinx.coroutines.launch
|
||||
fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
val editProfile = rememberSaveable { mutableStateOf(false) }
|
||||
var profile by remember { mutableStateOf(user.profile.toProfile()) }
|
||||
UserProfileLayout(
|
||||
editProfile = editProfile,
|
||||
profile = profile,
|
||||
close,
|
||||
saveProfile = { displayName, fullName, image ->
|
||||
@@ -49,7 +45,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
|
||||
chatModel.updateCurrentUser(newProfile)
|
||||
profile = newProfile
|
||||
}
|
||||
editProfile.value = false
|
||||
close()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -58,7 +54,6 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
fun UserProfileLayout(
|
||||
editProfile: MutableState<Boolean>,
|
||||
profile: Profile,
|
||||
close: () -> Unit,
|
||||
saveProfile: (String, String, String?) -> Unit,
|
||||
@@ -72,6 +67,7 @@ fun UserProfileLayout(
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardState by getKeyboardState()
|
||||
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
@@ -87,97 +83,89 @@ fun UserProfileLayout(
|
||||
sheetState = bottomSheetModalState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
ModalView(close = close) {
|
||||
val dataUnchanged =
|
||||
displayName.value == profile.displayName &&
|
||||
fullName.value == profile.fullName &&
|
||||
chosenImage.value == null
|
||||
|
||||
val closeWithAlert = {
|
||||
if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) {
|
||||
close()
|
||||
} else {
|
||||
showUnsavedChangesAlert({ saveProfile(displayName.value, fullName.value, profileImage.value) }, close)
|
||||
}
|
||||
}
|
||||
ModalView(close = closeWithAlert) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.your_current_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),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
if (editProfile.value) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
AppBarTitleCentered(stringResource(R.string.your_current_profile))
|
||||
ReadableText(generalGetString(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it), TextAlign.Center)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(192.dp, profileImage.value)
|
||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||
}
|
||||
if (profileImage.value != null) {
|
||||
DeleteImageButton { profileImage.value = null }
|
||||
}
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(108.dp, profileImage.value, color = HighOrLowlight.copy(alpha = 0.1f))
|
||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||
}
|
||||
if (profileImage.value != null) {
|
||||
DeleteImageButton { profileImage.value = null }
|
||||
}
|
||||
}
|
||||
Box {
|
||||
if (!isValidDisplayName(displayName.value)) {
|
||||
Icon(Icons.Outlined.Info, tint = Color.Red, contentDescription = stringResource(R.string.display_name_cannot_contain_whitespace))
|
||||
}
|
||||
ProfileNameTextField(displayName)
|
||||
}
|
||||
ProfileNameTextField(fullName)
|
||||
Row {
|
||||
TextButton(stringResource(R.string.cancel_verb)) {
|
||||
displayName.value = profile.displayName
|
||||
fullName.value = profile.fullName
|
||||
profileImage.value = profile.image
|
||||
editProfile.value = false
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
val saveModifier: Modifier
|
||||
val saveColor: Color
|
||||
if (enabled) {
|
||||
saveModifier = Modifier
|
||||
.clickable { saveProfile(displayName.value, fullName.value, profileImage.value) }
|
||||
saveColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
saveModifier = Modifier
|
||||
saveColor = HighOrLowlight
|
||||
}
|
||||
}
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(R.string.display_name__field),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
if (!isValidDisplayName(displayName.value)) {
|
||||
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||
Text(
|
||||
stringResource(R.string.save_and_notify_contacts),
|
||||
modifier = saveModifier,
|
||||
color = saveColor
|
||||
stringResource(R.string.no_spaces),
|
||||
fontSize = 16.sp,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp), contentAlignment = Alignment.Center
|
||||
) {
|
||||
ProfileImage(192.dp, profile.image)
|
||||
if (profile.image == null) {
|
||||
EditImageButton {
|
||||
editProfile.value = true
|
||||
scope.launch { bottomSheetModalState.show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
ProfileNameRow(stringResource(R.string.display_name__field), profile.displayName)
|
||||
ProfileNameRow(stringResource(R.string.full_name__field), profile.fullName)
|
||||
TextButton(stringResource(R.string.edit_verb)) { editProfile.value = true }
|
||||
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Text(
|
||||
stringResource(R.string.full_name__field),
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
val saveModifier: Modifier
|
||||
val saveColor: Color
|
||||
if (enabled) {
|
||||
saveModifier = Modifier
|
||||
.clickable { saveProfile(displayName.value, fullName.value, profileImage.value) }
|
||||
saveColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
saveModifier = Modifier
|
||||
saveColor = HighOrLowlight
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.save_and_notify_contacts),
|
||||
modifier = saveModifier,
|
||||
color = saveColor
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_BOTTOM_BUTTON_PADDING))
|
||||
if (savedKeyboardState != keyboardState) {
|
||||
LaunchedEffect(keyboardState) {
|
||||
scope.launch {
|
||||
@@ -192,60 +180,17 @@ fun UserProfileLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileNameTextField(name: MutableState<String>) {
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
modifier = Modifier
|
||||
.padding(bottom = 24.dp)
|
||||
.padding(start = 28.dp)
|
||||
.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileNameRow(label: String, text: String) {
|
||||
Row(Modifier.padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
label,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextButton(text: String, click: () -> Unit) {
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable(onClick = click),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditImageButton(click: () -> Unit) {
|
||||
IconButton(
|
||||
onClick = click,
|
||||
modifier = Modifier.background(Color(1f, 1f, 1f, 0.2f), shape = CircleShape)
|
||||
modifier = Modifier.size(30.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.PhotoCamera,
|
||||
contentDescription = stringResource(R.string.edit_image),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.size(36.dp)
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -261,6 +206,16 @@ fun DeleteImageButton(click: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
@@ -273,7 +228,6 @@ fun PreviewUserProfileLayoutEditOff() {
|
||||
UserProfileLayout(
|
||||
profile = Profile.sampleData,
|
||||
close = {},
|
||||
editProfile = remember { mutableStateOf(false) },
|
||||
saveProfile = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
@@ -291,7 +245,6 @@ fun PreviewUserProfileLayoutEditOn() {
|
||||
UserProfileLayout(
|
||||
profile = Profile.sampleData,
|
||||
close = {},
|
||||
editProfile = remember { mutableStateOf(true) },
|
||||
saveProfile = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,33 +2,50 @@ package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.annotation.StringRes
|
||||
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.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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.chatPasswordHash
|
||||
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.chatlist.UserProfileRow
|
||||
import chat.simplex.app.views.database.PassphraseField
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.CreateProfile
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun UserProfilesView(m: ChatModel) {
|
||||
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>) {
|
||||
val searchTextOrPassword = rememberSaveable { search }
|
||||
val users by remember { derivedStateOf { m.users.map { it.user } } }
|
||||
val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } }
|
||||
UserProfilesView(
|
||||
users = users,
|
||||
filteredUsers = filteredUsers,
|
||||
profileHidden = profileHidden,
|
||||
searchTextOrPassword = searchTextOrPassword,
|
||||
showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice,
|
||||
visibleUsersCount = visibleUsersCount(m),
|
||||
prefPerformLA = m.controller.appPrefs.performLA.get(),
|
||||
addUser = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
CreateProfile(m, close)
|
||||
@@ -36,38 +53,85 @@ fun UserProfilesView(m: ChatModel) {
|
||||
},
|
||||
activateUser = { user ->
|
||||
withBGApi {
|
||||
m.controller.changeActiveUser(user.userId)
|
||||
m.controller.changeActiveUser(user.userId, userViewPassword(user, searchTextOrPassword.value.trim()))
|
||||
}
|
||||
},
|
||||
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)
|
||||
if (m.users.size > 1 && (user.hidden || visibleUsersCount(m) > 1)) {
|
||||
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(":")
|
||||
}
|
||||
append(":")
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(R.string.users_delete_question),
|
||||
text = text,
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, true, searchTextOrPassword.value.trim())
|
||||
}) {
|
||||
Text(stringResource(R.string.users_delete_with_connections), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, false, searchTextOrPassword.value.trim())
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.users_delete_data_only), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.cant_delete_user_profile),
|
||||
text = if (m.users.size > 1) {
|
||||
generalGetString(R.string.should_be_at_least_one_visible_profile)
|
||||
} else {
|
||||
generalGetString(R.string.should_be_at_least_one_profile)
|
||||
}
|
||||
)
|
||||
}
|
||||
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)
|
||||
},
|
||||
unhideUser = { user ->
|
||||
if (passwordEntryRequired(user, searchTextOrPassword.value)) {
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ProfileActionView(UserProfileAction.UNHIDE, user) { pwd ->
|
||||
withBGApi {
|
||||
setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, pwd) }
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, searchTextOrPassword.value.trim()) } }
|
||||
}
|
||||
},
|
||||
muteUser = { user ->
|
||||
withBGApi {
|
||||
setUserPrivacy(m, onSuccess = {
|
||||
if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert)
|
||||
}) { m.controller.apiMuteUser(user.userId) }
|
||||
}
|
||||
},
|
||||
unmuteUser = { user ->
|
||||
withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId) } }
|
||||
},
|
||||
showHiddenProfile = { user ->
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
HiddenProfileView(m, user) {
|
||||
profileHidden.value = true
|
||||
withBGApi {
|
||||
delay(10_000)
|
||||
profileHidden.value = false
|
||||
}
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -75,9 +139,19 @@ fun UserProfilesView(m: ChatModel) {
|
||||
@Composable
|
||||
private fun UserProfilesView(
|
||||
users: List<User>,
|
||||
filteredUsers: List<User>,
|
||||
searchTextOrPassword: MutableState<String>,
|
||||
profileHidden: MutableState<Boolean>,
|
||||
visibleUsersCount: Int,
|
||||
prefPerformLA: Boolean,
|
||||
showHiddenProfilesNotice: SharedPreference<Boolean>,
|
||||
addUser: () -> Unit,
|
||||
activateUser: (User) -> Unit,
|
||||
removeUser: (User) -> Unit,
|
||||
unhideUser: (User) -> Unit,
|
||||
muteUser: (User) -> Unit,
|
||||
unmuteUser: (User) -> Unit,
|
||||
showHiddenProfile: (User) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -85,57 +159,232 @@ private fun UserProfilesView(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_PADDING),
|
||||
) {
|
||||
if (profileHidden.value) {
|
||||
SectionView {
|
||||
SettingsActionItem(Icons.Outlined.LockOpen, stringResource(R.string.enter_password_to_show), click = {
|
||||
profileHidden.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
AppBarTitle(stringResource(R.string.your_chat_profiles))
|
||||
|
||||
SectionView {
|
||||
for (user in users) {
|
||||
UserView(user, users, activateUser, removeUser)
|
||||
for (user in filteredUsers) {
|
||||
UserView(user, users, visibleUsersCount, prefPerformLA, activateUser, removeUser, unhideUser, muteUser, unmuteUser, showHiddenProfile)
|
||||
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)
|
||||
if (searchTextOrPassword.value.trim().isEmpty()) {
|
||||
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.tap_to_activate_profile))
|
||||
LaunchedEffect(Unit) {
|
||||
if (showHiddenProfilesNotice.state.value && users.size > 1) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.make_profile_private),
|
||||
text = generalGetString(R.string.you_can_hide_or_mute_user_profile),
|
||||
confirmText = generalGetString(R.string.ok),
|
||||
dismissText = generalGetString(R.string.dont_show_again),
|
||||
onDismiss = {
|
||||
showHiddenProfilesNotice.set(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
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 }) {
|
||||
private fun UserView(
|
||||
user: User,
|
||||
users: List<User>,
|
||||
visibleUsersCount: Int,
|
||||
prefPerformLA: Boolean,
|
||||
activateUser: (User) -> Unit,
|
||||
removeUser: (User) -> Unit,
|
||||
unhideUser: (User) -> Unit,
|
||||
muteUser: (User) -> Unit,
|
||||
unmuteUser: (User) -> Unit,
|
||||
showHiddenProfile: (User) -> Unit,
|
||||
) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
UserProfilePickerItem(user, onLongClick = { if (users.size > 1) showMenu.value = true }) {
|
||||
activateUser(user)
|
||||
}
|
||||
Box(Modifier.padding(horizontal = 16.dp)) {
|
||||
DropdownMenu(
|
||||
expanded = showDropdownMenu,
|
||||
onDismissRequest = { showDropdownMenu = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (user.hidden) {
|
||||
ItemAction(stringResource(R.string.user_unhide), Icons.Outlined.LockOpen, onClick = {
|
||||
showMenu.value = false
|
||||
unhideUser(user)
|
||||
})
|
||||
} else {
|
||||
if (visibleUsersCount > 1 && prefPerformLA) {
|
||||
ItemAction(stringResource(R.string.user_hide), Icons.Outlined.Lock, onClick = {
|
||||
showMenu.value = false
|
||||
showHiddenProfile(user)
|
||||
})
|
||||
}
|
||||
if (user.showNtfs) {
|
||||
ItemAction(stringResource(R.string.user_mute), Icons.Outlined.NotificationsOff, onClick = {
|
||||
showMenu.value = false
|
||||
muteUser(user)
|
||||
})
|
||||
} else {
|
||||
ItemAction(stringResource(R.string.user_unmute), Icons.Outlined.Notifications, onClick = {
|
||||
showMenu.value = false
|
||||
unmuteUser(user)
|
||||
})
|
||||
}
|
||||
}
|
||||
ItemAction(stringResource(R.string.delete_verb), Icons.Outlined.Delete, color = Color.Red, onClick = {
|
||||
removeUser(user)
|
||||
showDropdownMenu = false
|
||||
}
|
||||
)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean) {
|
||||
enum class UserProfileAction {
|
||||
DELETE,
|
||||
UNHIDE
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileActionView(action: UserProfileAction, user: User, doAction: (String) -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_BOTTOM_PADDING),
|
||||
) {
|
||||
val actionPassword = rememberSaveable { mutableStateOf("") }
|
||||
val passwordValid by remember { derivedStateOf { actionPassword.value == actionPassword.value.trim() } }
|
||||
val actionEnabled by remember { derivedStateOf { actionPassword.value != "" && passwordValid && correctPassword(user, actionPassword.value) } }
|
||||
|
||||
@Composable fun ActionHeader(@StringRes title: Int) {
|
||||
AppBarTitle(stringResource(title))
|
||||
SectionView(padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
|
||||
UserProfileRow(user)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
@Composable fun PasswordAndAction(@StringRes label: Int, color: Color = MaterialTheme.colors.primary) {
|
||||
SectionView() {
|
||||
SectionItemView {
|
||||
PassphraseField(actionPassword, generalGetString(R.string.profile_password), isValid = { passwordValid }, showStrength = true)
|
||||
}
|
||||
SectionItemViewSpaceBetween({ doAction(actionPassword.value) }, disabled = !actionEnabled, minHeight = TextFieldDefaults.MinHeight) {
|
||||
Text(generalGetString(label), color = if (actionEnabled) color else HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (action) {
|
||||
UserProfileAction.DELETE -> {
|
||||
ActionHeader(R.string.delete_profile)
|
||||
PasswordAndAction(R.string.delete_chat_profile, color = Color.Red)
|
||||
if (actionEnabled) {
|
||||
SectionTextFooter(stringResource(R.string.users_delete_all_chats_deleted))
|
||||
}
|
||||
}
|
||||
UserProfileAction.UNHIDE -> {
|
||||
ActionHeader(R.string.unhide_profile)
|
||||
PasswordAndAction(R.string.unhide_chat_profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<User> {
|
||||
val s = searchTextOrPassword.trim()
|
||||
val lower = s.lowercase()
|
||||
return m.users.filter { u ->
|
||||
if ((u.user.activeUser || !u.user.hidden) && (s == "" || u.user.chatViewName.lowercase().contains(lower))) {
|
||||
true
|
||||
} else {
|
||||
correctPassword(u.user, s)
|
||||
}
|
||||
}.map { it.user }
|
||||
}
|
||||
|
||||
private fun visibleUsersCount(m: ChatModel): Int = m.users.filter { u -> !u.user.hidden }.size
|
||||
|
||||
private fun correctPassword(user: User, pwd: String): Boolean {
|
||||
val ph = user.viewPwdHash
|
||||
return ph != null && pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
|
||||
}
|
||||
|
||||
private fun userViewPassword(user: User, searchTextOrPassword: String): String? =
|
||||
if (user.hidden) searchTextOrPassword.trim() else null
|
||||
|
||||
private fun passwordEntryRequired(user: User, searchTextOrPassword: String): Boolean =
|
||||
user.hidden && user.activeUser && !correctPassword(user, searchTextOrPassword.trim())
|
||||
|
||||
private fun removeUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean, searchTextOrPassword: String) {
|
||||
if (passwordEntryRequired(user, searchTextOrPassword)) {
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ProfileActionView(UserProfileAction.DELETE, user) { pwd ->
|
||||
withBGApi {
|
||||
doRemoveUser(m, user, users, delSMPQueues, pwd)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withBGApi { doRemoveUser(m, user, users, delSMPQueues, userViewPassword(user, searchTextOrPassword.trim())) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doRemoveUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean, viewPwd: String?) {
|
||||
if (users.size < 2) return
|
||||
|
||||
withBGApi {
|
||||
try {
|
||||
if (user.activeUser) {
|
||||
val newActive = users.first { !it.activeUser }
|
||||
m.controller.changeActiveUser_(newActive.userId)
|
||||
suspend fun deleteUser(user: User) {
|
||||
m.controller.apiDeleteUser(user.userId, delSMPQueues, viewPwd)
|
||||
m.removeUser(user)
|
||||
}
|
||||
try {
|
||||
if (user.activeUser) {
|
||||
val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden }
|
||||
if (newActive != null) {
|
||||
m.controller.changeActiveUser_(newActive.userId, null)
|
||||
deleteUser(user.copy(activeUser = false))
|
||||
}
|
||||
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())
|
||||
} else {
|
||||
deleteUser(user)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_user), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setUserPrivacy(m: ChatModel, onSuccess: (() -> Unit)? = null, api: suspend () -> User) {
|
||||
try {
|
||||
m.updateUser(api())
|
||||
onSuccess?.invoke()
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.error_updating_user_privacy),
|
||||
text = e.stackTraceToString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMuteProfileAlert(showMuteProfileAlert: SharedPreference<Boolean>) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.muted_when_inactive),
|
||||
text = generalGetString(R.string.you_will_still_receive_calls_and_ntfs),
|
||||
confirmText = generalGetString(R.string.ok),
|
||||
dismissText = generalGetString(R.string.dont_show_again),
|
||||
onDismiss = {
|
||||
showMuteProfileAlert.set(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user