Compare commits
430 Commits
v4.4.1-bet
...
v4.6.1-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
07ad3edbc2 | ||
|
|
b40ed2a7f3 | ||
|
|
29b074607c | ||
|
|
258a157e44 | ||
|
|
92d9a1f9f2 | ||
|
|
e5009a58df | ||
|
|
35a1ce4903 | ||
|
|
7c4c627ee9 | ||
|
|
b7575ec01d | ||
|
|
a0351d6f99 | ||
|
|
6f68840b3a | ||
|
|
2eef858db1 | ||
|
|
434315fb08 | ||
|
|
9b495e576c | ||
|
|
5405f44f54 | ||
|
|
53b05974c9 | ||
|
|
3530022152 | ||
|
|
dc6bab7ae6 | ||
|
|
c9b4ce457e | ||
|
|
bd3325a889 | ||
|
|
9e347484eb | ||
|
|
894af0602d | ||
|
|
aa6011a196 | ||
|
|
f24035a99d | ||
|
|
c006b8150f | ||
|
|
9e4499de6d | ||
|
|
a018e4a581 | ||
|
|
d29fd93ea7 | ||
|
|
b30c7af3a3 | ||
|
|
2798671d22 | ||
|
|
0339b399f7 | ||
|
|
d048962959 | ||
|
|
4af91c4cae | ||
|
|
8a445ece90 | ||
|
|
5082f5b4a4 | ||
|
|
c8fae0ec43 | ||
|
|
d9a8d333f7 | ||
|
|
73f8c543e3 | ||
|
|
14945a9296 | ||
|
|
155ffd16ec | ||
|
|
1eb1e52912 | ||
|
|
af173ee5c4 | ||
|
|
06a2f7e4da | ||
|
|
958299784d | ||
|
|
49b6979ff0 | ||
|
|
3c493db613 | ||
|
|
86cc85b3a5 | ||
|
|
0427d2e578 | ||
|
|
c90d911d2a | ||
|
|
2473d14baa | ||
|
|
a36f2147d8 | ||
|
|
3837e92556 | ||
|
|
76505afff2 | ||
|
|
89c9a01b20 | ||
|
|
68517cf852 | ||
|
|
fbbad55a0f | ||
|
|
bcca27bfdb | ||
|
|
c55a7692c5 | ||
|
|
a8aa829e4c | ||
|
|
d5af03ce18 | ||
|
|
101ef7a81a | ||
|
|
71daeed81a | ||
|
|
d44324eb4d | ||
|
|
93d2ef66cf | ||
|
|
8a78943e94 | ||
|
|
d0f0013755 | ||
|
|
f22ee1a6cf | ||
|
|
4a58ca60ac | ||
|
|
b206868730 | ||
|
|
bd4c10b224 | ||
|
|
46d15d1811 | ||
|
|
ea64be55e1 | ||
|
|
c2cd58f63d | ||
|
|
f21fc76ced | ||
|
|
13bd51b97d | ||
|
|
a1ed0a84b8 | ||
|
|
4815e447fa | ||
|
|
d80cad57b6 | ||
|
|
a58be6ebb6 | ||
|
|
dfa0272065 | ||
|
|
9723c47b25 | ||
|
|
3eb51eca58 | ||
|
|
86151d4ec2 | ||
|
|
717d05c4a3 | ||
|
|
3c43c5d254 | ||
|
|
0bae260fae | ||
|
|
e694848cd5 | ||
|
|
f5f61c5806 | ||
|
|
96c1c1d439 | ||
|
|
3e560278b6 | ||
|
|
6e131e0bad | ||
|
|
bd158f3b0d | ||
|
|
a96fb2f8d1 | ||
|
|
148261a1ee | ||
|
|
22b7aa90b2 | ||
|
|
88d9e70ef8 | ||
|
|
a0cfc19063 | ||
|
|
451aab46dc | ||
|
|
3b3db562cd | ||
|
|
15ed6ea4a6 | ||
|
|
9f661ff4e2 | ||
|
|
52c39c9641 | ||
|
|
0dab486ac8 | ||
|
|
d640f4a5d5 | ||
|
|
1c47bfbf44 | ||
|
|
db3fc4ee7b | ||
|
|
74df35d3b0 | ||
|
|
c01c629f73 | ||
|
|
25e4a1e86d | ||
|
|
e27013071b | ||
|
|
2679bc2e94 | ||
|
|
93ab713748 | ||
|
|
bc1d86e303 | ||
|
|
b386346cf1 | ||
|
|
b6db41dd50 | ||
|
|
6bca013e67 | ||
|
|
e538826cd8 | ||
|
|
a7a56ea1d9 | ||
|
|
77d0f70270 | ||
|
|
2a20f78877 | ||
|
|
b027708828 | ||
|
|
a0bf298b66 | ||
|
|
e3b22d83ad | ||
|
|
ab4e4e1db9 | ||
|
|
a393bc8163 | ||
|
|
3bc4fd222c | ||
|
|
1b01dcec6d | ||
|
|
1d5c361b9a | ||
|
|
4cd396a0d2 | ||
|
|
bcc80be8e9 | ||
|
|
5d12217eab | ||
|
|
4b0046a60b | ||
|
|
114b76e3f8 | ||
|
|
c72aa5d074 | ||
|
|
2e9882b0bd | ||
|
|
8ff8f9d695 | ||
|
|
1e3c2024bb | ||
|
|
8bec0004cc | ||
|
|
c337a6888d | ||
|
|
e72d4638d2 | ||
|
|
7dd4dc3b40 | ||
|
|
980c7a9ddd | ||
|
|
cb5f26d354 | ||
|
|
04d886e546 | ||
|
|
ed12ccaac2 | ||
|
|
e9e9286fbb | ||
|
|
f4ffa5237c | ||
|
|
ef15dca0b4 | ||
|
|
396b3ae639 | ||
|
|
c409f58067 | ||
|
|
69ca731641 | ||
|
|
006a30e65c | ||
|
|
0a9aa8b3d2 | ||
|
|
69196084fc | ||
|
|
cf4105e256 | ||
|
|
ad6aa10cd2 | ||
|
|
ba29d0242e | ||
|
|
ca64ed9784 | ||
|
|
a227e21fcf | ||
|
|
7c9c358ce2 | ||
|
|
84237f79fc | ||
|
|
80e0bfb61f | ||
|
|
153f80fe64 | ||
|
|
19881703e5 | ||
|
|
a668bd5736 | ||
|
|
e8cab01c03 | ||
|
|
3f72633d22 | ||
|
|
5c7ad0926c | ||
|
|
7eca44bb84 | ||
|
|
2f39cfd86f | ||
|
|
2fdc23274d | ||
|
|
91a39cae23 | ||
|
|
882966d5d3 | ||
|
|
3ed5e6e50b | ||
|
|
df6cec6a32 | ||
|
|
24c47657f4 | ||
|
|
cf6afb7687 | ||
|
|
774af334fd | ||
|
|
9dc6c1327f | ||
|
|
af414d7f6e | ||
|
|
a040fa65bb | ||
|
|
9fc26ca799 | ||
|
|
9dad55ce8d | ||
|
|
6e0addbea3 | ||
|
|
e452edb781 | ||
|
|
a3283708e7 | ||
|
|
e833d66557 | ||
|
|
71f5b51350 | ||
|
|
20cec4db11 | ||
|
|
2085dc5d60 | ||
|
|
9290fcc6b2 | ||
|
|
0c3d643408 | ||
|
|
6d5c3ae484 | ||
|
|
cccdcef914 | ||
|
|
e63e158b2d | ||
|
|
892b91e958 | ||
|
|
fb04108b11 | ||
|
|
424328b9d1 | ||
|
|
e73f5c40cf | ||
|
|
4c960bdc44 | ||
|
|
dcb82951ed | ||
|
|
138cce4436 | ||
|
|
98417dafc4 | ||
|
|
e8374be19c | ||
|
|
62a2f61751 | ||
|
|
7323bb4333 | ||
|
|
2d47175f94 | ||
|
|
a6d7604d21 | ||
|
|
9e3573fc76 | ||
|
|
41e873d5ca | ||
|
|
ad1b091b18 | ||
|
|
bb0482104c | ||
|
|
fa9e0086f6 |
16
.github/workflows/build.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- stable
|
||||
- sqlcipher
|
||||
- users
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
@@ -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
|
||||
@@ -91,8 +91,12 @@ jobs:
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Install pkg-config for Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
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
|
||||
@@ -108,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: 10
|
||||
if: matrix.os != 'windows-latest'
|
||||
timeout-minutes: 30
|
||||
shell: bash
|
||||
run: cabal test --test-show-details=direct
|
||||
|
||||
|
||||
6
.gitignore
vendored
@@ -42,12 +42,15 @@ stack.yaml.lock
|
||||
|
||||
# Temporary test files
|
||||
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
|
||||
@@ -72,3 +75,4 @@ website/package-lock.json
|
||||
# Ignore test files
|
||||
website/.cache
|
||||
website/test/stubs-layout-cache/_includes/*.js
|
||||
apps/android/app/release
|
||||
|
||||
220
README.md
@@ -1,13 +1,28 @@
|
||||
<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)
|
||||
[](https://twitter.com/SimpleXChat)
|
||||
|
||||
| 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)
|
||||
|
||||
@@ -23,9 +38,92 @@
|
||||
- 🔐 Double ratchet end-to-end encryption, with additional encryption layer.
|
||||
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
|
||||
- 🚀 [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 on Linux, MacOS, Windows.
|
||||
- 🖥 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-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 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/)||
|
||||
|🇷🇺 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)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)|[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|
||||
Languages in progress: Arabic, Hindi, Japanese, Spanish 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 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
|
||||
|
||||
@@ -37,15 +135,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)
|
||||
- [Contribute](#contribute)
|
||||
- [Help us with donations](#help-us-with-donations)
|
||||
- [Disclaimers, Security contact, License](#disclaimers)
|
||||
|
||||
## Why privacy matters
|
||||
@@ -76,36 +170,32 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release annoucement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
|
||||
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release announcement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
|
||||
|
||||
2. _Why should I not just use Signal?_ Signal is a centralised platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
|
||||
2. _Why should I not just use Signal?_ Signal is a centralized platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
|
||||
|
||||
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identites?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages – it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
|
||||
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identities?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages – it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
|
||||
|
||||
## News and updates
|
||||
|
||||
Recent updates:
|
||||
|
||||
[Jan 03, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
||||
[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).
|
||||
|
||||
[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).
|
||||
[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).
|
||||
|
||||
[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).
|
||||
[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).
|
||||
|
||||
[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).
|
||||
|
||||
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md).
|
||||
|
||||
[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
|
||||
@@ -138,7 +228,7 @@ SimpleX Chat is a work in progress – we are releasing improvements as they are
|
||||
|
||||
What is already implemented:
|
||||
|
||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notificaitons on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||
2. End-to-end encryption in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
||||
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with forward secrecy (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||
@@ -148,10 +238,11 @@ What is already implemented:
|
||||
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
||||
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
||||
|
||||
We plan to add soon:
|
||||
|
||||
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
1. Automatic message queue rotation. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
|
||||
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
|
||||
@@ -161,7 +252,7 @@ You can:
|
||||
|
||||
- use SimpleX Chat library to integrate chat functionality into your mobile apps.
|
||||
- create chat bots and services in Haskell - see [simple](./apps/simplex-bot/) and more [advanced chat bot example](./apps/simplex-bot-advanced/).
|
||||
- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScipt chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
|
||||
- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
|
||||
- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
|
||||
|
||||
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
||||
@@ -196,17 +287,24 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ Disappearing messages (with recipient opt-in per-contact).
|
||||
- ✅ "Live" messages.
|
||||
- ✅ Contact verification via a separate out-of-band channel.
|
||||
- 🏗 Multiple user profiles in the same chat database.
|
||||
- 🏗 Optionally avoid re-using the same TCP session for multiple connections.
|
||||
- 🏗 File server to optimize for efficient and private sending of large files.
|
||||
- ✅ 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.
|
||||
- ✅ 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.
|
||||
- 🏗 Preserve message drafts.
|
||||
- 🏗 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).
|
||||
- Video messages.
|
||||
- Local app files encryption.
|
||||
- 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.).
|
||||
@@ -215,53 +313,9 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||
- keep all your contacts and groups even if you lose the domain.
|
||||
- the server doesn't have information about your contacts and groups.
|
||||
- Channels server for large groups and broadcast channels.
|
||||
|
||||
## Join a user group
|
||||
|
||||
You can join a general English speaking group: [#SimpleX-Group](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FcIS0gu1h0Y8pZpQkDaSz7HZGSHcKpMB9%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAKzzWAJYrVt1zdgRp4pD3FBst6eK7233DJeNElENLJRA%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%228mazMhefXoM5HxWBfZnvwQ%3D%3D%22%7D).
|
||||
|
||||
Groups in languages other than English, that we have app interface translated into: [\#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).
|
||||
|
||||
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!
|
||||
|
||||
## Contribute
|
||||
|
||||
We would love to have you join the development! You can contribute to SimpleX Chat with:
|
||||
|
||||
- translate UI to your language - we are using [Weblate](https://hosted.weblate.org/projects/simplex-chat/) to translate the interface, please get in touch if you want to contribute!
|
||||
- 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
|
||||
- Hosting server for large groups, communities and public channels.
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- High capacity multi-node SMP relays.
|
||||
|
||||
## Disclaimers
|
||||
|
||||
|
||||
@@ -9,15 +9,12 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
minSdk 26
|
||||
targetSdk 32
|
||||
versionCode 86
|
||||
versionName "4.4.1-beta.1"
|
||||
versionCode 111
|
||||
versionName "4.6.1-beta.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
@@ -76,6 +73,28 @@ android {
|
||||
}
|
||||
jniLibs.useLegacyPackaging = compression_level != "0"
|
||||
}
|
||||
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||
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", "cs", "de", "es", "fr", "it", "nl", "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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -124,6 +143,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'
|
||||
@@ -131,19 +153,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
|
||||
}
|
||||
}
|
||||
@@ -151,6 +166,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 = ""
|
||||
@@ -191,6 +213,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
|
||||
|
||||
@@ -22,10 +22,12 @@ var TransformOperation;
|
||||
TransformOperation["Decrypt"] = "decrypt";
|
||||
})(TransformOperation || (TransformOperation = {}));
|
||||
let activeCall;
|
||||
let answerTimeout = 30000;
|
||||
const processCommand = (function () {
|
||||
const defaultIceServers = [
|
||||
{ urls: ["stun:stun.simplex.im:443"] },
|
||||
{ urls: ["turn:turn.simplex.im:443"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
{ urls: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
{ urls: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
];
|
||||
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
|
||||
return {
|
||||
@@ -100,9 +102,16 @@ const processCommand = (function () {
|
||||
const iceCandidates = getIceCandidates(pc, config);
|
||||
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker };
|
||||
await setupMediaStreams(call);
|
||||
let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
|
||||
pc.addEventListener("connectionstatechange", connectionStateChange);
|
||||
return call;
|
||||
async function connectionStateChange() {
|
||||
// "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView)
|
||||
// See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70)
|
||||
if (pc.connectionState !== "failed")
|
||||
connectionHandler();
|
||||
}
|
||||
async function connectionHandler() {
|
||||
sendMessageToNative({
|
||||
resp: {
|
||||
type: "connection",
|
||||
@@ -115,6 +124,7 @@ const processCommand = (function () {
|
||||
},
|
||||
});
|
||||
if (pc.connectionState == "disconnected" || pc.connectionState == "failed") {
|
||||
clearConnectionTimeout();
|
||||
pc.removeEventListener("connectionstatechange", connectionStateChange);
|
||||
if (activeCall) {
|
||||
setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
|
||||
@@ -122,6 +132,7 @@ const processCommand = (function () {
|
||||
endCall();
|
||||
}
|
||||
else if (pc.connectionState == "connected") {
|
||||
clearConnectionTimeout();
|
||||
const stats = (await pc.getStats());
|
||||
for (const stat of stats.values()) {
|
||||
const { type, state } = stat;
|
||||
@@ -141,6 +152,12 @@ const processCommand = (function () {
|
||||
}
|
||||
}
|
||||
}
|
||||
function clearConnectionTimeout() {
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
function serialize(x) {
|
||||
return LZString.compressToBase64(JSON.stringify(x));
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +27,7 @@ import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.*
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.model.NtfManager.Companion.getUserIdFromIntent
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.SplashView
|
||||
@@ -40,9 +39,8 @@ import chat.simplex.app.views.database.DatabaseErrorView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
companion object {
|
||||
@@ -67,6 +65,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) {
|
||||
@@ -109,8 +108,8 @@ 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) {
|
||||
runAuthenticate()
|
||||
@@ -129,6 +128,7 @@ class MainActivity: FragmentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
VideoPlayer.stopAll()
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
|
||||
@@ -160,25 +160,31 @@ 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(
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,14 +267,6 @@ fun MainPage(
|
||||
setPerformLA: (Boolean) -> 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)
|
||||
}
|
||||
@@ -327,7 +325,7 @@ fun MainPage(
|
||||
}
|
||||
}
|
||||
onboarding == null || userCreated == null -> SplashView()
|
||||
!chatsAccessAuthorized -> {
|
||||
userAuthorized.value != true -> {
|
||||
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||
authView()
|
||||
} else {
|
||||
@@ -373,13 +371,6 @@ fun MainPage(
|
||||
.collect {
|
||||
if (it != null) currentChatId = it
|
||||
else onComposed()
|
||||
|
||||
// Deletes files that were not sent but already stored in files directory.
|
||||
// Currently, it's voice records only
|
||||
if (it == null && chatModel.filesToDelete.isNotEmpty()) {
|
||||
chatModel.filesToDelete.forEach { it.delete() }
|
||||
chatModel.filesToDelete.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,7 +384,7 @@ fun MainPage(
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
|
||||
onboarding == OnboardingStage.Step3_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||
}
|
||||
ModalManager.shared.showInView()
|
||||
@@ -404,20 +395,33 @@ fun MainPage(
|
||||
}
|
||||
|
||||
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
val userId = getUserIdFromIntent(intent)
|
||||
when (intent?.action) {
|
||||
NtfManager.OpenChatAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||
if (chatId != null) {
|
||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||
chatModel.clearOverlays.value = true
|
||||
if (cInfo != null) withApi { openChat(cInfo, chatModel) }
|
||||
withBGApi {
|
||||
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
|
||||
if (cInfo != null) openChat(cInfo, chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
NtfManager.ShowChatsAction -> {
|
||||
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
withBGApi {
|
||||
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
|
||||
}
|
||||
}
|
||||
NtfManager.AcceptCallAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
@@ -503,6 +507,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) }
|
||||
@@ -90,6 +94,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
context = this
|
||||
initChatController()
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
@@ -100,8 +105,19 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
isAppOnForeground = true
|
||||
if (chatModel.chatRunning.value == true) {
|
||||
kotlin.runCatching {
|
||||
val chats = chatController.apiGetChats()
|
||||
chatModel.updateChats(chats)
|
||||
val currentUserId = chatModel.currentUser.value?.userId
|
||||
val chats = ArrayList(chatController.apiGetChats())
|
||||
/** Active user can be changed in background while [ChatController.apiGetChats] is executing */
|
||||
if (chatModel.currentUser.value?.userId == currentUserId) {
|
||||
val currentChatId = chatModel.chatId.value
|
||||
val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null
|
||||
if (oldStats != null) {
|
||||
val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId }
|
||||
/** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
|
||||
if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
|
||||
}
|
||||
chatModel.updateChats(chats)
|
||||
}
|
||||
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.usersettings.NotificationPreviewMode
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.descriptors.*
|
||||
@@ -34,6 +36,7 @@ import kotlin.time.*
|
||||
class ChatModel(val controller: ChatController) {
|
||||
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
||||
val currentUser = mutableStateOf<User?>(null)
|
||||
val users = mutableStateListOf<UserInfo>()
|
||||
val userCreated = mutableStateOf<Boolean?>(null)
|
||||
val chatRunning = mutableStateOf<Boolean?>(null)
|
||||
val chatDbChanged = mutableStateOf<Boolean>(false)
|
||||
@@ -41,6 +44,8 @@ class ChatModel(val controller: ChatController) {
|
||||
val chatDbStatus = mutableStateOf<DBMigrationResult?>(null)
|
||||
val chatDbDeleted = mutableStateOf(false)
|
||||
val chats = mutableStateListOf<Chat>()
|
||||
// map of connections network statuses, key is agent connection id
|
||||
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
|
||||
|
||||
// current chat
|
||||
val chatId = mutableStateOf<String?>(null)
|
||||
@@ -80,16 +85,38 @@ class ChatModel(val controller: ChatController) {
|
||||
// currently showing QR code
|
||||
val connReqInv = mutableStateOf(null as String?)
|
||||
|
||||
var draft = mutableStateOf(null as ComposeState?)
|
||||
var draftChatId = mutableStateOf(null as String?)
|
||||
|
||||
// working with external intents
|
||||
val sharedContent = mutableStateOf(null as SharedContent?)
|
||||
|
||||
val filesToDelete = mutableSetOf<File>()
|
||||
val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
|
||||
|
||||
fun updateUserProfile(profile: LocalProfile) {
|
||||
val user = currentUser.value
|
||||
if (user != null) {
|
||||
currentUser.value = user.copy(profile = profile)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,17 +146,8 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
|
||||
fun updateChats(newChats: List<Chat>) {
|
||||
val mergedChats = arrayListOf<Chat>()
|
||||
for (newChat in newChats) {
|
||||
val i = getChatIndex(newChat.chatInfo.id)
|
||||
if (i >= 0) {
|
||||
mergedChats.add(newChat.copy(serverInfo = chats[i].serverInfo))
|
||||
} else {
|
||||
mergedChats.add(newChat)
|
||||
}
|
||||
}
|
||||
chats.clear()
|
||||
chats.addAll(mergedChats)
|
||||
chats.addAll(newChats)
|
||||
|
||||
val cId = chatId.value
|
||||
// If chat is null, it was deleted in background after apiGetChats call
|
||||
@@ -138,14 +156,6 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) {
|
||||
val i = getChatIndex(id)
|
||||
if (i >= 0) {
|
||||
val chat = chats[i]
|
||||
chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status))
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceChat(id: String, chat: Chat) {
|
||||
val i = getChatIndex(id)
|
||||
if (i >= 0) {
|
||||
@@ -156,7 +166,7 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
}
|
||||
|
||||
fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
// update previews
|
||||
val i = getChatIndex(cInfo.id)
|
||||
val chat: Chat
|
||||
@@ -167,6 +177,7 @@ class ChatModel(val controller: ChatController) {
|
||||
chatStats =
|
||||
if (cItem.meta.itemStatus is CIStatus.RcvNew) {
|
||||
val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId
|
||||
increaseUnreadCounter(currentUser.value!!)
|
||||
chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, minUnreadItemId = minUnreadId)
|
||||
}
|
||||
else
|
||||
@@ -180,15 +191,17 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
// add to current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean {
|
||||
suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean {
|
||||
// update previews
|
||||
val i = getChatIndex(cInfo.id)
|
||||
val chat: Chat
|
||||
@@ -215,7 +228,9 @@ class ChatModel(val controller: ChatController) {
|
||||
chatItems[itemIndex] = cItem
|
||||
return false
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatItems.add(cItem)
|
||||
}
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
@@ -251,6 +266,7 @@ class ChatModel(val controller: ChatController) {
|
||||
// clear preview
|
||||
val i = getChatIndex(cInfo.id)
|
||||
if (i >= 0) {
|
||||
decreaseUnreadCounter(currentUser.value!!, chats[i].chatStats.unreadCount)
|
||||
chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo)
|
||||
}
|
||||
// clear current chat
|
||||
@@ -259,16 +275,28 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
}
|
||||
|
||||
fun addLiveChatItemDummy(quotedCItem: ChatItem?, chatInfo: ChatInfo): ChatItem {
|
||||
val quoted = if (quotedCItem?.content?.msgContent != null) {
|
||||
CIQuote(chatDir = quotedCItem.chatDir, itemId = quotedCItem.id, sentAt = quotedCItem.meta.createdAt, content = quotedCItem.content.msgContent!!)
|
||||
} else null
|
||||
val cItem = ChatItem.liveChatItemDummy(chatInfo is ChatInfo.Direct, quoted)
|
||||
chatItems.add(cItem)
|
||||
fun updateCurrentUser(newProfile: Profile, preferences: FullChatPreferences? = null) {
|
||||
val current = currentUser.value ?: return
|
||||
val updated = current.copy(
|
||||
profile = newProfile.toLocalProfile(current.profile.profileId),
|
||||
fullPreferences = preferences ?: current.fullPreferences
|
||||
)
|
||||
val indexInUsers = users.indexOfFirst { it.user.userId == current.userId }
|
||||
if (indexInUsers != -1) {
|
||||
users[indexInUsers] = UserInfo(updated, users[indexInUsers].unreadCount)
|
||||
}
|
||||
currentUser.value = updated
|
||||
}
|
||||
|
||||
suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
|
||||
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatItems.add(cItem)
|
||||
}
|
||||
return cItem
|
||||
}
|
||||
|
||||
fun removeLiveChatItemDummy() {
|
||||
fun removeLiveDummy() {
|
||||
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.removeLast()
|
||||
}
|
||||
@@ -282,9 +310,11 @@ class ChatModel(val controller: ChatController) {
|
||||
val chat = chats[chatIdx]
|
||||
val lastId = chat.chatItems.lastOrNull()?.id
|
||||
if (lastId != null) {
|
||||
val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0
|
||||
decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
|
||||
chats[chatIdx] = chat.copy(
|
||||
chatStats = chat.chatStats.copy(
|
||||
unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0,
|
||||
unreadCount = unreadCount,
|
||||
// Can't use minUnreadItemId currently since chat items can have unread items between read items
|
||||
//minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1
|
||||
)
|
||||
@@ -320,13 +350,30 @@ class ChatModel(val controller: ChatController) {
|
||||
if (chatIndex == -1) return
|
||||
|
||||
val chat = chats[chatIndex]
|
||||
val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0)
|
||||
decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
|
||||
chats[chatIndex] = chat.copy(
|
||||
chatStats = chat.chatStats.copy(
|
||||
unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0),
|
||||
unreadCount = unreadCount,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun increaseUnreadCounter(user: User) {
|
||||
changeUnreadCounter(user, 1)
|
||||
}
|
||||
|
||||
fun decreaseUnreadCounter(user: User, by: Int = 1) {
|
||||
changeUnreadCounter(user, -by)
|
||||
}
|
||||
|
||||
private fun changeUnreadCounter(user: User, by: Int) {
|
||||
val i = users.indexOfFirst { it.user.userId == user.userId }
|
||||
if (i != -1) {
|
||||
users[i] = UserInfo(user, users[i].unreadCount + by)
|
||||
}
|
||||
}
|
||||
|
||||
// func popChat(_ id: String) {
|
||||
// if let i = getChatIndex(id) {
|
||||
// popChat_(i)
|
||||
@@ -371,6 +418,20 @@ class ChatModel(val controller: ChatController) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) {
|
||||
networkStatuses[contact.activeConn.agentConnId] = status
|
||||
}
|
||||
|
||||
fun contactNetworkStatus(contact: Contact): NetworkStatus =
|
||||
networkStatuses[contact.activeConn.agentConnId] ?: NetworkStatus.Unknown()
|
||||
|
||||
fun addTerminalItem(item: TerminalItem) {
|
||||
if (terminalItems.size >= 500) {
|
||||
terminalItems.removeAt(0)
|
||||
}
|
||||
terminalItems.add(item)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChatType(val type: String) {
|
||||
@@ -387,13 +448,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,
|
||||
@@ -401,7 +468,28 @@ 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,
|
||||
val unreadCount: Int
|
||||
) {
|
||||
companion object {
|
||||
val sampleData = UserInfo(
|
||||
user = User.sampleData,
|
||||
unreadCount = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -437,37 +525,30 @@ data class Chat (
|
||||
val chatInfo: ChatInfo,
|
||||
val chatItems: List<ChatItem>,
|
||||
val chatStats: ChatStats = ChatStats(),
|
||||
val serverInfo: ServerInfo = ServerInfo(NetworkStatus.Unknown())
|
||||
) {
|
||||
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
|
||||
data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false)
|
||||
|
||||
@Serializable
|
||||
data class ServerInfo(val networkStatus: NetworkStatus)
|
||||
|
||||
@Serializable
|
||||
sealed class NetworkStatus {
|
||||
val statusString: String get() =
|
||||
when (this) {
|
||||
is Connected -> generalGetString(R.string.server_connected)
|
||||
is Error -> generalGetString(R.string.server_error)
|
||||
else -> generalGetString(R.string.server_connecting)
|
||||
}
|
||||
val statusExplanation: String get() =
|
||||
when (this) {
|
||||
is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact)
|
||||
is Error -> String.format(generalGetString(R.string.trying_to_connect_to_server_to_receive_messages_with_error), error)
|
||||
else -> generalGetString(R.string.trying_to_connect_to_server_to_receive_messages)
|
||||
}
|
||||
|
||||
@Serializable @SerialName("unknown") class Unknown: NetworkStatus()
|
||||
@Serializable @SerialName("connected") class Connected: NetworkStatus()
|
||||
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
|
||||
@Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val sampleData = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
@@ -601,6 +682,27 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class NetworkStatus {
|
||||
val statusString: String get() =
|
||||
when (this) {
|
||||
is Connected -> generalGetString(R.string.server_connected)
|
||||
is Error -> generalGetString(R.string.server_error)
|
||||
else -> generalGetString(R.string.server_connecting)
|
||||
}
|
||||
val statusExplanation: String get() =
|
||||
when (this) {
|
||||
is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact)
|
||||
is Error -> String.format(generalGetString(R.string.trying_to_connect_to_server_to_receive_messages_with_error), error)
|
||||
else -> generalGetString(R.string.trying_to_connect_to_server_to_receive_messages)
|
||||
}
|
||||
|
||||
@Serializable @SerialName("unknown") class Unknown: NetworkStatus()
|
||||
@Serializable @SerialName("connected") class Connected: NetworkStatus()
|
||||
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
|
||||
@Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Contact(
|
||||
val contactId: Long,
|
||||
@@ -671,6 +773,8 @@ data class Contact(
|
||||
@Serializable
|
||||
class ContactRef(
|
||||
val contactId: Long,
|
||||
val agentConnId: String,
|
||||
val connId: Long,
|
||||
var localDisplayName: String
|
||||
) {
|
||||
val id: ChatId get() = "@$contactId"
|
||||
@@ -685,6 +789,7 @@ class ContactSubStatus(
|
||||
@Serializable
|
||||
data class Connection(
|
||||
val connId: Long,
|
||||
val agentConnId: String,
|
||||
val connStatus: ConnStatus,
|
||||
val connLevel: Int,
|
||||
val viaGroupLink: Boolean,
|
||||
@@ -693,7 +798,7 @@ data class Connection(
|
||||
) {
|
||||
val id: ChatId get() = ":$connId"
|
||||
companion object {
|
||||
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null)
|
||||
val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,11 +1021,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)
|
||||
@@ -1171,9 +1278,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() =
|
||||
@@ -1210,6 +1332,8 @@ data class ChatItem (
|
||||
is CIContent.SndGroupFeature -> showNtfDir
|
||||
is CIContent.RcvChatFeatureRejected -> showNtfDir
|
||||
is CIContent.RcvGroupFeatureRejected -> showNtfDir
|
||||
is CIContent.SndModerated -> true
|
||||
is CIContent.RcvModerated -> true
|
||||
is CIContent.InvalidJSON -> false
|
||||
}
|
||||
|
||||
@@ -1224,14 +1348,14 @@ data class ChatItem (
|
||||
status: CIStatus = CIStatus.SndNew(),
|
||||
quotedItem: CIQuote? = null,
|
||||
file: CIFile? = null,
|
||||
itemDeleted: Boolean = false,
|
||||
itemDeleted: CIDeleted? = null,
|
||||
itemEdited: Boolean = false,
|
||||
itemTimed: CITimed? = null,
|
||||
editable: Boolean = true
|
||||
) =
|
||||
ChatItem(
|
||||
chatDir = dir,
|
||||
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, null, editable),
|
||||
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, itemTimed, editable),
|
||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
|
||||
quotedItem = quotedItem,
|
||||
file = file
|
||||
@@ -1246,7 +1370,7 @@ data class ChatItem (
|
||||
) =
|
||||
ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead()),
|
||||
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile(text)),
|
||||
quotedItem = null,
|
||||
file = CIFile.getSample(fileName = fileName, fileSize = fileSize, fileStatus = fileStatus)
|
||||
@@ -1261,7 +1385,7 @@ data class ChatItem (
|
||||
) =
|
||||
ChatItem(
|
||||
chatDir = dir,
|
||||
meta = CIMeta.getSample(id, ts, text, status, itemDeleted = false, itemEdited = false, editable = false),
|
||||
meta = CIMeta.getSample(id, ts, text, status),
|
||||
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
@@ -1270,7 +1394,7 @@ data class ChatItem (
|
||||
fun getGroupInvitationSample(status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending) =
|
||||
ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "received invitation to join group team as admin", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "received invitation to join group team as admin", CIStatus.RcvRead()),
|
||||
content = CIContent.RcvGroupInvitation(groupInvitation = CIGroupInvitation.getSample(status = status), memberRole = GroupMemberRole.Admin),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
@@ -1279,7 +1403,7 @@ data class ChatItem (
|
||||
fun getGroupEventSample() =
|
||||
ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "group event text", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "group event text", CIStatus.RcvRead()),
|
||||
content = CIContent.RcvGroupEventContent(rcvGroupEvent = RcvGroupEvent.MemberAdded(groupMemberId = 1, profile = Profile.sampleData)),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
@@ -1289,13 +1413,13 @@ data class ChatItem (
|
||||
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled, param = null)
|
||||
return ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead()),
|
||||
content = content,
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
|
||||
const val TEMP_LIVE_CHAT_ITEM_ID = -2L
|
||||
|
||||
@@ -1309,7 +1433,7 @@ data class ChatItem (
|
||||
itemStatus = CIStatus.RcvRead(),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now(),
|
||||
itemDeleted = false,
|
||||
itemDeleted = null,
|
||||
itemEdited = false,
|
||||
itemTimed = null,
|
||||
itemLive = false,
|
||||
@@ -1320,7 +1444,7 @@ data class ChatItem (
|
||||
file = null
|
||||
)
|
||||
|
||||
fun liveChatItemDummy(direct: Boolean, quoted: CIQuote?): ChatItem = ChatItem(
|
||||
fun liveDummy(direct: Boolean): ChatItem = ChatItem(
|
||||
chatDir = if (direct) CIDirection.DirectSnd() else CIDirection.GroupSnd(),
|
||||
meta = CIMeta(
|
||||
itemId = TEMP_LIVE_CHAT_ITEM_ID,
|
||||
@@ -1329,14 +1453,14 @@ data class ChatItem (
|
||||
itemStatus = CIStatus.RcvRead(),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now(),
|
||||
itemDeleted = false,
|
||||
itemDeleted = null,
|
||||
itemEdited = false,
|
||||
itemTimed = null,
|
||||
itemLive = true,
|
||||
editable = false
|
||||
),
|
||||
content = CIContent.SndMsgContent(MsgContent.MCText("")),
|
||||
quotedItem = quoted,
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
|
||||
@@ -1374,7 +1498,7 @@ data class CIMeta (
|
||||
val itemStatus: CIStatus,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val itemDeleted: Boolean,
|
||||
val itemDeleted: CIDeleted?,
|
||||
val itemEdited: Boolean,
|
||||
val itemTimed: CITimed?,
|
||||
val itemLive: Boolean?,
|
||||
@@ -1399,7 +1523,7 @@ data class CIMeta (
|
||||
companion object {
|
||||
fun getSample(
|
||||
id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
|
||||
itemDeleted: Boolean = false, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, editable: Boolean = true
|
||||
itemDeleted: CIDeleted? = null, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, editable: Boolean = true
|
||||
): CIMeta =
|
||||
CIMeta(
|
||||
itemId = id,
|
||||
@@ -1424,7 +1548,7 @@ data class CIMeta (
|
||||
itemStatus = CIStatus.SndNew(),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now(),
|
||||
itemDeleted = false,
|
||||
itemDeleted = null,
|
||||
itemEdited = false,
|
||||
itemTimed = null,
|
||||
itemLive = false,
|
||||
@@ -1459,6 +1583,12 @@ sealed class CIStatus {
|
||||
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CIDeleted {
|
||||
@Serializable @SerialName("deleted") class Deleted: CIDeleted()
|
||||
@Serializable @SerialName("moderated") class Moderated(val byGroupMember: GroupMember): CIDeleted()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CIDeleteMode(val deleteMode: String) {
|
||||
@SerialName("internal") cidmInternal("internal"),
|
||||
@@ -1494,6 +1624,8 @@ sealed class CIContent: ItemContent {
|
||||
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndModerated") object SndModerated: CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvModerated") object RcvModerated: CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
|
||||
override val text: String get() = when (this) {
|
||||
@@ -1518,6 +1650,8 @@ sealed class CIContent: ItemContent {
|
||||
is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param)
|
||||
is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
|
||||
is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
|
||||
is SndModerated -> generalGetString(R.string.moderated_description)
|
||||
is RcvModerated -> generalGetString(R.string.moderated_description)
|
||||
is InvalidJSON -> "invalid data"
|
||||
}
|
||||
|
||||
@@ -1531,11 +1665,11 @@ sealed class CIContent: ItemContent {
|
||||
|
||||
fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when {
|
||||
allowed != FeatureAllowed.NO && feature.hasParam && param != null ->
|
||||
"offered ${feature.text}: ${TimedMessagesPreference.ttlText(param)}"
|
||||
String.format(generalGetString(R.string.feature_offered_item_with_param), feature.text, TimedMessagesPreference.ttlText(param))
|
||||
allowed != FeatureAllowed.NO ->
|
||||
"offered ${feature.text}"
|
||||
String.format(generalGetString(R.string.feature_offered_item), feature.text, TimedMessagesPreference.ttlText(param))
|
||||
else ->
|
||||
"cancelled ${feature.text}"
|
||||
String.format(generalGetString(R.string.feature_cancelled_item), feature.text, TimedMessagesPreference.ttlText(param))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1577,18 +1711,31 @@ 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.RcvInvitation -> false
|
||||
is CIFileStatus.RcvAccepted -> false
|
||||
is CIFileStatus.RcvTransfer -> false
|
||||
is CIFileStatus.RcvCancelled -> false
|
||||
is CIFileStatus.RcvComplete -> true
|
||||
}
|
||||
|
||||
val cancellable: Boolean = when (fileStatus) {
|
||||
is CIFileStatus.SndStored -> fileProtocol != FileProtocol.XFTP // TODO true - enable when XFTP send supports cancel
|
||||
is CIFileStatus.SndTransfer -> fileProtocol != FileProtocol.XFTP // TODO true
|
||||
is CIFileStatus.SndComplete -> false
|
||||
is CIFileStatus.SndCancelled -> false
|
||||
is CIFileStatus.RcvInvitation -> false
|
||||
is CIFileStatus.RcvAccepted -> true
|
||||
is CIFileStatus.RcvTransfer -> true
|
||||
is CIFileStatus.RcvCancelled -> false
|
||||
is CIFileStatus.RcvComplete -> false
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -1599,21 +1746,27 @@ 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;
|
||||
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("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()
|
||||
}
|
||||
|
||||
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
|
||||
@@ -1624,6 +1777,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()
|
||||
@@ -1677,6 +1831,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")
|
||||
})
|
||||
@@ -1700,6 +1859,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)
|
||||
@@ -1735,6 +1899,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")
|
||||
|
||||
@@ -30,7 +30,13 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
||||
const val CallNotificationId: Int = -1
|
||||
|
||||
private const val UserIdKey: String = "userId"
|
||||
private const val ChatIdKey: String = "chatId"
|
||||
|
||||
fun getUserIdFromIntent(intent: Intent?): Long? {
|
||||
val userId = intent?.getLongExtra(UserIdKey, -1L)
|
||||
return if (userId == -1L || userId == null) null else userId
|
||||
}
|
||||
}
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -38,11 +44,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
private val msgNtfTimeoutMs = 30000L
|
||||
|
||||
init {
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
|
||||
// Remove old channels since they can't be edited
|
||||
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
||||
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
||||
if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert()
|
||||
}
|
||||
|
||||
enum class NotificationAction {
|
||||
@@ -77,8 +79,9 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyContactRequestReceived(cInfo: ChatInfo.ContactRequest) {
|
||||
notifyMessageReceived(
|
||||
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
|
||||
displayNotification(
|
||||
user = user,
|
||||
chatId = cInfo.id,
|
||||
displayName = cInfo.displayName,
|
||||
msgText = generalGetString(R.string.notification_new_contact_request),
|
||||
@@ -87,21 +90,22 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
)
|
||||
}
|
||||
|
||||
fun notifyContactConnected(contact: Contact) {
|
||||
notifyMessageReceived(
|
||||
fun notifyContactConnected(user: User, contact: Contact) {
|
||||
displayNotification(
|
||||
user = user,
|
||||
chatId = contact.id,
|
||||
displayName = contact.displayName,
|
||||
msgText = generalGetString(R.string.notification_contact_connected)
|
||||
)
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
|
||||
if (!cInfo.ntfsEnabled) return
|
||||
|
||||
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(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)
|
||||
@@ -126,13 +130,14 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
.setVibrate(if (actions.isEmpty()) null else longArrayOf(0, 250, 250, 250))
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, user.userId, chatId))
|
||||
.setSilent(if (actions.isEmpty()) recentNotification else false)
|
||||
|
||||
for (action in actions) {
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val actionIntent = Intent(SimplexApp.context, NtfActionReceiver::class.java)
|
||||
actionIntent.action = action.name
|
||||
actionIntent.putExtra(UserIdKey, user.userId)
|
||||
actionIntent.putExtra(ChatIdKey, chatId)
|
||||
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
|
||||
val actionButton = when (action) {
|
||||
@@ -147,7 +152,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
.setGroup(MessageGroup)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setGroupSummary(true)
|
||||
.setContentIntent(chatPendingIntent(ShowChatsAction))
|
||||
.setContentIntent(chatPendingIntent(ShowChatsAction, null))
|
||||
.build()
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
@@ -182,9 +187,9 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, contactId, true))
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.user.userId, invitation.contact.id))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, invitation.user.userId, contactId))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, invitation.user.userId, contactId, true))
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setSound(soundUri)
|
||||
}
|
||||
@@ -241,12 +246,13 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
}
|
||||
|
||||
private fun chatPendingIntent(intentAction: String, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
||||
private fun chatPendingIntent(intentAction: String, userId: Long?, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
||||
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||
var intent = Intent(context, if (!broadcast) MainActivity::class.java else NtfActionReceiver::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.setAction(intentAction)
|
||||
.putExtra(UserIdKey, userId)
|
||||
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
|
||||
return if (!broadcast) {
|
||||
TaskStackBuilder.create(context).run {
|
||||
@@ -258,24 +264,46 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function creates notifications channels. On Android 13+ calling it for the first time will trigger system alert,
|
||||
* The alert asks a user to allow or disallow to show notifications for the app. That's why it should be called only when the user
|
||||
* already saw such alert or when you want to trigger showing the alert.
|
||||
* On the first app launch the channels will be created after user profile is created. Subsequent calls will create new channels and delete
|
||||
* old ones if needed
|
||||
* */
|
||||
fun createNtfChannelsMaybeShowAlert() {
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
|
||||
// Remove old channels since they can't be edited
|
||||
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
||||
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes every action specified by [NotificationCompat.Builder.addAction] that comes with [NotificationAction]
|
||||
* and [ChatInfo.id] as [ChatIdKey] in extra
|
||||
* */
|
||||
class NtfActionReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val userId = getUserIdFromIntent(intent)
|
||||
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
|
||||
val cInfo = SimplexApp.context.chatModel.getChat(chatId)?.chatInfo
|
||||
val m = SimplexApp.context.chatModel
|
||||
when (intent.action) {
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
|
||||
if (cInfo !is ChatInfo.ContactRequest) return
|
||||
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
|
||||
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
|
||||
val isCurrentUser = m.currentUser.value?.userId == userId
|
||||
val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
|
||||
(m.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
|
||||
acceptContactRequest(apiId, cInfo, isCurrentUser, m)
|
||||
m.controller.ntfManager.cancelNotificationsForChat(chatId)
|
||||
}
|
||||
RejectCallAction -> {
|
||||
val invitation = SimplexApp.context.chatModel.callInvitations[chatId]
|
||||
val invitation = m.callInvitations[chatId]
|
||||
if (invitation != null) {
|
||||
SimplexApp.context.chatModel.callManager.endCall(invitation = invitation)
|
||||
m.callManager.endCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 DarkColorPalette = darkColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.SystemClock
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
@@ -31,70 +23,18 @@ import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
|
||||
private val lastSuccessfulAuth: MutableState<Long?> = mutableStateOf(null)
|
||||
|
||||
@Composable
|
||||
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
||||
val lastSuccessfulAuth = remember { lastSuccessfulAuth }
|
||||
BackHandler(onBack = {
|
||||
lastSuccessfulAuth.value = null
|
||||
close()
|
||||
})
|
||||
val authorized = remember { !chatModel.controller.appPrefs.performLA.get() }
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(lastSuccessfulAuth.value) {
|
||||
if (!authorized && !authorizedPreviously(lastSuccessfulAuth)) {
|
||||
runAuth(lastSuccessfulAuth, context)
|
||||
}
|
||||
}
|
||||
if (authorized || authorizedPreviously(lastSuccessfulAuth)) {
|
||||
LaunchedEffect(Unit) {
|
||||
// Update auth each time user visits this screen in authenticated state just to prolong authorized time
|
||||
lastSuccessfulAuth.value = SystemClock.elapsedRealtime()
|
||||
}
|
||||
TerminalLayout(
|
||||
chatModel.terminalItems,
|
||||
remember { chatModel.terminalItems },
|
||||
composeState,
|
||||
sendCommand = { sendCommand(chatModel, composeState) },
|
||||
close
|
||||
)
|
||||
} else {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.background(MaterialTheme.colors.background)) {
|
||||
CloseSheetBar(close)
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.auth_unlock),
|
||||
icon = Icons.Outlined.Lock,
|
||||
click = {
|
||||
runAuth(lastSuccessfulAuth, context)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun authorizedPreviously(lastSuccessfulAuth: State<Long?>): Boolean =
|
||||
lastSuccessfulAuth.value?.let { SystemClock.elapsedRealtime() - it < 30_000 } ?: false
|
||||
|
||||
private fun runAuth(lastSuccessfulAuth: MutableState<Long?>, context: Context) {
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_open_chat_console),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
lastSuccessfulAuth.value = when (laResult) {
|
||||
LAResult.Success, LAResult.Unavailable -> SystemClock.elapsedRealtime()
|
||||
is LAResult.Error, LAResult.Failed -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendCommand(chatModel: ChatModel, composeState: MutableState<ComposeState>) {
|
||||
@@ -102,9 +42,9 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState<Compose
|
||||
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
|
||||
val s = composeState.value.message
|
||||
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
|
||||
val resp = CR.ChatCmdError(ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
|
||||
chatModel.terminalItems.add(TerminalItem.cmd(CC.Console(s)))
|
||||
chatModel.terminalItems.add(TerminalItem.resp(resp))
|
||||
val resp = CR.ChatCmdError(null, ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
|
||||
chatModel.addTerminalItem(TerminalItem.cmd(CC.Console(s)))
|
||||
chatModel.addTerminalItem(TerminalItem.resp(resp))
|
||||
composeState.value = ComposeState(useLinkPreviews = false)
|
||||
} else {
|
||||
withApi {
|
||||
@@ -143,6 +83,8 @@ fun TerminalLayout(
|
||||
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = sendCommand,
|
||||
sendLiveMessage = null,
|
||||
@@ -174,7 +116,8 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
}
|
||||
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
|
||||
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
|
||||
val context = LocalContext.current
|
||||
LazyColumn(state = listState, reverseLayout = true) {
|
||||
items(reversedTerminalItems) { item ->
|
||||
Text(
|
||||
@@ -185,7 +128,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
ModalManager.shared.showModal {
|
||||
ModalManager.shared.showModal(endButtons = { ShareButton { shareText(context, item.details) } }) {
|
||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexService
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Profile
|
||||
import chat.simplex.app.ui.theme.*
|
||||
@@ -38,7 +38,7 @@ fun isValidDisplayName(name: String) : Boolean {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateProfilePanel(chatModel: ChatModel) {
|
||||
fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||
val displayName = remember { mutableStateOf("") }
|
||||
val fullName = remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
@@ -72,10 +72,12 @@ fun CreateProfilePanel(chatModel: ChatModel) {
|
||||
ProfileNameField(fullName)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
SimpleButton(
|
||||
text = stringResource(R.string.about_simplex),
|
||||
icon = Icons.Outlined.ArrowBackIosNew
|
||||
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
|
||||
if (chatModel.users.isEmpty()) {
|
||||
SimpleButton(
|
||||
text = stringResource(R.string.about_simplex),
|
||||
icon = Icons.Outlined.ArrowBackIosNew
|
||||
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
|
||||
}
|
||||
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
|
||||
@@ -83,7 +85,7 @@ fun CreateProfilePanel(chatModel: ChatModel) {
|
||||
val createModifier: Modifier
|
||||
val createColor: Color
|
||||
if (enabled) {
|
||||
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value) }.padding(8.dp)
|
||||
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp)
|
||||
createColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
createModifier = Modifier.padding(8.dp)
|
||||
@@ -105,13 +107,23 @@ fun CreateProfilePanel(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
|
||||
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
|
||||
withApi {
|
||||
val user = chatModel.controller.apiCreateActiveUser(
|
||||
Profile(displayName, fullName, null)
|
||||
)
|
||||
chatModel.controller.startChat(user)
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
|
||||
) ?: return@withApi
|
||||
chatModel.currentUser.value = user
|
||||
if (chatModel.users.isEmpty()) {
|
||||
chatModel.controller.startChat(user)
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
|
||||
SimplexApp.context.chatModel.controller.ntfManager.createNtfChannelsMaybeShowAlert()
|
||||
} else {
|
||||
val users = chatModel.controller.listUsers()
|
||||
chatModel.users.clear()
|
||||
chatModel.users.addAll(users)
|
||||
chatModel.controller.getUserChatData()
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,10 +351,10 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
InfoText(call.callState.text)
|
||||
|
||||
val connInfo =
|
||||
if (call.connectionInfo == null) ""
|
||||
else " (${call.connectionInfo.text})"
|
||||
InfoText(call.encryptionStatus + connInfo)
|
||||
val connInfo = call.connectionInfo
|
||||
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
||||
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
|
||||
InfoText(call.encryptionStatus + connInfoText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,8 +534,12 @@ fun PreviewActiveCallOverlayVideo() {
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Video,
|
||||
peerMedia = CallMediaType.Video,
|
||||
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
||||
connectionInfo = ConnectionInfo(
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null),
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null)
|
||||
)
|
||||
),
|
||||
speakerCanBeEnabled = true,
|
||||
dismiss = {},
|
||||
toggleAudio = {},
|
||||
toggleVideo = {},
|
||||
@@ -501,8 +559,12 @@ fun PreviewActiveCallOverlayAudio() {
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Audio,
|
||||
peerMedia = CallMediaType.Audio,
|
||||
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
||||
connectionInfo = ConnectionInfo(
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null),
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null)
|
||||
)
|
||||
),
|
||||
speakerCanBeEnabled = true,
|
||||
dismiss = {},
|
||||
toggleAudio = {},
|
||||
toggleVideo = {},
|
||||
|
||||
@@ -126,6 +126,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
invitation,
|
||||
callOnLockScreen,
|
||||
chatModel,
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
@@ -135,6 +136,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
openApp = {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.setAction(OpenChatAction)
|
||||
.putExtra("userId", invitation.user.userId)
|
||||
.putExtra("chatId", invitation.contact.id)
|
||||
context.startActivity(intent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -149,6 +151,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
fun IncomingCallLockScreenAlertLayout(
|
||||
invitation: RcvCallInvitation,
|
||||
callOnLockScreen: CallOnLockScreen?,
|
||||
chatModel: ChatModel,
|
||||
rejectCall: () -> Unit,
|
||||
ignoreCall: () -> Unit,
|
||||
acceptCall: () -> Unit,
|
||||
@@ -160,7 +163,7 @@ fun IncomingCallLockScreenAlertLayout(
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
IncomingCallInfo(invitation)
|
||||
IncomingCallInfo(invitation, chatModel)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
|
||||
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
|
||||
@@ -217,12 +220,14 @@ fun PreviewIncomingCallLockScreenAlert() {
|
||||
.fillMaxSize()) {
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
invitation = RcvCallInvitation(
|
||||
user = User.sampleData,
|
||||
contact = Contact.sampleData,
|
||||
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||
sharedKey = null,
|
||||
callTs = Clock.System.now()
|
||||
),
|
||||
callOnLockScreen = null,
|
||||
chatModel = SimplexApp.context.chatModel,
|
||||
rejectCall = {},
|
||||
ignoreCall = {},
|
||||
acceptCall = {},
|
||||
|
||||
@@ -17,9 +17,10 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Contact
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.usersettings.ProfilePreview
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@@ -32,6 +33,7 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||
IncomingCallAlertLayout(
|
||||
invitation,
|
||||
chatModel,
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
@@ -44,13 +46,14 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
@Composable
|
||||
fun IncomingCallAlertLayout(
|
||||
invitation: RcvCallInvitation,
|
||||
chatModel: ChatModel,
|
||||
rejectCall: () -> Unit,
|
||||
ignoreCall: () -> Unit,
|
||||
acceptCall: () -> Unit
|
||||
) {
|
||||
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||
Column(Modifier.fillMaxWidth().background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
|
||||
IncomingCallInfo(invitation)
|
||||
IncomingCallInfo(invitation, chatModel)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -66,9 +69,13 @@ fun IncomingCallAlertLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallInfo(invitation: RcvCallInvitation) {
|
||||
fun IncomingCallInfo(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
|
||||
Row {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (chatModel.users.size > 1) {
|
||||
ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.secondary)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
|
||||
else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
@@ -101,11 +108,13 @@ fun PreviewIncomingCallAlertLayout() {
|
||||
SimpleXTheme {
|
||||
IncomingCallAlertLayout(
|
||||
invitation = RcvCallInvitation(
|
||||
user = User.sampleData,
|
||||
contact = Contact.sampleData,
|
||||
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||
sharedKey = null,
|
||||
callTs = Clock.System.now()
|
||||
),
|
||||
chatModel = SimplexApp.context.chatModel,
|
||||
rejectCall = {},
|
||||
ignoreCall = {},
|
||||
acceptCall = {}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import androidx.compose.ui.text.toUpperCase
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.Contact
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
data class Call(
|
||||
val contact: Contact,
|
||||
@@ -61,39 +65,39 @@ enum class CallState {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
|
||||
@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
|
||||
@Serializable data class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
|
||||
@Serializable data class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
|
||||
|
||||
@Serializable
|
||||
sealed class WCallCommand {
|
||||
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
|
||||
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||
@Serializable @SerialName("camera") class Camera(val camera: VideoCamera): WCallCommand()
|
||||
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
|
||||
@Serializable @SerialName("end") object End: WCallCommand()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class WCallResponse {
|
||||
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
|
||||
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
|
||||
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
|
||||
@Serializable @SerialName("connected") class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||
@Serializable @SerialName("capabilities") data class Capabilities(val capabilities: CallCapabilities): WCallResponse()
|
||||
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
|
||||
@Serializable @SerialName("answer") data class Answer(val answer: String, val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
|
||||
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
||||
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
||||
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
|
||||
@Serializable @SerialName("error") data class Error(val message: String): WCallResponse()
|
||||
}
|
||||
|
||||
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
|
||||
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
||||
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
|
||||
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
||||
@Serializable class RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
|
||||
@Serializable data class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
|
||||
@Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
||||
@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String)
|
||||
@Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
||||
@Serializable data class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
|
||||
val callTypeText: String get() = generalGetString(when(callType.media) {
|
||||
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||
@@ -103,19 +107,32 @@ sealed class WCallResponse {
|
||||
CallMediaType.Audio -> R.string.incoming_audio_call
|
||||
})
|
||||
}
|
||||
@Serializable class CallCapabilities(val encryption: Boolean)
|
||||
@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
||||
val text: String @Composable get() = when {
|
||||
localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
|
||||
stringResource(R.string.call_connection_peer_to_peer)
|
||||
localCandidate?.candidateType == RTCIceCandidateType.Relay && remoteCandidate?.candidateType == RTCIceCandidateType.Relay ->
|
||||
stringResource(R.string.call_connection_via_relay)
|
||||
else ->
|
||||
"${localCandidate?.candidateType?.value ?: "unknown"} / ${remoteCandidate?.candidateType?.value ?: "unknown"}"
|
||||
@Serializable data class CallCapabilities(val encryption: Boolean)
|
||||
@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
||||
val text: String @Composable get() {
|
||||
val local = localCandidate?.candidateType
|
||||
val remote = remoteCandidate?.candidateType
|
||||
return when {
|
||||
local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host ->
|
||||
stringResource(R.string.call_connection_peer_to_peer)
|
||||
local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay ->
|
||||
stringResource(R.string.call_connection_via_relay)
|
||||
else ->
|
||||
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
|
||||
}
|
||||
}
|
||||
|
||||
val protocolText: String get() {
|
||||
val local = localCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
|
||||
val localRelay = localCandidate?.relayProtocol?.uppercase(Locale.ROOT) ?: "unknown"
|
||||
val remote = remoteCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
|
||||
val localText = if (localRelay == local || localCandidate?.relayProtocol == null) local else "$local ($localRelay)"
|
||||
return if (local == remote) localText else "$localText / $remote"
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||
@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
|
||||
@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?, val relayProtocol: String?)
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
|
||||
@Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
|
||||
|
||||
@@ -150,7 +167,7 @@ enum class VideoCamera {
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ConnectionState(
|
||||
data class ConnectionState(
|
||||
val connectionState: String,
|
||||
val iceConnectionState: String,
|
||||
val iceGatheringState: String,
|
||||
@@ -158,20 +175,22 @@ class ConnectionState(
|
||||
)
|
||||
|
||||
// the servers are expected in this format:
|
||||
// stun:stun.simplex.im:443
|
||||
// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443
|
||||
// stun:stun.simplex.im:443?transport=tcp
|
||||
// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp
|
||||
fun parseRTCIceServer(str: String): RTCIceServer? {
|
||||
var s = replaceScheme(str, "stun:")
|
||||
s = replaceScheme(s, "turn:")
|
||||
s = replaceScheme(s, "turns:")
|
||||
val u = runCatching { URI(s) }.getOrNull()
|
||||
if (u != null) {
|
||||
val scheme = u.scheme
|
||||
val host = u.host
|
||||
val port = u.port
|
||||
if (u.path == "" && (scheme == "stun" || scheme == "turn")) {
|
||||
if (u.path == "" && (scheme == "stun" || scheme == "turn" || scheme == "turns")) {
|
||||
val userInfo = u.userInfo?.split(":")
|
||||
val query = if (u.query == null || u.query == "") "" else "?${u.query}"
|
||||
return RTCIceServer(
|
||||
urls = listOf("$scheme:$host:$port"),
|
||||
urls = listOf("$scheme:$host:$port$query"),
|
||||
username = userInfo?.getOrNull(0),
|
||||
credential = userInfo?.getOrNull(1)
|
||||
)
|
||||
|
||||
@@ -54,10 +54,14 @@ fun ChatInfoView(
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
if (chat != null) {
|
||||
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
|
||||
mutableStateOf(chatModel.contactNetworkStatus(contact))
|
||||
}
|
||||
ChatInfoLayout(
|
||||
chat,
|
||||
contact,
|
||||
connStats,
|
||||
contactNetworkStatus.value,
|
||||
customUserProfile,
|
||||
localAlias,
|
||||
connectionCode,
|
||||
@@ -149,6 +153,7 @@ fun ChatInfoLayout(
|
||||
chat: Chat,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
contactNetworkStatus: NetworkStatus,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
connectionCode: String?,
|
||||
@@ -200,9 +205,9 @@ fun ChatInfoLayout(
|
||||
SectionItemView({
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.network_status),
|
||||
chat.serverInfo.networkStatus.statusExplanation
|
||||
contactNetworkStatus.statusExplanation
|
||||
)}) {
|
||||
NetworkStatusRow(chat.serverInfo.networkStatus)
|
||||
NetworkStatusRow(contactNetworkStatus)
|
||||
}
|
||||
val rcvServers = connStats.rcvServers
|
||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||
@@ -314,7 +319,7 @@ fun LocalAliasEditor(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
||||
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -346,14 +351,14 @@ private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerImage(networkStatus: Chat.NetworkStatus) {
|
||||
private fun ServerImage(networkStatus: NetworkStatus) {
|
||||
Box(Modifier.size(18.dp)) {
|
||||
when (networkStatus) {
|
||||
is Chat.NetworkStatus.Connected ->
|
||||
is NetworkStatus.Connected ->
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
||||
is Chat.NetworkStatus.Disconnected ->
|
||||
is NetworkStatus.Disconnected ->
|
||||
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
|
||||
is Chat.NetworkStatus.Error ->
|
||||
is NetworkStatus.Error ->
|
||||
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
|
||||
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
|
||||
}
|
||||
@@ -446,14 +451,14 @@ fun PreviewChatInfoLayout() {
|
||||
ChatInfoLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = arrayListOf(),
|
||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
||||
chatItems = arrayListOf()
|
||||
),
|
||||
Contact.sampleData,
|
||||
localAlias = "",
|
||||
connectionCode = "123",
|
||||
developerTools = false,
|
||||
connStats = null,
|
||||
contactNetworkStatus = NetworkStatus.Connected(),
|
||||
onLocalAliasChanged = {},
|
||||
customUserProfile = null,
|
||||
openPreferences = {},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
@@ -56,7 +57,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val composeState = rememberSaveable(saver = ComposeState.saver()) {
|
||||
mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews))
|
||||
mutableStateOf(
|
||||
if (chatModel.draftChatId.value == chatId && chatModel.draft.value != null) {
|
||||
chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
}
|
||||
)
|
||||
}
|
||||
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
|
||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
@@ -127,6 +134,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
searchText,
|
||||
useLinkPreviews = useLinkPreviews,
|
||||
linkMode = chatModel.simplexLinkMode.value,
|
||||
allowVideoAttachment = chatModel.controller.appPrefs.xftpSendEnabled.get(),
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
back = {
|
||||
hideKeyboard(view)
|
||||
@@ -146,9 +154,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,24 +200,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 ->
|
||||
withApi { chatModel.controller.receiveFile(fileId) }
|
||||
withApi { chatModel.controller.receiveFile(user, fileId) }
|
||||
},
|
||||
cancelFile = { fileId ->
|
||||
withApi { chatModel.controller.cancelFile(user, fileId) }
|
||||
},
|
||||
joinGroup = { groupId ->
|
||||
withApi { chatModel.controller.apiJoinGroup(groupId) }
|
||||
@@ -277,6 +308,7 @@ fun ChatLayout(
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
allowVideoAttachment: Boolean,
|
||||
chatModelIncognito: Boolean,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
@@ -284,6 +316,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,
|
||||
@@ -307,6 +340,7 @@ fun ChatLayout(
|
||||
sheetContent = {
|
||||
ChooseAttachmentView(
|
||||
attachmentOption,
|
||||
allowVideoAttachment,
|
||||
hide = { scope.launch { attachmentBottomSheetState.hide() } }
|
||||
)
|
||||
},
|
||||
@@ -328,7 +362,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -501,6 +535,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,
|
||||
@@ -529,7 +564,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
|
||||
val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } }
|
||||
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
|
||||
val scrollToItem: (Long) -> Unit = { itemId: Long ->
|
||||
val index = reversedChatItems.indexOfFirst { it.id == itemId }
|
||||
@@ -547,6 +582,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(
|
||||
@@ -566,10 +606,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,11 +651,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
|
||||
@@ -624,7 +666,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,10 +707,18 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
|
||||
.distinctUntilChanged()
|
||||
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
|
||||
.collect {
|
||||
if (listState.firstVisibleItemIndex == 0) {
|
||||
listState.animateScrollToItem(0)
|
||||
} else {
|
||||
listState.animateScrollBy(scrollDistance)
|
||||
try {
|
||||
if (listState.firstVisibleItemIndex == 0) {
|
||||
listState.animateScrollToItem(0)
|
||||
} else {
|
||||
listState.animateScrollBy(scrollDistance)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
/**
|
||||
* When you tap and hold a finger on a lazy column with chatItems, and then you receive a message,
|
||||
* this coroutine will be canceled with the message "Current mutation had a higher priority" because of animatedScroll.
|
||||
* Which breaks auto-scrolling to bottom. So just ignoring the exception
|
||||
* */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -896,21 +946,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) {
|
||||
@@ -924,16 +979,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) {
|
||||
@@ -945,7 +1012,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) {
|
||||
@@ -1016,6 +1083,7 @@ fun PreviewChatLayout() {
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
allowVideoAttachment = true,
|
||||
chatModelIncognito = false,
|
||||
back = {},
|
||||
info = {},
|
||||
@@ -1023,6 +1091,7 @@ fun PreviewChatLayout() {
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
@@ -1075,6 +1144,7 @@ fun PreviewGroupChatLayout() {
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
allowVideoAttachment = true,
|
||||
chatModelIncognito = false,
|
||||
back = {},
|
||||
info = {},
|
||||
@@ -1082,6 +1152,7 @@ fun PreviewGroupChatLayout() {
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
@file:UseSerializers(UriSerializer::class)
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import ComposeVoiceView
|
||||
import ComposeFileView
|
||||
import ComposeVoiceView
|
||||
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.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
@@ -20,7 +20,8 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.Reply
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
@@ -32,29 +33,26 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.*
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
@Serializable
|
||||
sealed class ComposePreview {
|
||||
@Serializable object NoPreview: ComposePreview()
|
||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
||||
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
||||
@Serializable class ImagePreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
|
||||
@Serializable class VideoPreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
|
||||
@Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||
@Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -99,16 +97,21 @@ data class ComposeState(
|
||||
get() = {
|
||||
val hasContent = when (preview) {
|
||||
is ComposePreview.ImagePreview -> true
|
||||
is ComposePreview.VideoPreview -> true
|
||||
is ComposePreview.VoicePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty() || liveMessage != null
|
||||
}
|
||||
hasContent && !inProgress
|
||||
}
|
||||
val endLiveDisabled: Boolean
|
||||
get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
|
||||
|
||||
val linkPreviewAllowed: Boolean
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.ImagePreview -> false
|
||||
is ComposePreview.VideoPreview -> false
|
||||
is ComposePreview.VoicePreview -> false
|
||||
is ComposePreview.FilePreview -> false
|
||||
else -> useLinkPreviews
|
||||
@@ -130,6 +133,9 @@ data class ComposeState(
|
||||
}
|
||||
}
|
||||
|
||||
val empty: Boolean
|
||||
get() = message.isEmpty() && preview is ComposePreview.NoPreview
|
||||
|
||||
companion object {
|
||||
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
|
||||
save = { json.encodeToString(serializer(), it.value) },
|
||||
@@ -150,15 +156,15 @@ sealed class RecordingState {
|
||||
}
|
||||
|
||||
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
val fileName = chatItem.file?.fileName ?: ""
|
||||
return when (val mc = chatItem.content.msgContent) {
|
||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
||||
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true)
|
||||
is MsgContent.MCFile -> {
|
||||
val fileName = chatItem.file?.fileName ?: ""
|
||||
ComposePreview.FilePreview(fileName)
|
||||
}
|
||||
// TODO: include correct type
|
||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
|
||||
is MsgContent.MCVideo -> ComposePreview.VideoPreview(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
|
||||
}
|
||||
}
|
||||
@@ -177,23 +183,17 @@ fun ComposeView(
|
||||
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val xftpSendEnabled = chatModel.controller.appPrefs.xftpSendEnabled.get()
|
||||
val maxFileSize = getMaxFileSize(fileProtocol = if (xftpSendEnabled) FileProtocol.XFTP else FileProtocol.SMP)
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
// attachments
|
||||
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
||||
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>>(
|
||||
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
|
||||
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
|
||||
)
|
||||
val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) }
|
||||
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
chosenContent.value = listOf(UploadContent.SimpleImage(uri))
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview)))
|
||||
val bitmap: Bitmap? = getBitmapFromUri(uri)
|
||||
if (bitmap != null) {
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
|
||||
}
|
||||
}
|
||||
}
|
||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
@@ -207,28 +207,21 @@ fun ComposeView(
|
||||
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) {
|
||||
val drawable = getDrawableFromUri(uri)
|
||||
var bitmap: 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 <= MAX_FILE_SIZE) {
|
||||
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(MAX_FILE_SIZE))
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -240,29 +233,44 @@ fun ComposeView(
|
||||
}
|
||||
|
||||
if (imagesPreview.isNotEmpty()) {
|
||||
chosenContent.value = content
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview))
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview, content))
|
||||
}
|
||||
}
|
||||
val processPickedVideo = { uris: List<Uri>, text: String? ->
|
||||
val content = ArrayList<UploadContent>()
|
||||
val imagesPreview = ArrayList<String>()
|
||||
uris.forEach { uri ->
|
||||
val (bitmap: Bitmap?, durationMs: Long?) = getBitmapFromVideo(uri)
|
||||
content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0))
|
||||
if (bitmap != null) {
|
||||
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
|
||||
}
|
||||
}
|
||||
|
||||
if (imagesPreview.isNotEmpty()) {
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.VideoPreview(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) {
|
||||
chosenFile.value = uri
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName))
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri))
|
||||
}
|
||||
} 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()) { processPickedImage(it, null) }
|
||||
val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
||||
val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedVideo(it, null) }
|
||||
val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedVideo(it, null) }
|
||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
||||
|
||||
@@ -281,9 +289,17 @@ fun ComposeView(
|
||||
}
|
||||
AttachmentOption.PickImage -> {
|
||||
try {
|
||||
galleryLauncher.launch(0)
|
||||
galleryImageLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryLauncherFallback.launch("image/*")
|
||||
galleryImageLauncherFallback.launch("image/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.PickVideo -> {
|
||||
try {
|
||||
galleryVideoLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryVideoLauncherFallback.launch("video/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
@@ -351,10 +367,12 @@ fun ComposeView(
|
||||
}
|
||||
recState.value = RecordingState.NotStarted
|
||||
textStyle.value = smallFont
|
||||
chosenContent.value = emptyList()
|
||||
chosenAudio.value = null
|
||||
chosenFile.value = null
|
||||
chatModel.removeLiveChatItemDummy()
|
||||
chatModel.removeLiveDummy()
|
||||
}
|
||||
|
||||
fun deleteUnusedFiles() {
|
||||
chatModel.filesToDelete.forEach { it.delete() }
|
||||
chatModel.filesToDelete.clear()
|
||||
}
|
||||
|
||||
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
|
||||
@@ -402,6 +420,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)
|
||||
@@ -442,35 +461,46 @@ fun ComposeView(
|
||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
|
||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||
is ComposePreview.ImagePreview -> {
|
||||
chosenContent.value.forEachIndexed { index, it ->
|
||||
preview.content.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
else -> return@forEachIndexed
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
msgs.add(MsgContent.MCImage(if (chosenContent.value.lastIndex == index) msgText else "", preview.images[index]))
|
||||
msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.VideoPreview -> {
|
||||
preview.content.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.Video -> saveFileFromUri(context, it.uri)
|
||||
else -> return@forEachIndexed
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
msgs.add(MsgContent.MCVideo(if (preview.content.lastIndex == index) msgText else "", preview.images[index], it.duration))
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.VoicePreview -> {
|
||||
val chosenAudioVal = chosenAudio.value
|
||||
if (chosenAudioVal != null) {
|
||||
val file = chosenAudioVal.first.toFile().name
|
||||
files.add((file))
|
||||
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
|
||||
AudioPlayer.stop(chosenAudioVal.first.toFile().absolutePath)
|
||||
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", chosenAudioVal.second / 1000))
|
||||
val tmpFile = File(preview.voice)
|
||||
AudioPlayer.stop(tmpFile.absolutePath)
|
||||
val actualFile = File(getAppFilePath(SimplexApp.context, tmpFile.name.replaceAfter(RecorderNative.extension, "")))
|
||||
withContext(Dispatchers.IO) {
|
||||
Files.move(tmpFile.toPath(), actualFile.toPath())
|
||||
}
|
||||
files.add(actualFile.name)
|
||||
deleteUnusedFiles()
|
||||
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
val file = saveFileFromUri(context, chosenFileVal)
|
||||
if (file != null) {
|
||||
files.add((file))
|
||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
|
||||
}
|
||||
val file = saveFileFromUri(context, preview.uri)
|
||||
if (file != null) {
|
||||
files.add((file))
|
||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,7 +515,12 @@ fun ComposeView(
|
||||
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
|
||||
)
|
||||
}
|
||||
if (sent == null && chosenContent.value.isNotEmpty()) {
|
||||
if (sent == null &&
|
||||
(cs.preview is ComposePreview.ImagePreview ||
|
||||
cs.preview is ComposePreview.VideoPreview ||
|
||||
cs.preview is ComposePreview.FilePreview ||
|
||||
cs.preview is ComposePreview.VoicePreview)
|
||||
) {
|
||||
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
|
||||
}
|
||||
}
|
||||
@@ -514,7 +549,6 @@ fun ComposeView(
|
||||
|
||||
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
|
||||
val file = File(filePath)
|
||||
chosenAudio.value = file.toUri() to durationMs
|
||||
chatModel.filesToDelete.add(file)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
|
||||
}
|
||||
@@ -537,7 +571,6 @@ fun ComposeView(
|
||||
|
||||
fun cancelImages() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenContent.value = emptyList()
|
||||
}
|
||||
|
||||
fun cancelVoice() {
|
||||
@@ -549,12 +582,10 @@ fun ComposeView(
|
||||
AudioPlayer.stop(filePath)
|
||||
filePath?.let { File(it).delete() }
|
||||
}
|
||||
chosenAudio.value = null
|
||||
}
|
||||
|
||||
fun cancelFile() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenFile.value = null
|
||||
}
|
||||
|
||||
fun truncateToWords(s: String): String {
|
||||
@@ -572,16 +603,16 @@ fun ComposeView(
|
||||
}
|
||||
|
||||
suspend fun sendLiveMessage() {
|
||||
val typedMsg = composeState.value.message
|
||||
val sentMsg = truncateToWords(typedMsg)
|
||||
if (sentMsg.isNotEmpty() && (composeState.value.liveMessage == null || composeState.value.liveMessage?.sent == false)) {
|
||||
val ci = sendMessageAsync(sentMsg, live = true)
|
||||
val cs = composeState.value
|
||||
val typedMsg = cs.message
|
||||
if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) {
|
||||
val ci = sendMessageAsync(typedMsg, live = true)
|
||||
if (ci != null) {
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true))
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
|
||||
}
|
||||
} else if (composeState.value.liveMessage == null) {
|
||||
val cItem = chatModel.addLiveChatItemDummy((composeState.value.contextItem as? ComposeContextItem.QuotedItem)?.chatItem, chat.chatInfo)
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = sentMsg, sent = false))
|
||||
} else if (cs.liveMessage == null) {
|
||||
val cItem = chatModel.addLiveDummy(chat.chatInfo)
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = typedMsg, sent = false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +647,11 @@ fun ComposeView(
|
||||
::cancelImages,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
is ComposePreview.VideoPreview -> ComposeImageView(
|
||||
preview.images,
|
||||
::cancelImages,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
||||
preview.voice,
|
||||
preview.durationMs,
|
||||
@@ -657,6 +693,9 @@ fun ComposeView(
|
||||
chatModel.sharedContent.value = null
|
||||
}
|
||||
|
||||
val userCanSend = rememberUpdatedState(chat.userCanSend)
|
||||
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
|
||||
|
||||
Column {
|
||||
contextItemView()
|
||||
when {
|
||||
@@ -668,11 +707,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)
|
||||
@@ -680,7 +719,7 @@ fun ComposeView(
|
||||
}
|
||||
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
|
||||
LaunchedEffect(allowedVoiceByPrefs) {
|
||||
if (!allowedVoiceByPrefs && chosenAudio.value != null) {
|
||||
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
// Voice was disabled right when this user records it, just cancel it
|
||||
cancelVoice()
|
||||
}
|
||||
@@ -702,15 +741,19 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { composeState.value.contextItem }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (composeState.value.liveMessage?.sent == false) {
|
||||
chatModel.removeLiveChatItemDummy()
|
||||
chatModel.addLiveChatItemDummy((it as? ComposeContextItem.QuotedItem)?.chatItem, chat.chatInfo)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCurrentDraft() {
|
||||
if (chatModel.draftChatId.value == chat.id) {
|
||||
chatModel.draft.value = null
|
||||
chatModel.draftChatId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
|
||||
if (!chat.userCanSend) {
|
||||
clearCurrentDraft()
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
|
||||
val activity = LocalContext.current as Activity
|
||||
@@ -722,8 +765,19 @@ fun ComposeView(
|
||||
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
clearCurrentDraft()
|
||||
deleteUnusedFiles()
|
||||
} else if (!composeState.value.empty) {
|
||||
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
|
||||
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
|
||||
}
|
||||
chatModel.draft.value = composeState.value
|
||||
chatModel.draftChatId.value = chat.id
|
||||
} else {
|
||||
clearCurrentDraft()
|
||||
deleteUnusedFiles()
|
||||
}
|
||||
chatModel.removeLiveChatItemDummy()
|
||||
chatModel.removeLiveDummy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -737,6 +791,8 @@ fun ComposeView(
|
||||
needToAllowVoiceToContact,
|
||||
allowedVoiceByPrefs,
|
||||
allowVoiceToContact = ::allowVoiceToContact,
|
||||
userIsObserver = userIsObserver.value,
|
||||
userCanSend = userCanSend.value,
|
||||
sendMessage = {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
@@ -745,7 +801,7 @@ fun ComposeView(
|
||||
updateLiveMessage = ::updateLiveMessage,
|
||||
cancelLiveMessage = {
|
||||
composeState.value = composeState.value.copy(liveMessage = null)
|
||||
chatModel.removeLiveChatItemDummy()
|
||||
chatModel.removeLiveDummy()
|
||||
},
|
||||
onMessageChange = ::onMessageChange,
|
||||
textStyle = textStyle
|
||||
@@ -763,7 +819,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)
|
||||
@@ -788,3 +844,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()
|
||||
}
|
||||
|
||||
@@ -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,9 +63,11 @@ fun SendMsgView(
|
||||
liveMessageAlertShown: SharedPreference<Boolean>,
|
||||
needToAllowVoiceToContact: Boolean,
|
||||
allowedVoiceByPrefs: Boolean,
|
||||
userIsObserver: Boolean,
|
||||
userCanSend: Boolean,
|
||||
allowVoiceToContact: () -> Unit,
|
||||
sendMessage: () -> Unit,
|
||||
sendLiveMessage: ( suspend () -> Unit)? = null,
|
||||
sendLiveMessage: (suspend () -> Unit)? = null,
|
||||
updateLiveMessage: (suspend () -> Unit)? = null,
|
||||
cancelLiveMessage: (() -> Unit)? = null,
|
||||
onMessageChange: (String) -> Unit,
|
||||
@@ -70,13 +75,25 @@ 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.ImagePreview || cs.preview is ComposePreview.VideoPreview || 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)
|
||||
NativeKeyboard(composeState, textStyle, onMessageChange)
|
||||
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
||||
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)
|
||||
}
|
||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||
val sendButtonSize = remember { Animatable(36f) }
|
||||
@@ -95,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)
|
||||
}
|
||||
}
|
||||
@@ -109,9 +126,12 @@ fun SendMsgView(
|
||||
else ->
|
||||
RecordVoiceView(recState, stopRecOnNextClick)
|
||||
}
|
||||
if (sendLiveMessage != null && updateLiveMessage != null && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)) {
|
||||
if (sendLiveMessage != null
|
||||
&& updateLiveMessage != null
|
||||
&& (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)
|
||||
}
|
||||
@@ -125,14 +145,18 @@ fun SendMsgView(
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val cs = composeState.value
|
||||
val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled() && cs.message.isNotEmpty()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (composeState.value.liveMessage == null &&
|
||||
val disabled = !cs.sendEnabled() ||
|
||||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
|
||||
cs.endLiveDisabled
|
||||
if (cs.liveMessage == null &&
|
||||
cs.preview !is ComposePreview.VoicePreview && !cs.editing &&
|
||||
cs.contextItem is ComposeContextItem.NoContextItem &&
|
||||
sendLiveMessage != null && updateLiveMessage != null
|
||||
) {
|
||||
var showDropdown by rememberSaveable { mutableStateOf(false) }
|
||||
SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) { showDropdown = true }
|
||||
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown = true }
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropdown,
|
||||
@@ -149,7 +173,7 @@ fun SendMsgView(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled() && cs.message.isNotEmpty(), sendMessage)
|
||||
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +185,8 @@ fun SendMsgView(
|
||||
private fun NativeKeyboard(
|
||||
composeState: MutableState<ComposeState>,
|
||||
textStyle: MutableState<TextStyle>,
|
||||
showDeleteTextButton: MutableState<Boolean>,
|
||||
userIsObserver: Boolean,
|
||||
onMessageChange: (String) -> Unit
|
||||
) {
|
||||
val cs = composeState.value
|
||||
@@ -171,7 +197,6 @@ private fun NativeKeyboard(
|
||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(cs.contextItem) {
|
||||
if (cs.contextItem is ComposeContextItem.QuotedItem) {
|
||||
@@ -192,6 +217,7 @@ private fun NativeKeyboard(
|
||||
) {
|
||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||
}
|
||||
|
||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||
val connection = super.onCreateInputConnection(editorInfo)
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||
@@ -217,7 +243,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
|
||||
@@ -238,14 +274,32 @@ private fun NativeKeyboard(
|
||||
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||
showKeyboard = false
|
||||
}
|
||||
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(
|
||||
{ composeState.value = composeState.value.copy(message = "") },
|
||||
Modifier.align(Alignment.TopEnd).size(36.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Close, null, Modifier.padding(7.dp).size(36.dp), tint = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,8 +353,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),
|
||||
@@ -344,7 +398,6 @@ private fun LockToCurrentOrientationUntilDispose() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun StopRecordButton(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
@@ -395,9 +448,8 @@ private fun CancelLiveMessageButton(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendTextButton(
|
||||
private fun SendMsgButton(
|
||||
icon: ImageVector,
|
||||
backgroundColor: Color,
|
||||
sizeDp: Animatable<Float, AnimationVector1D>,
|
||||
alpha: Animatable<Float, AnimationVector1D>,
|
||||
enabled: Boolean,
|
||||
@@ -426,20 +478,20 @@ private fun SendTextButton(
|
||||
.padding(4.dp)
|
||||
.alpha(alpha.value)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor)
|
||||
.background(if (enabled) MaterialTheme.colors.primary else HighOrLowlight)
|
||||
.padding(3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
@@ -449,7 +501,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)
|
||||
@@ -550,6 +602,8 @@ fun PreviewSendMsgView() {
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
@@ -573,11 +627,13 @@ fun PreviewSendMsgViewEditing() {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||
showVoiceRecordIcon = false,
|
||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||
isDirectChat = true,
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
@@ -596,7 +652,7 @@ fun PreviewSendMsgViewEditing() {
|
||||
fun PreviewSendMsgViewInProgress() {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true, useLinkPreviews = true)
|
||||
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt", getAppFileUri("test.txt")), inProgress = true, useLinkPreviews = true)
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||
@@ -606,6 +662,8 @@ fun PreviewSendMsgViewInProgress() {
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
|
||||
@@ -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(
|
||||
@@ -427,14 +454,13 @@ fun PreviewGroupChatInfoLayout() {
|
||||
GroupChatInfoLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = arrayListOf(),
|
||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
||||
chatItems = arrayListOf()
|
||||
),
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
groupLink = null,
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, 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,23 +51,16 @@ 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) {
|
||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
||||
val newChat = c.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
|
||||
chatModel.addChat(newChat)
|
||||
if (chatModel.getContactChat(it) == null) {
|
||||
chatModel.addChat(c)
|
||||
}
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatId.value = newChat.id
|
||||
chatModel.chatItems.addAll(c.chatItems)
|
||||
chatModel.chatId.value = c.id
|
||||
closeAll()
|
||||
}
|
||||
}
|
||||
@@ -152,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,
|
||||
@@ -178,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()
|
||||
}
|
||||
@@ -366,8 +350,7 @@ fun PreviewGroupMemberInfoLayout() {
|
||||
developerTools = false,
|
||||
connectionCode = "123",
|
||||
getContactChat = { Chat.sampleData },
|
||||
knownDirectChat = {},
|
||||
newDirectChat = {},
|
||||
openDirectChat = {},
|
||||
removeMember = {},
|
||||
onRoleSelected = {},
|
||||
switchMemberAddress = {},
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,32 @@ 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.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)
|
||||
}
|
||||
} else {
|
||||
fileIcon()
|
||||
@@ -174,14 +211,14 @@ fun CIFileView(
|
||||
class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||
private val sentFile = ChatItem(
|
||||
chatDir = CIDirection.DirectSnd(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemEdited = true),
|
||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
|
||||
quotedItem = null,
|
||||
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
|
||||
)
|
||||
private val fileChatItemWtFile = ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), ),
|
||||
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
@@ -191,7 +228,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,18 @@ 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.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)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +134,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 +177,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,339 @@
|
||||
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.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)
|
||||
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
|
||||
@@ -25,6 +27,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.ComposeContextItem
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import 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 +43,7 @@ fun ChatItemView(
|
||||
linkMode: SimplexLinkMode,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
scrollToItem: (Long) -> Unit,
|
||||
@@ -54,6 +58,7 @@ fun ChatItemView(
|
||||
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
val live = composeState.value.liveMessage != null
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -90,6 +95,14 @@ 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(
|
||||
@@ -97,7 +110,7 @@ fun ChatItemView(
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
if (!cItem.meta.itemDeleted) {
|
||||
if (cItem.meta.itemDeleted == null && !live) {
|
||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
@@ -119,27 +132,33 @@ 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
|
||||
})
|
||||
}
|
||||
}
|
||||
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
|
||||
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
|
||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
if (cItem.meta.itemDeleted && revealed.value) {
|
||||
if (cItem.meta.itemDeleted != null && revealed.value) {
|
||||
ItemAction(
|
||||
stringResource(R.string.hide_verb),
|
||||
Icons.Outlined.VisibilityOff,
|
||||
@@ -149,7 +168,16 @@ fun ChatItemView(
|
||||
}
|
||||
)
|
||||
}
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancellable) {
|
||||
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,14 +188,16 @@ fun ChatItemView(
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.reveal_verb),
|
||||
Icons.Outlined.Visibility,
|
||||
onClick = {
|
||||
revealed.value = true
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -175,10 +205,10 @@ fun ChatItemView(
|
||||
@Composable
|
||||
fun ContentItem() {
|
||||
val mc = cItem.content.msgContent
|
||||
if (cItem.meta.itemDeleted && !revealed.value) {
|
||||
if (cItem.meta.itemDeleted != null && !revealed.value) {
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
MarkedDeletedItemDropdownMenu()
|
||||
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted && !cItem.meta.isLive) {
|
||||
} else if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
|
||||
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem, cInfo.timedMessagesTTL)
|
||||
MsgContentItemDropdownMenu()
|
||||
@@ -235,12 +265,31 @@ 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 -> 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
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.cancel_verb),
|
||||
Icons.Outlined.Close,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
cancelFileAlertDialog(fileId, cancelFile = cancelFile)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItemAction(
|
||||
cItem: ChatItem,
|
||||
@@ -259,6 +308,24 @@ fun DeleteItemAction(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
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 = MaterialTheme.colors.onBackground) {
|
||||
DropdownMenuItem(onClick) {
|
||||
@@ -276,6 +343,18 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.cancel_file__question),
|
||||
text = generalGetString(R.string.file_transfer_will_be_cancelled_warning),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
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),
|
||||
@@ -303,6 +382,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),
|
||||
@@ -324,6 +415,7 @@ fun PreviewChatItemView() {
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
@@ -344,6 +436,7 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
|
||||
@@ -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
|
||||
@@ -19,9 +18,10 @@ import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
val sent = ci.chatDir.sent
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = ReceivedColorLight,
|
||||
color = if (sent) SentColorLight else ReceivedColorLight,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
|
||||
@@ -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
|
||||
@@ -28,6 +30,7 @@ import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.min
|
||||
|
||||
val SentColorLight = Color(0x1E45B8FF)
|
||||
val ReceivedColorLight = Color(0x20B1B0B5)
|
||||
@@ -74,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) {
|
||||
@@ -95,6 +95,8 @@ fun FramedItemView(
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -123,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)
|
||||
@@ -149,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))
|
||||
@@ -164,8 +179,12 @@ fun FramedItemView(
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||
if (ci.meta.itemDeleted) {
|
||||
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete)
|
||||
if (ci.meta.itemDeleted != null) {
|
||||
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)
|
||||
}
|
||||
@@ -192,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 != "") {
|
||||
@@ -241,6 +268,12 @@ fun CIMarkdownText(
|
||||
}
|
||||
|
||||
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
|
||||
/**
|
||||
* Equal to [androidx.compose.ui.unit.Constraints.MaxFocusMask], which is 0x3FFFF - 1
|
||||
* Other values make a crash `java.lang.IllegalArgumentException: Can't represent a width of 123456 and height of 9909 in Constraints`
|
||||
* See [androidx.compose.ui.unit.Constraints.createConstraints]
|
||||
* */
|
||||
const val MAX_SAFE_WIDTH = 0x3FFFF - 1
|
||||
|
||||
@Composable
|
||||
fun PriorityLayout(
|
||||
@@ -248,6 +281,17 @@ fun PriorityLayout(
|
||||
priorityLayoutId: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
/**
|
||||
* Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints]
|
||||
* */
|
||||
fun maxSafeHeight(width: Int) = when { // width bits + height bits should be <= 31
|
||||
width < 0x1FFF /*MaxNonFocusMask*/ -> 0x3FFFF - 1 /* MaxFocusMask */ // 13 bits width + 18 bits height
|
||||
width < 0x7FFF /*MinNonFocusMask*/ -> 0xFFFF - 1 /* MinFocusMask */ // 15 bits width + 16 bits height
|
||||
width < 0xFFFF /*MinFocusMask*/ -> 0x7FFF - 1 /* MinFocusMask */ // 16 bits width + 15 bits height
|
||||
width < 0x3FFFF /*MaxFocusMask*/ -> 0x1FFF - 1 /* MaxNonFocusMask */ // 18 bits width + 13 bits height
|
||||
else -> 0x1FFF // shouldn't happen since width is limited already
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
@@ -259,9 +303,11 @@ fun PriorityLayout(
|
||||
if (it.layoutId == priorityLayoutId)
|
||||
imagePlaceable!!
|
||||
else
|
||||
it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: constraints.maxWidth)) }
|
||||
// Limit width for every other element to width of important element and height for a sum of all elements
|
||||
layout(imagePlaceable?.measuredWidth ?: placeables.maxOf { it.width }, placeables.sumOf { it.height }) {
|
||||
it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: min(MAX_SAFE_WIDTH, constraints.maxWidth))) }
|
||||
// Limit width for every other element to width of important element and height for a sum of all elements.
|
||||
val width = imagePlaceable?.measuredWidth ?: min(MAX_SAFE_WIDTH, placeables.maxOf { it.width })
|
||||
val height = minOf(maxSafeHeight(width), placeables.sumOf { it.height })
|
||||
layout(width, height) {
|
||||
var y = 0
|
||||
placeables.forEach {
|
||||
it.place(0, y)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +9,12 @@ 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
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIDeleted
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
@@ -29,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,
|
||||
@@ -51,7 +65,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Bool
|
||||
fun PreviewMarkedDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getSampleData(itemDeleted = true),
|
||||
ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted()),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
|
||||
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
text: String,
|
||||
text: CharSequence,
|
||||
formattedText: List<FormattedText>? = null,
|
||||
sender: String? = null,
|
||||
meta: CIMeta? = null,
|
||||
@@ -78,6 +78,7 @@ fun MarkdownText (
|
||||
senderBold: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
linkMode: SimplexLinkMode,
|
||||
inlineContent: Map<String, InlineTextContent>? = null,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
val textLayoutDirection = remember (text) {
|
||||
@@ -132,13 +133,14 @@ fun MarkdownText (
|
||||
if (formattedText == null) {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
append(text)
|
||||
if (text is String) append(text)
|
||||
else if (text is AnnotatedString) append(text)
|
||||
if (meta?.isLive == true) {
|
||||
append(typingIndicator(meta.recent, typingIdx))
|
||||
}
|
||||
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
}
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent ?: mapOf())
|
||||
} else {
|
||||
var hasLinks = false
|
||||
val annotatedText = buildAnnotatedString {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -44,17 +44,19 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
delay(500L)
|
||||
}
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
is ChatInfo.Direct -> {
|
||||
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
|
||||
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
stopped
|
||||
)
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
|
||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
@@ -299,7 +301,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo
|
||||
if (chatModel.incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.Check,
|
||||
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
|
||||
onClick = {
|
||||
acceptContactRequest(chatInfo, chatModel)
|
||||
acceptContactRequest(chatInfo.apiId, chatInfo, true, chatModel)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
@@ -407,16 +409,16 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel
|
||||
title = generalGetString(R.string.accept_connection_request__question),
|
||||
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
||||
confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button),
|
||||
onConfirm = { acceptContactRequest(contactRequest, chatModel) },
|
||||
onConfirm = { acceptContactRequest(contactRequest.apiId, contactRequest, true, chatModel) },
|
||||
dismissText = generalGetString(R.string.reject_contact_button),
|
||||
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
|
||||
)
|
||||
}
|
||||
|
||||
fun acceptContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||
fun acceptContactRequest(apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
|
||||
withApi {
|
||||
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
|
||||
if (contact != null) {
|
||||
val contact = chatModel.controller.apiAcceptContactRequest(apiId)
|
||||
if (contact != null && isCurrentUser && contactRequest != null) {
|
||||
val chat = Chat(ChatInfo.Direct(contact), listOf())
|
||||
chatModel.replaceChat(contactRequest.id, chat)
|
||||
}
|
||||
@@ -625,8 +627,11 @@ fun PreviewChatListNavLinkDirect() {
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
stopped = false,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION
|
||||
)
|
||||
@@ -663,8 +668,11 @@ fun PreviewChatListNavLinkGroup() {
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
stopped = false,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -13,16 +14,15 @@ 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.Path
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.connectIfOpenedViaUri
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
@@ -37,13 +37,14 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
||||
val newChatSheetState by rememberSaveable(stateSaver = NewChatSheetState.saver()) { mutableStateOf(MutableStateFlow(NewChatSheetState.GONE)) }
|
||||
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||
val showNewChatSheet = {
|
||||
newChatSheetState.value = NewChatSheetState.VISIBLE
|
||||
newChatSheetState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
|
||||
if (animated) newChatSheetState.value = NewChatSheetState.HIDING
|
||||
else newChatSheetState.value = NewChatSheetState.GONE
|
||||
if (animated) newChatSheetState.value = AnimatedViewState.HIDING
|
||||
else newChatSheetState.value = AnimatedViewState.GONE
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (shouldShowWhatsNew(chatModel)) {
|
||||
@@ -63,8 +64,10 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
}
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
||||
Scaffold(
|
||||
topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, stopped) { searchInList = it.trim() } },
|
||||
topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } },
|
||||
scaffoldState = scaffoldState,
|
||||
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
||||
floatingActionButton = {
|
||||
@@ -97,7 +100,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
) {
|
||||
if (chatModel.chats.isNotEmpty()) {
|
||||
ChatList(chatModel, search = searchInList)
|
||||
} else {
|
||||
} else if (!switchingUsers.value) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||
OnboardingButtons(showNewChatSheet)
|
||||
@@ -111,6 +114,17 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
if (searchInList.isEmpty()) {
|
||||
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
||||
}
|
||||
UserPicker(chatModel, userPickerState, switchingUsers) {
|
||||
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
|
||||
}
|
||||
if (switchingUsers.value) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -118,7 +132,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)
|
||||
@@ -156,7 +170,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||
private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
||||
if (showSearch) {
|
||||
@@ -189,10 +203,23 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop
|
||||
val scope = rememberCoroutineScope()
|
||||
DefaultTopAppBar(
|
||||
navigationButton = {
|
||||
if (showSearch)
|
||||
if (showSearch) {
|
||||
NavigationButtonBack(hideSearchOnBack)
|
||||
else
|
||||
} else if (chatModel.users.isEmpty()) {
|
||||
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
|
||||
} else {
|
||||
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||
val allRead = users
|
||||
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||
.all { u -> u.unreadCount == 0 }
|
||||
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||
if (users.size == 1) {
|
||||
scope.launch { drawerState.open() }
|
||||
} else {
|
||||
userPickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -219,6 +246,47 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop
|
||||
Divider(Modifier.padding(top = AppBarHeight))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
|
||||
IconButton(onClick = onButtonClicked) {
|
||||
Box {
|
||||
ProfileImage(
|
||||
image = image,
|
||||
size = 37.dp
|
||||
)
|
||||
if (!allRead) {
|
||||
unreadBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.unreadBadge(text: String? = "") {
|
||||
Text(
|
||||
text ?: "",
|
||||
color = MaterialTheme.colors.onPrimary,
|
||||
fontSize = 6.sp,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.badgeLayout()
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
|
||||
private var lazyListState = 0 to 0
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -4,16 +4,20 @@ import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -21,11 +25,22 @@ import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.ComposePreview
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.chat.item.MarkdownText
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean, linkMode: SimplexLinkMode) {
|
||||
fun ChatPreviewView(
|
||||
chat: Chat,
|
||||
chatModelDraft: ComposeState?,
|
||||
chatModelDraftChatId: ChatId?,
|
||||
chatModelIncognito: Boolean,
|
||||
currentUserProfileDisplayName: String?,
|
||||
contactNetworkStatus: NetworkStatus?,
|
||||
stopped: Boolean,
|
||||
linkMode: SimplexLinkMode
|
||||
) {
|
||||
val cInfo = chat.chatInfo
|
||||
|
||||
@Composable
|
||||
@@ -67,6 +82,43 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
|
||||
}
|
||||
|
||||
fun messageDraft(draft: ComposeState): Pair<AnnotatedString, Map<String, InlineTextContent>> {
|
||||
fun attachment(): Pair<ImageVector, String?>? =
|
||||
when (draft.preview) {
|
||||
is ComposePreview.FilePreview -> Icons.Filled.InsertDriveFile to draft.preview.fileName
|
||||
is ComposePreview.ImagePreview -> Icons.Outlined.Image to null
|
||||
is ComposePreview.VoicePreview -> Icons.Filled.PlayArrow to durationText(draft.preview.durationMs / 1000)
|
||||
else -> null
|
||||
}
|
||||
|
||||
val attachment = attachment()
|
||||
val text = buildAnnotatedString {
|
||||
appendInlineContent(id = "editIcon")
|
||||
append(" ")
|
||||
if (attachment != null) {
|
||||
appendInlineContent(id = "attachmentIcon")
|
||||
if (attachment.second != null) {
|
||||
append(attachment.second as String)
|
||||
}
|
||||
append(" ")
|
||||
}
|
||||
append(draft.message)
|
||||
}
|
||||
val inlineContent: Map<String, InlineTextContent> = mapOf(
|
||||
"editIcon" to InlineTextContent(
|
||||
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Icon(Icons.Outlined.EditNote, null, tint = MaterialTheme.colors.primary)
|
||||
},
|
||||
"attachmentIcon" to InlineTextContent(
|
||||
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Icon(attachment?.first ?: Icons.Outlined.EditNote, null, tint = HighOrLowlight)
|
||||
}
|
||||
)
|
||||
return text to inlineContent
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewTitle() {
|
||||
when (cInfo) {
|
||||
@@ -91,15 +143,30 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
||||
fun chatPreviewText(chatModelIncognito: Boolean) {
|
||||
val ci = chat.chatItems.lastOrNull()
|
||||
if (ci != null) {
|
||||
val (text: CharSequence, inlineTextContent) = when {
|
||||
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
|
||||
ci.meta.itemDeleted == null -> ci.text to null
|
||||
else -> generalGetString(R.string.marked_deleted_description) to null
|
||||
}
|
||||
val formattedText = when {
|
||||
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
|
||||
ci.meta.itemDeleted == null -> ci.formattedText
|
||||
else -> null
|
||||
}
|
||||
MarkdownText(
|
||||
if (!ci.meta.itemDeleted) ci.text else generalGetString(R.string.marked_deleted_description),
|
||||
if (!ci.meta.itemDeleted) ci.formattedText else null,
|
||||
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
|
||||
text,
|
||||
formattedText,
|
||||
sender = when {
|
||||
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
|
||||
cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName
|
||||
else -> null
|
||||
},
|
||||
linkMode = linkMode,
|
||||
senderBold = true,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||
inlineContent = inlineTextContent,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
@@ -187,7 +254,7 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
||||
Modifier.padding(top = 52.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ChatStatusImage(chat)
|
||||
ChatStatusImage(contactNetworkStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,10 +277,9 @@ fun unreadCountStr(n: Int): String {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatStatusImage(chat: Chat) {
|
||||
val s = chat.serverInfo.networkStatus
|
||||
val descr = s.statusString
|
||||
if (s is Chat.NetworkStatus.Error) {
|
||||
fun ChatStatusImage(s: NetworkStatus?) {
|
||||
val descr = s?.statusString
|
||||
if (s is NetworkStatus.Error) {
|
||||
Icon(
|
||||
Icons.Outlined.ErrorOutline,
|
||||
contentDescription = descr,
|
||||
@@ -221,7 +287,7 @@ fun ChatStatusImage(chat: Chat) {
|
||||
modifier = Modifier
|
||||
.size(19.dp)
|
||||
)
|
||||
} else if (s !is Chat.NetworkStatus.Connected) {
|
||||
} else if (s !is NetworkStatus.Connected) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
@@ -241,6 +307,6 @@ fun ChatStatusImage(chat: Chat) {
|
||||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
ChatPreviewView(Chat.sampleData, false, "", stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
|
||||
ChatPreviewView(Chat.sampleData, null, null, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +108,7 @@ 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(
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import SectionItemViewSpaceBetween
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun UserPicker(
|
||||
chatModel: ChatModel,
|
||||
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
switchingUsers: MutableState<Boolean>,
|
||||
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
|
||||
.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 {
|
||||
userPickerState.collect {
|
||||
newChat = it
|
||||
launch {
|
||||
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
|
||||
if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { newChat.isVisible() }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect {
|
||||
try {
|
||||
val updatedUsers = chatModel.controller.listUsers().sortedByDescending { it.user.activeUser }
|
||||
var same = users.size == updatedUsers.size
|
||||
if (same) {
|
||||
for (i in 0 until minOf(users.size, updatedUsers.size)) {
|
||||
val prev = updatedUsers[i].user
|
||||
val next = users[i].user
|
||||
if (prev.userId != next.userId || prev.activeUser != next.activeUser || prev.chatViewName != next.chatViewName || prev.image != next.image) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!same) {
|
||||
chatModel.users.clear()
|
||||
chatModel.users.addAll(updatedUsers)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error updating users ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
|
||||
val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density }
|
||||
Box(Modifier
|
||||
.fillMaxSize()
|
||||
.offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else xOffset, 0) }
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
|
||||
.padding(bottom = 10.dp, top = 10.dp)
|
||||
.graphicsLayer {
|
||||
alpha = animatedFloat.value
|
||||
translationY = (animatedFloat.value - 1) * xOffset
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.widthIn(min = 220.dp)
|
||||
.width(IntrinsicSize.Min)
|
||||
.height(IntrinsicSize.Min)
|
||||
.shadow(8.dp, MaterialTheme.shapes.medium, clip = false)
|
||||
.background(if (isInDarkTheme()) MaterialTheme.colors.background.darker(-0.7f) else MaterialTheme.colors.background, MaterialTheme.shapes.medium)
|
||||
) {
|
||||
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||
users.forEach { u ->
|
||||
UserProfilePickerItem(u.user, u.unreadCount, openSettings = {
|
||||
settingsClicked()
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
if (!u.user.activeUser) {
|
||||
scope.launch {
|
||||
val job = launch {
|
||||
delay(500)
|
||||
switchingUsers.value = true
|
||||
}
|
||||
chatModel.controller.changeActiveUser(u.user.userId, null)
|
||||
job.cancel()
|
||||
switchingUsers.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
|
||||
}
|
||||
}
|
||||
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) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = 46.dp)
|
||||
.combinedClickable(
|
||||
onClick = if (u.activeUser) openSettings else onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = if (!u.activeUser) LocalIndication.current else null
|
||||
)
|
||||
.padding(PaddingValues(start = 8.dp, end = DEFAULT_PADDING)),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserProfileRow(u)
|
||||
if (u.activeUser) {
|
||||
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 = Color.White,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.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),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
}
|
||||
} else if (!u.showNtfs) {
|
||||
Icon(Icons.Outlined.NotificationsOff, null, Modifier.size(20.dp), tint = HighOrLowlight)
|
||||
} else {
|
||||
Box(Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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),
|
||||
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||
SectionItemViewSpaceBetween(onClick, minHeight = 68.dp) {
|
||||
val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
)
|
||||
Icon(Icons.Outlined.Settings, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CancelPickerItem(onClick: () -> Unit) {
|
||||
SectionItemViewSpaceBetween(onClick, minHeight = 68.dp) {
|
||||
val text = generalGetString(R.string.cancel_verb)
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
)
|
||||
Icon(Icons.Outlined.Close, 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
|
||||
|
||||
@@ -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: MutableState<String>? = 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/
|
||||
|
||||
@@ -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
|
||||
@@ -39,24 +42,39 @@ fun DatabaseErrorView(
|
||||
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
|
||||
val context = LocalContext.current
|
||||
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
|
||||
val saveAndRunChatOnClick: () -> Unit = {
|
||||
|
||||
fun callRunChat(confirmMigrations: MigrationConfirmation? = null) {
|
||||
val useKey = if (useKeychain) null else dbKey.value
|
||||
runChat(useKey, confirmMigrations, chatDbStatus, progressIndicator, appPreferences)
|
||||
}
|
||||
|
||||
fun saveAndRunChatOnClick() {
|
||||
DatabaseUtils.setDatabaseKey(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
|
||||
@@ -27,6 +26,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
@@ -38,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.*
|
||||
@@ -87,6 +89,8 @@ fun DatabaseView(
|
||||
m.controller.appPrefs.privacyFullBackup,
|
||||
appFilesCountAndSize,
|
||||
chatItemTTL,
|
||||
m.currentUser.value,
|
||||
m.users,
|
||||
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
|
||||
stopChatAlert = { stopChatAlert(m, runChat, context) },
|
||||
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
|
||||
@@ -136,6 +140,8 @@ fun DatabaseLayout(
|
||||
privacyFullBackup: SharedPreference<Boolean>,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
chatItemTTL: MutableState<ChatItemTTL>,
|
||||
currentUser: User?,
|
||||
users: List<UserInfo>,
|
||||
startChat: () -> Unit,
|
||||
stopChatAlert: () -> Unit,
|
||||
exportArchive: () -> Unit,
|
||||
@@ -148,10 +154,27 @@ fun DatabaseLayout(
|
||||
val operationsDisabled = !stopped || progressIndicator
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
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) }
|
||||
}
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(R.string.messages_section_description) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(currentUser?.displayName ?: "")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
}
|
||||
)
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.run_chat_section)) {
|
||||
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
|
||||
}
|
||||
@@ -167,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,
|
||||
@@ -224,16 +247,14 @@ fun DatabaseLayout(
|
||||
)
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.data_section)) {
|
||||
SectionItemView { TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!progressIndicator && !chatDbChanged), onChatItemTTLSelected) }
|
||||
SectionDivider()
|
||||
SectionView(stringResource(R.string.files_and_media_section).uppercase()) {
|
||||
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
|
||||
SectionItemView(
|
||||
deleteAppFilesAndMedia,
|
||||
disabled = deleteFilesDisabled
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.delete_files_and_media),
|
||||
stringResource(if (users.size > 1) R.string.delete_files_and_media_for_all_users else R.string.delete_files_and_media_all),
|
||||
color = if (deleteFilesDisabled) HighOrLowlight else Color.Red
|
||||
)
|
||||
}
|
||||
@@ -249,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>,
|
||||
@@ -569,7 +620,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")
|
||||
@@ -696,6 +747,8 @@ fun PreviewDatabaseLayout() {
|
||||
privacyFullBackup = SharedPreference({ true }, {}),
|
||||
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
|
||||
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
|
||||
currentUser = User.sampleData,
|
||||
users = listOf(UserInfo.sampleData),
|
||||
startChat = {},
|
||||
stopChatAlert = {},
|
||||
exportArchive = {},
|
||||
|
||||
@@ -8,11 +8,13 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
class AlertManager {
|
||||
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
|
||||
@@ -44,15 +46,25 @@ class AlertManager {
|
||||
|
||||
fun showAlertDialogButtonsColumn(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
text: AnnotatedString? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
showAlert {
|
||||
Dialog(onDismissRequest = this::hideAlert) {
|
||||
Column(Modifier.background(MaterialTheme.colors.background)) {
|
||||
Text(title, Modifier.padding(DEFAULT_PADDING), fontSize = 18.sp)
|
||||
Column(Modifier.background(MaterialTheme.colors.background, MaterialTheme.shapes.medium)) {
|
||||
Text(title,
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING, bottom = if (text == null) DEFAULT_PADDING else DEFAULT_PADDING_HALF),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (text != null) {
|
||||
Text(text)
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||
Text(
|
||||
text,
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||
buttons()
|
||||
|
||||
@@ -15,12 +15,14 @@ import chat.simplex.app.views.newchat.ActionButton
|
||||
sealed class AttachmentOption {
|
||||
object TakePhoto: AttachmentOption()
|
||||
object PickImage: AttachmentOption()
|
||||
object PickVideo: AttachmentOption()
|
||||
object PickFile: AttachmentOption()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseAttachmentView(
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
allowVideoAttachment: Boolean,
|
||||
hide: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
@@ -45,6 +47,12 @@ fun ChooseAttachmentView(
|
||||
attachmentOption.value = AttachmentOption.PickImage
|
||||
hide()
|
||||
}
|
||||
if (allowVideoAttachment) {
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Videocam) {
|
||||
attachmentOption.value = AttachmentOption.PickVideo
|
||||
hide()
|
||||
}
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
||||
attachmentOption.value = AttachmentOption.PickFile
|
||||
hide()
|
||||
|
||||
@@ -4,6 +4,7 @@ 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.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -11,7 +12,7 @@ import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CloseSheetBar(close: () -> Unit) {
|
||||
fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit = {}) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -20,9 +21,19 @@ fun CloseSheetBar(close: () -> Unit) {
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.width(TitleInsetWithIcon - AppBarHorizontalPadding)
|
||||
.padding(top = 4.dp), // Like in DefaultAppBar
|
||||
content = { NavigationButtonBack(close) }
|
||||
content = {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(TextFieldDefaults.MinHeight),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
NavigationButtonBack(close)
|
||||
Row {
|
||||
endButtons()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,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()
|
||||
}
|
||||
@@ -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,
|
||||
@@ -53,6 +53,15 @@ fun NavigationButtonBack(onButtonClicked: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShareButton(onButtonClicked: () -> Unit) {
|
||||
IconButton(onButtonClicked) {
|
||||
Icon(
|
||||
Icons.Outlined.Share, stringResource(R.string.share_verb), tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
|
||||
IconButton(onClick = onButtonClicked) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
@file:UseSerializers(UriSerializer::class)
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.descriptors.*
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
sealed class SharedContent {
|
||||
data class Text(val text: String): SharedContent()
|
||||
@@ -11,7 +15,7 @@ sealed class SharedContent {
|
||||
data class File(val text: String, val uri: Uri): SharedContent()
|
||||
}
|
||||
|
||||
enum class NewChatSheetState {
|
||||
enum class AnimatedViewState {
|
||||
VISIBLE, HIDING, GONE;
|
||||
fun isVisible(): Boolean {
|
||||
return this == VISIBLE
|
||||
@@ -23,7 +27,7 @@ enum class NewChatSheetState {
|
||||
return this == GONE
|
||||
}
|
||||
companion object {
|
||||
fun saver(): Saver<MutableStateFlow<NewChatSheetState>, *> = Saver(
|
||||
fun saver(): Saver<MutableStateFlow<AnimatedViewState>, *> = Saver(
|
||||
save = { it.value.toString() },
|
||||
restore = {
|
||||
MutableStateFlow(valueOf(it))
|
||||
@@ -32,7 +36,17 @@ enum class NewChatSheetState {
|
||||
}
|
||||
}
|
||||
|
||||
sealed class UploadContent {
|
||||
data class SimpleImage(val uri: Uri): UploadContent()
|
||||
data class AnimatedImage(val uri: Uri): UploadContent()
|
||||
|
||||
@Serializer(forClass = Uri::class)
|
||||
object UriSerializer : KSerializer<Uri> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING)
|
||||
override fun serialize(encoder: Encoder, value: Uri) = encoder.encodeString(value.toString())
|
||||
override fun deserialize(decoder: Decoder): Uri = Uri.parse(decoder.decodeString())
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class UploadContent {
|
||||
@Serializable data class SimpleImage(val uri: Uri): UploadContent()
|
||||
@Serializable data class AnimatedImage(val uri: Uri): UploadContent()
|
||||
@Serializable data class Video(val uri: Uri, val duration: Int): UploadContent()
|
||||
}
|
||||
|
||||
@@ -113,7 +113,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!!)
|
||||
@@ -204,22 +204,16 @@ fun GetImageBottomSheet(
|
||||
val context = LocalContext.current
|
||||
val processPickedImage = { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
imageBitmap.value = uri
|
||||
onImageChange(bitmap)
|
||||
}
|
||||
}
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { processPickedImage(it) }
|
||||
val galleryLauncherFallback = rememberGetContentLauncher { processPickedImage(it) }
|
||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
imageBitmap.value = uri
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
onImageChange(bitmap)
|
||||
val bitmap = getBitmapFromUri(uri)
|
||||
if (bitmap != null) {
|
||||
imageBitmap.value = uri
|
||||
onImageChange(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery(), processPickedImage)
|
||||
val galleryLauncherFallback = rememberGetContentLauncher(processPickedImage)
|
||||
val cameraLauncher = rememberCameraLauncher(processPickedImage)
|
||||
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
cameraLauncher.launchWithFallback()
|
||||
|
||||
@@ -20,12 +20,13 @@ fun ModalView(
|
||||
close: () -> Unit,
|
||||
background: Color = MaterialTheme.colors.background,
|
||||
modifier: Modifier = Modifier,
|
||||
endButtons: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.background(background)) {
|
||||
CloseSheetBar(close)
|
||||
CloseSheetBar(close, endButtons)
|
||||
Box(modifier) { content() }
|
||||
}
|
||||
}
|
||||
@@ -37,9 +38,9 @@ class ModalManager {
|
||||
private val toRemove = mutableSetOf<Int>()
|
||||
private var oldViewChanging = AtomicBoolean(false)
|
||||
|
||||
fun showModal(settings: Boolean = false, content: @Composable () -> Unit) {
|
||||
fun showModal(settings: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit) {
|
||||
showCustomModal { close ->
|
||||
ModalView(close, if (!settings || isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight, content = content)
|
||||
ModalView(close, if (!settings || isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight, endButtons = endButtons, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.media.*
|
||||
import android.media.AudioManager.AudioPlaybackCallback
|
||||
@@ -14,8 +15,6 @@ import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.views.helpers.AudioPlayer.duration
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
interface Recorder {
|
||||
fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String
|
||||
@@ -26,6 +25,7 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||
companion object {
|
||||
// Allows to stop the recorder from outside without having the recorder in a variable
|
||||
var stopRecording: (() -> Unit)? = null
|
||||
const val extension = "m4a"
|
||||
}
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var progressJob: Job? = null
|
||||
@@ -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 }
|
||||
@@ -50,8 +51,10 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||
rec.setAudioEncodingBitRate(16000)
|
||||
rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING)
|
||||
rec.setMaxFileSize(recordedBytesLimit)
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val path = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
|
||||
val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE)
|
||||
val fileToSave = File.createTempFile(generateNewFileName(SimplexApp.context, "voice", "${extension}_"), ".tmp", tmpDir)
|
||||
fileToSave.deleteOnExit()
|
||||
val path = fileToSave.absolutePath
|
||||
filePath = path
|
||||
rec.setOutputFile(path)
|
||||
rec.prepare()
|
||||
@@ -150,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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.*
|
||||
@@ -11,6 +12,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
@@ -83,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()
|
||||
@@ -99,6 +102,7 @@ fun SectionItemView(
|
||||
@Composable
|
||||
fun SectionItemViewSpaceBetween(
|
||||
click: (() -> Unit)? = null,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
minHeight: Dp = 46.dp,
|
||||
padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING),
|
||||
disabled: Boolean = false,
|
||||
@@ -108,7 +112,7 @@ fun SectionItemViewSpaceBetween(
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = minHeight)
|
||||
Row(
|
||||
if (click == null || disabled) modifier.padding(padding) else modifier.clickable(onClick = click).padding(padding),
|
||||
if (click == null || disabled) modifier.padding(padding) else modifier.combinedClickable(onClick = click, onLongClick = onLongClick).padding(padding),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -157,6 +161,11 @@ fun <T> SectionItemWithValue(
|
||||
|
||||
@Composable
|
||||
fun SectionTextFooter(text: String) {
|
||||
SectionTextFooter(AnnotatedString(text))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionTextFooter(text: AnnotatedString) {
|
||||
Text(
|
||||
text,
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +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
|
||||
@@ -28,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.*
|
||||
@@ -230,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"
|
||||
}
|
||||
@@ -244,6 +258,11 @@ fun getAppFilePath(context: Context, fileName: String): String {
|
||||
return "${getAppFilesDirectory(context)}/$fileName"
|
||||
}
|
||||
|
||||
fun getAppFileUri(fileName: String): Uri {
|
||||
return Uri.parse("${getAppFilesDirectory(SimplexApp.context)}/$fileName")
|
||||
}
|
||||
|
||||
|
||||
fun getLoadedFilePath(context: Context, file: CIFile?): String? {
|
||||
return if (file?.filePath != null && file.loaded) {
|
||||
val filePath = getAppFilePath(context, file.filePath)
|
||||
@@ -313,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)
|
||||
@@ -321,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)
|
||||
}
|
||||
|
||||
@@ -331,8 +397,7 @@ fun saveImage(context: Context, image: Bitmap): String? {
|
||||
return try {
|
||||
val ext = if (image.hasAlpha()) "png" else "jpg"
|
||||
val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE)
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext")
|
||||
val fileToSave = generateNewFileName(context, "IMG", ext)
|
||||
val file = File(getAppFilePath(context, fileToSave))
|
||||
val output = FileOutputStream(file)
|
||||
dataResized.writeTo(output)
|
||||
@@ -355,8 +420,7 @@ fun saveAnimImage(context: Context, uri: Uri): String? {
|
||||
}
|
||||
// Just in case the image has a strange extension
|
||||
if (ext.length < 3 || ext.length > 4) ext = "gif"
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext")
|
||||
val fileToSave = generateNewFileName(context, "IMG", ext)
|
||||
val file = File(getAppFilePath(context, fileToSave))
|
||||
val output = FileOutputStream(file)
|
||||
context.contentResolver.openInputStream(uri)!!.use { input ->
|
||||
@@ -371,6 +435,24 @@ fun saveAnimImage(context: Context, uri: Uri): String? {
|
||||
}
|
||||
}
|
||||
|
||||
fun saveTempImageUncompressed(image: Bitmap, asPng: Boolean): File? {
|
||||
return try {
|
||||
val ext = if (asPng) "png" else "jpg"
|
||||
val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE)
|
||||
return File(tmpDir.absolutePath + File.separator + generateNewFileName(SimplexApp.context, "IMG", ext)).apply {
|
||||
outputStream().use { out ->
|
||||
image.compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out)
|
||||
out.flush()
|
||||
}
|
||||
deleteOnExit()
|
||||
SimplexApp.context.chatModel.filesToDelete.add(this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Util.kt saveTempImageUncompressed error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveFileFromUri(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
@@ -378,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")
|
||||
@@ -390,15 +472,23 @@ fun saveFileFromUri(context: Context, uri: Uri): String? {
|
||||
}
|
||||
}
|
||||
|
||||
fun generateNewFileName(context: Context, prefix: String, ext: String): String {
|
||||
val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||
sdf.timeZone = TimeZone.getTimeZone("GMT")
|
||||
val timestamp = sdf.format(Date())
|
||||
return uniqueCombine(context, "${prefix}_$timestamp.$ext")
|
||||
}
|
||||
|
||||
fun uniqueCombine(context: Context, fileName: String): String {
|
||||
fun tryCombine(fileName: String, n: Int): String {
|
||||
val name = File(fileName).nameWithoutExtension
|
||||
val ext = File(fileName).extension
|
||||
val orig = File(fileName)
|
||||
val name = orig.nameWithoutExtension
|
||||
val ext = orig.extension
|
||||
fun tryCombine(n: Int): String {
|
||||
val suffix = if (n == 0) "" else "_$n"
|
||||
val f = "$name$suffix.$ext"
|
||||
return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f
|
||||
return if (File(getAppFilePath(context, f)).exists()) tryCombine(n + 1) else f
|
||||
}
|
||||
return tryCombine(fileName, 0)
|
||||
return tryCombine(0)
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long): String {
|
||||
@@ -406,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()]
|
||||
|
||||
@@ -453,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)
|
||||
|
||||
@@ -471,3 +581,63 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,13 @@ fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> U
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
if (enabled) {
|
||||
CreateGroupButton(MaterialTheme.colors.primary, Modifier
|
||||
.clickable { createGroup(GroupProfile(displayName.value, fullName.value, profileImage.value)) }
|
||||
.clickable {
|
||||
createGroup(GroupProfile(
|
||||
displayName = displayName.value,
|
||||
fullName = fullName.value,
|
||||
image = profileImage.value
|
||||
))
|
||||
}
|
||||
.padding(8.dp))
|
||||
} else {
|
||||
CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp))
|
||||
|
||||
@@ -37,7 +37,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<NewChatSheetState>, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) {
|
||||
fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedViewState>, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) {
|
||||
if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) }
|
||||
NewChatSheetLayout(
|
||||
newChatSheetState,
|
||||
@@ -63,7 +63,7 @@ private val icons = listOf(Icons.Outlined.AddLink, Icons.Outlined.QrCode, Icons.
|
||||
|
||||
@Composable
|
||||
private fun NewChatSheetLayout(
|
||||
newChatSheetState: StateFlow<NewChatSheetState>,
|
||||
newChatSheetState: StateFlow<AnimatedViewState>,
|
||||
stopped: Boolean,
|
||||
addContact: () -> Unit,
|
||||
connectViaLink: () -> Unit,
|
||||
@@ -216,7 +216,7 @@ fun ActionButton(
|
||||
private fun PreviewNewChatSheet() {
|
||||
SimpleXTheme {
|
||||
NewChatSheetLayout(
|
||||
MutableStateFlow(NewChatSheetState.VISIBLE),
|
||||
MutableStateFlow(AnimatedViewState.VISIBLE),
|
||||
stopped = false,
|
||||
addContact = {},
|
||||
connectViaLink = {},
|
||||
|
||||
@@ -1,32 +1,100 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import boofcv.alg.fiducial.qrcode.QrCodeEncoder
|
||||
import boofcv.alg.fiducial.qrcode.QrCodeGeneratorImage
|
||||
import androidx.core.graphics.*
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import boofcv.alg.drawing.FiducialImageEngine
|
||||
import boofcv.alg.fiducial.qrcode.*
|
||||
import boofcv.android.ConvertBitmap
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun QRCode(connReq: String, modifier: Modifier = Modifier) {
|
||||
Image(
|
||||
bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.image_descr_qr_code),
|
||||
modifier = modifier
|
||||
)
|
||||
fun QRCode(
|
||||
connReq: String,
|
||||
modifier: Modifier = Modifier,
|
||||
tintColor: Color = Color(0xff062d56),
|
||||
withLogo: Boolean = true
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BoxWithConstraints {
|
||||
val maxWidthInPx = with(LocalDensity.current) { maxWidth.roundToPx() }
|
||||
val qr = remember(maxWidthInPx, connReq, tintColor, withLogo) {
|
||||
qrCodeBitmap(connReq, maxWidthInPx).replaceColor(Color.Black.toArgb(), tintColor.toArgb())
|
||||
.let { if (withLogo) it.addLogo() else it }
|
||||
.asImageBitmap()
|
||||
}
|
||||
Image(
|
||||
bitmap = qr,
|
||||
contentDescription = stringResource(R.string.image_descr_qr_code),
|
||||
modifier
|
||||
.clickable {
|
||||
scope.launch {
|
||||
val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb())
|
||||
.let { if (withLogo) it.addLogo() else it }
|
||||
val file = saveTempImageUncompressed(image, false)
|
||||
if (file != null) {
|
||||
shareFile(context, "", file.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun qrCodeBitmap(content: String, size: Int): Bitmap {
|
||||
val qrCode = QrCodeEncoder().addAutomatic(content).fixate()
|
||||
val renderer = QrCodeGeneratorImage(5)
|
||||
fun qrCodeBitmap(content: String, size: Int = 1024): Bitmap {
|
||||
val qrCode = QrCodeEncoder().addAutomatic(content).setError(QrCode.ErrorLevel.L).fixate()
|
||||
/** See [QrCodeGeneratorImage.initialize] and [FiducialImageEngine.configure] for size calculation */
|
||||
val numModules = QrCode.totalModules(qrCode.version)
|
||||
val borderModule = 1
|
||||
// val calculatedFinalWidth = (pixelsPerModule * numModules) + 2 * (borderModule * pixelsPerModule)
|
||||
// size = (x * numModules) + 2 * (borderModule * x)
|
||||
// size / x = numModules + 2 * borderModule
|
||||
// x = size / (numModules + 2 * borderModule)
|
||||
val pixelsPerModule = size / (numModules + 2 * borderModule)
|
||||
// + 1 to make it with better quality
|
||||
val renderer = QrCodeGeneratorImage(pixelsPerModule + 1)
|
||||
renderer.borderModule = borderModule
|
||||
renderer.render(qrCode)
|
||||
return ConvertBitmap.grayToBitmap(renderer.gray, Bitmap.Config.RGB_565)
|
||||
return ConvertBitmap.grayToBitmap(renderer.gray, Bitmap.Config.RGB_565).scale(size, size)
|
||||
}
|
||||
|
||||
fun Bitmap.replaceColor(from: Int, to: Int): Bitmap {
|
||||
val pixels = IntArray(width * height)
|
||||
getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
var i = 0
|
||||
while (i < pixels.size) {
|
||||
if (pixels[i] == from) {
|
||||
pixels[i] = to
|
||||
}
|
||||
i++
|
||||
}
|
||||
setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return this
|
||||
}
|
||||
|
||||
fun Bitmap.addLogo(): Bitmap = applyCanvas {
|
||||
val radius = (width * 0.16f) / 2
|
||||
val paint = android.graphics.Paint()
|
||||
paint.color = android.graphics.Color.WHITE
|
||||
drawCircle(width / 2f, height / 2f, radius, paint)
|
||||
val logo = SimplexApp.context.resources.getDrawable(R.mipmap.icon_foreground, null).toBitmap()
|
||||
val logoSize = (width * 0.24).toInt()
|
||||
translate((width - logoSize) / 2f, (height - logoSize) / 2f)
|
||||
drawBitmap(logo, null, android.graphics.Rect(0, 0, logoSize, logoSize), null)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
||||
@@ -36,7 +36,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 {
|
||||
|
||||
@@ -21,7 +21,7 @@ enum class OnboardingStage {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateProfile(chatModel: ChatModel) {
|
||||
fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardState by getKeyboardState()
|
||||
@@ -34,7 +34,7 @@ fun CreateProfile(chatModel: ChatModel) {
|
||||
.background(color = MaterialTheme.colors.background)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
CreateProfilePanel(chatModel)
|
||||
CreateProfilePanel(chatModel, close)
|
||||
LaunchedEffect(Unit) {
|
||||
setLastVersionDefault(chatModel)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,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
|
||||
@@ -47,15 +46,15 @@ fun SimpleXInfoLayout(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 8.dp), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.fillMaxWidth().padding(top = 8.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 = 36.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))
|
||||
|
||||
@@ -89,14 +88,14 @@ fun SimpleXLogo() {
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ 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) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -226,10 +226,89 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
icon = Icons.Outlined.VerifiedUser,
|
||||
titleId = R.string.v4_4_verify_connection_security,
|
||||
descrId = R.string.v4_4_verify_connection_security_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Translate,
|
||||
titleId = R.string.v4_4_french_interface,
|
||||
descrId = R.string.v4_4_french_interface_descr
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v4.5",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.ManageAccounts,
|
||||
titleId = R.string.v4_5_multiple_chat_profiles,
|
||||
descrId = R.string.v4_5_multiple_chat_profiles_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.EditNote,
|
||||
titleId = R.string.v4_5_message_draft,
|
||||
descrId = R.string.v4_5_message_draft_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.SafetyDivider,
|
||||
titleId = R.string.v4_5_transport_isolation,
|
||||
descrId = R.string.v4_5_transport_isolation_descr,
|
||||
link = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Task,
|
||||
titleId = R.string.v4_5_private_filenames,
|
||||
descrId = R.string.v4_5_private_filenames_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Battery2Bar,
|
||||
titleId = R.string.v4_5_reduced_battery_usage,
|
||||
descrId = R.string.v4_5_reduced_battery_usage_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Translate,
|
||||
titleId = R.string.v4_5_italian_interface,
|
||||
descrId = R.string.v4_5_italian_interface_descr,
|
||||
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#translate-the-apps"
|
||||
)
|
||||
)
|
||||
),
|
||||
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"
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
private val lastVersion = versionDescriptions.last().version
|
||||
|
||||
fun setLastVersionDefault(m: ChatModel) {
|
||||
|
||||
@@ -33,6 +33,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
val networkTCPConnectTimeout = remember { mutableStateOf(currentCfgVal.tcpConnectTimeout) }
|
||||
val networkTCPTimeout = remember { mutableStateOf(currentCfgVal.tcpTimeout) }
|
||||
val networkSMPPingInterval = remember { mutableStateOf(currentCfgVal.smpPingInterval) }
|
||||
val networkSMPPingCount = remember { mutableStateOf(currentCfgVal.smpPingCount) }
|
||||
val networkEnableKeepAlive = remember { mutableStateOf(currentCfgVal.enableKeepAlive) }
|
||||
val networkTCPKeepIdle: MutableState<Int>
|
||||
val networkTCPKeepIntvl: MutableState<Int>
|
||||
@@ -48,10 +49,6 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
fun buildCfg(): NetCfg {
|
||||
val socksProxy = currentCfg.value.socksProxy
|
||||
val tcpConnectTimeout = networkTCPConnectTimeout.value
|
||||
val tcpTimeout = networkTCPTimeout.value
|
||||
val smpPingInterval = networkSMPPingInterval.value
|
||||
val enableKeepAlive = networkEnableKeepAlive.value
|
||||
val tcpKeepAlive = if (enableKeepAlive) {
|
||||
val keepIdle = networkTCPKeepIdle.value
|
||||
@@ -62,11 +59,15 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
null
|
||||
}
|
||||
return NetCfg(
|
||||
socksProxy = socksProxy,
|
||||
tcpConnectTimeout = tcpConnectTimeout,
|
||||
tcpTimeout = tcpTimeout,
|
||||
socksProxy = currentCfg.value.socksProxy,
|
||||
hostMode = currentCfg.value.hostMode,
|
||||
requiredHostMode = currentCfg.value.requiredHostMode,
|
||||
sessionMode = currentCfg.value.sessionMode,
|
||||
tcpConnectTimeout = networkTCPConnectTimeout.value,
|
||||
tcpTimeout = networkTCPTimeout.value,
|
||||
tcpKeepAlive = tcpKeepAlive,
|
||||
smpPingInterval = smpPingInterval
|
||||
smpPingInterval = networkSMPPingInterval.value,
|
||||
smpPingCount = networkSMPPingCount.value
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,6 +75,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
networkTCPConnectTimeout.value = cfg.tcpConnectTimeout
|
||||
networkTCPTimeout.value = cfg.tcpTimeout
|
||||
networkSMPPingInterval.value = cfg.smpPingInterval
|
||||
networkSMPPingCount.value = cfg.smpPingCount
|
||||
networkEnableKeepAlive.value = cfg.enableKeepAlive
|
||||
if (cfg.tcpKeepAlive != null) {
|
||||
networkTCPKeepIdle.value = cfg.tcpKeepAlive.keepIdle
|
||||
@@ -113,6 +115,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
networkTCPConnectTimeout,
|
||||
networkTCPTimeout,
|
||||
networkSMPPingInterval,
|
||||
networkSMPPingCount,
|
||||
networkEnableKeepAlive,
|
||||
networkTCPKeepIdle,
|
||||
networkTCPKeepIntvl,
|
||||
@@ -129,6 +132,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
networkTCPConnectTimeout: MutableState<Long>,
|
||||
networkTCPTimeout: MutableState<Long>,
|
||||
networkSMPPingInterval: MutableState<Long>,
|
||||
networkSMPPingCount: MutableState<Int>,
|
||||
networkEnableKeepAlive: MutableState<Boolean>,
|
||||
networkTCPKeepIdle: MutableState<Int>,
|
||||
networkTCPKeepIntvl: MutableState<Int>,
|
||||
@@ -170,7 +174,14 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
SectionItemView {
|
||||
TimeoutSettingRow(
|
||||
stringResource(R.string.network_option_ping_interval), networkSMPPingInterval,
|
||||
listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000), secondsLabel
|
||||
listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel
|
||||
)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
IntSettingRow(
|
||||
stringResource(R.string.network_option_ping_count), networkSMPPingCount,
|
||||
listOf(1, 2, 3, 5, 8), ""
|
||||
)
|
||||
}
|
||||
SectionDivider()
|
||||
@@ -412,6 +423,7 @@ fun PreviewAdvancedNetworkSettingsLayout() {
|
||||
networkTCPConnectTimeout = remember { mutableStateOf(10_000000) },
|
||||
networkTCPTimeout = remember { mutableStateOf(10_000000) },
|
||||
networkSMPPingInterval = remember { mutableStateOf(10_000000) },
|
||||
networkSMPPingCount = remember { mutableStateOf(3) },
|
||||
networkEnableKeepAlive = remember { mutableStateOf(true) },
|
||||
networkTCPKeepIdle = remember { mutableStateOf(10) },
|
||||
networkTCPKeepIntvl = remember { mutableStateOf(10) },
|
||||
|
||||
@@ -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
|
||||
@@ -24,17 +32,19 @@ import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
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),
|
||||
@@ -42,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) {
|
||||
@@ -64,12 +74,8 @@ fun AppearanceView() {
|
||||
|
||||
AppearanceLayout(
|
||||
appIcon,
|
||||
m.controller.appPrefs.appLanguage,
|
||||
changeIcon = ::setAppIcon,
|
||||
showThemeSelector = {
|
||||
ModalManager.shared.showModal(true) {
|
||||
ThemeSelectorView()
|
||||
}
|
||||
},
|
||||
editPrimaryColor = { primary ->
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
ColorEditor(primary, close)
|
||||
@@ -80,8 +86,8 @@ fun AppearanceView() {
|
||||
|
||||
@Composable fun AppearanceLayout(
|
||||
icon: MutableState<AppIcon>,
|
||||
languagePref: SharedPreference<String?>,
|
||||
changeIcon: (AppIcon) -> Unit,
|
||||
showThemeSelector: () -> Unit,
|
||||
editPrimaryColor: (Color) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
@@ -89,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 ->
|
||||
@@ -115,8 +152,12 @@ fun AppearanceView() {
|
||||
SectionSpacer()
|
||||
val currentTheme by CurrentColors.collectAsState()
|
||||
SectionView(stringResource(R.string.settings_section_title_themes)) {
|
||||
SectionItemViewSpaceBetween(showThemeSelector) {
|
||||
Text(generalGetString(R.string.theme))
|
||||
SectionItemViewSpaceBetween {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val state = remember { derivedStateOf { currentTheme.second } }
|
||||
ThemeSelector(state) {
|
||||
ThemeManager.applyTheme(it.name, darkTheme)
|
||||
}
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemViewSpaceBetween({ editPrimaryColor(currentTheme.first.primary) }) {
|
||||
@@ -183,6 +224,50 @@ 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",
|
||||
"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()
|
||||
val values by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second to it.third }) }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.theme),
|
||||
values,
|
||||
state,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
//private fun 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(
|
||||
ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}")
|
||||
@@ -195,8 +280,8 @@ fun PreviewAppearanceSettings() {
|
||||
SimpleXTheme {
|
||||
AppearanceLayout(
|
||||
icon = remember { mutableStateOf(AppIcon.DARK_BLUE) },
|
||||
languagePref = SharedPreference({ null }, {}),
|
||||
changeIcon = {},
|
||||
showThemeSelector = {},
|
||||
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.*
|
||||
@@ -9,6 +10,7 @@ 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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -42,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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +122,7 @@ fun SharedPreferenceToggleWithIcon(
|
||||
) {
|
||||
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text, Modifier.padding(end = 4.dp))
|
||||
Text(text, Modifier.padding(end = 4.dp), color = if (stopped) HighOrLowlight else Color.Unspecified)
|
||||
Icon(
|
||||
icon,
|
||||
null,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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()
|
||||
|
||||
val xftpSendEnabled = m.controller.appPrefs.xftpSendEnabled
|
||||
val xftpEnabled = remember { mutableStateOf(xftpSendEnabled.get()) }
|
||||
SectionView(generalGetString(R.string.settings_section_title_experimenta)) {
|
||||
SettingsPreferenceItem(Icons.Outlined.UploadFile, stringResource(R.string.settings_send_files_via_xftp), xftpSendEnabled, xftpEnabled) {
|
||||
withApi { m.controller.apiSetXFTPConfig(m.controller.getXFTPCfg()) }
|
||||
}
|
||||
}
|
||||
if (xftpEnabled.value) {
|
||||
SectionTextFooter(generalGetString(R.string.xftp_requires_v461))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@ 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.material.icons.outlined.UploadFile
|
||||
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
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
|
||||
@Composable
|
||||
fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boolean>) {
|
||||
fun ExperimentalFeaturesView(chatModel: ChatModel) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
@@ -27,7 +27,11 @@ fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boo
|
||||
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)
|
||||
SettingsPreferenceItem(Icons.Outlined.UploadFile, stringResource(R.string.settings_send_files_via_xftp), chatModel.controller.appPrefs.xftpSendEnabled) {
|
||||
withApi {
|
||||
chatModel.controller.apiSetXFTPConfig(chatModel.controller.getXFTPCfg())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,14 @@ 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() }
|
||||
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
|
||||
val sessionMode = remember { mutableStateOf(netCfg.sessionMode) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
chatModel.userSMPServersUnsaved.value = null
|
||||
@@ -40,8 +42,10 @@ fun NetworkAndServersView(
|
||||
developerTools = developerTools,
|
||||
networkUseSocksProxy = networkUseSocksProxy,
|
||||
onionHosts = onionHosts,
|
||||
sessionMode = sessionMode,
|
||||
showModal = showModal,
|
||||
showSettingsModal = showSettingsModal,
|
||||
showCustomModal = showCustomModal,
|
||||
toggleSocksProxy = { enable ->
|
||||
if (enable) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -82,9 +86,13 @@ 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)
|
||||
}
|
||||
updateOnionHostsDialog(startsWith, onDismiss = {
|
||||
onionHosts.value = prevValue
|
||||
}) {
|
||||
updateNetworkSettingsDialog(
|
||||
title = generalGetString(R.string.update_onion_hosts_settings_question),
|
||||
startsWith,
|
||||
onDismiss = {
|
||||
onionHosts.value = prevValue
|
||||
}
|
||||
) {
|
||||
withApi {
|
||||
val newCfg = chatModel.controller.getNetCfg().withOnionHosts(it)
|
||||
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
|
||||
@@ -96,6 +104,31 @@ fun NetworkAndServersView(
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updateSessionMode = {
|
||||
if (sessionMode.value == it) return@NetworkAndServersLayout
|
||||
val prevValue = sessionMode.value
|
||||
sessionMode.value = it
|
||||
val startsWith = when (it) {
|
||||
TransportSessionMode.User -> generalGetString(R.string.network_session_mode_user_description)
|
||||
TransportSessionMode.Entity -> generalGetString(R.string.network_session_mode_entity_description)
|
||||
}
|
||||
updateNetworkSettingsDialog(
|
||||
title = generalGetString(R.string.update_network_session_mode_question),
|
||||
startsWith,
|
||||
onDismiss = { sessionMode.value = prevValue }
|
||||
) {
|
||||
withApi {
|
||||
val newCfg = chatModel.controller.getNetCfg().copy(sessionMode = it)
|
||||
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
|
||||
if (res) {
|
||||
chatModel.controller.setNetCfg(newCfg)
|
||||
sessionMode.value = it
|
||||
} else {
|
||||
sessionMode.value = prevValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -104,10 +137,13 @@ fun NetworkAndServersView(
|
||||
developerTools: Boolean,
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
sessionMode: MutableState<TransportSessionMode>,
|
||||
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,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -116,17 +152,19 @@ 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 -> SMPServersView(m, close) })
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||
}
|
||||
SectionDivider()
|
||||
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
|
||||
SectionDivider()
|
||||
if (developerTools) {
|
||||
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
|
||||
}
|
||||
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionView(generalGetString(R.string.settings_section_title_calls)) {
|
||||
@@ -185,7 +223,6 @@ private fun UseOnionHosts(
|
||||
}
|
||||
}
|
||||
val onSelected = showModal {
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
@@ -205,14 +242,47 @@ private fun UseOnionHosts(
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateOnionHostsDialog(
|
||||
@Composable
|
||||
private fun SessionModePicker(
|
||||
sessionMode: MutableState<TransportSessionMode>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
updateSessionMode: (TransportSessionMode) -> Unit,
|
||||
) {
|
||||
val values = remember {
|
||||
TransportSessionMode.values().map {
|
||||
when (it) {
|
||||
TransportSessionMode.User -> ValueTitleDesc(TransportSessionMode.User, generalGetString(R.string.network_session_mode_user), generalGetString(R.string.network_session_mode_user_description))
|
||||
TransportSessionMode.Entity -> ValueTitleDesc(TransportSessionMode.Entity, generalGetString(R.string.network_session_mode_entity), generalGetString(R.string.network_session_mode_entity_description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionItemWithValue(
|
||||
generalGetString(R.string.network_session_mode_transport_isolation),
|
||||
sessionMode,
|
||||
values,
|
||||
icon = Icons.Outlined.SafetyDivider,
|
||||
onSelected = showModal {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.network_session_mode_transport_isolation))
|
||||
SectionViewSelectable(null, sessionMode, values, updateSessionMode)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateNetworkSettingsDialog(
|
||||
title: String,
|
||||
startsWith: String = "",
|
||||
message: String = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.update_onion_hosts_settings_question),
|
||||
title = title,
|
||||
text = startsWith + "\n\n" + message,
|
||||
confirmText = generalGetString(R.string.update_network_settings_confirmation),
|
||||
onDismiss = onDismiss,
|
||||
@@ -230,9 +300,12 @@ fun PreviewNetworkAndServersLayout() {
|
||||
networkUseSocksProxy = remember { mutableStateOf(true) },
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showCustomModal = { {} },
|
||||
toggleSocksProxy = {},
|
||||
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
|
||||
sessionMode = remember { mutableStateOf(TransportSessionMode.User) },
|
||||
useOnion = {},
|
||||
updateSessionMode = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,8 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) {
|
||||
val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences())
|
||||
val updatedProfile = m.controller.apiUpdateProfile(newProfile)
|
||||
if (updatedProfile != null) {
|
||||
val updatedUser = user.copy(
|
||||
profile = updatedProfile.toLocalProfile(user.profile.profileId),
|
||||
fullPreferences = preferences
|
||||
)
|
||||
m.updateCurrentUser(updatedProfile, preferences)
|
||||
currentPreferences = preferences
|
||||
m.currentUser.value = updatedUser
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ 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.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
@@ -51,8 +50,6 @@ fun PrivacySettingsView(
|
||||
SectionView(stringResource(R.string.settings_section_title_chats)) {
|
||||
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
|
||||
SectionDivider()
|
||||
SectionItemView { SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.usersettings
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -15,6 +16,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
@@ -24,7 +27,7 @@ import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SMPServersView(m: ChatModel) {
|
||||
fun SMPServersView(m: ChatModel, close: () -> Unit) {
|
||||
var servers by remember {
|
||||
mutableStateOf(m.userSMPServersUnsaved.value ?: m.userSMPServers.value ?: emptyList())
|
||||
}
|
||||
@@ -69,82 +72,90 @@ fun SMPServersView(m: ChatModel) {
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
SMPServersLayout(
|
||||
testing = testing.value,
|
||||
servers = servers,
|
||||
serversUnchanged = serversUnchanged.value,
|
||||
saveDisabled = saveDisabled.value,
|
||||
allServersDisabled = allServersDisabled.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({ saveSMPServers(servers, m, close) }, close)
|
||||
},
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
SMPServersLayout(
|
||||
testing = testing.value,
|
||||
servers = servers,
|
||||
serversUnchanged = serversUnchanged.value,
|
||||
saveDisabled = saveDisabled.value,
|
||||
allServersDisabled = allServersDisabled.value,
|
||||
m.currentUser.value,
|
||||
addServer = {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(R.string.smp_servers_add),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
servers = servers + ServerCfg.empty
|
||||
// No saving until something will be changed on the next screen to prevent blank servers on the list
|
||||
showServer(servers.last())
|
||||
}) {
|
||||
Text(stringResource(R.string.smp_servers_enter_manually))
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
ScanSMPServer {
|
||||
close()
|
||||
servers = servers + it
|
||||
m.userSMPServersUnsaved.value = servers
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.smp_servers_scan_qr))
|
||||
}
|
||||
val hasAllPresets = hasAllPresets(servers, m)
|
||||
if (!hasAllPresets) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
servers = (servers + addAllPresets(servers, m)).sortedByDescending { it.preset }
|
||||
}) {
|
||||
Text(stringResource(R.string.smp_servers_preset_add), color = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
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 = m.userSMPServers.value ?: emptyList()
|
||||
m.userSMPServersUnsaved.value = null
|
||||
},
|
||||
saveSMPServers = {
|
||||
saveSMPServers(servers, m)
|
||||
},
|
||||
showServer = ::showServer,
|
||||
)
|
||||
|
||||
if (testing.value) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
if (testing.value) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +167,7 @@ private fun SMPServersLayout(
|
||||
serversUnchanged: Boolean,
|
||||
saveDisabled: Boolean,
|
||||
allServersDisabled: Boolean,
|
||||
currentUser: User?,
|
||||
addServer: () -> Unit,
|
||||
testServers: () -> Unit,
|
||||
resetServers: () -> Unit,
|
||||
@@ -186,6 +198,17 @@ private fun SMPServersLayout(
|
||||
iconColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(R.string.smp_servers_per_user) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(currentUser?.displayName ?: "")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
}
|
||||
)
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
SectionItemView(resetServers, disabled = serversUnchanged) {
|
||||
@@ -231,7 +254,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
|
||||
)
|
||||
@@ -308,12 +331,22 @@ private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpd
|
||||
return fs
|
||||
}
|
||||
|
||||
private fun saveSMPServers(servers: List<ServerCfg>, m: ChatModel) {
|
||||
private fun saveSMPServers(servers: List<ServerCfg>, m: ChatModel, afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
if (m.controller.setUserSMPServers(servers)) {
|
||||
m.userSMPServers.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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import SectionDivider
|
||||
import SectionItemView
|
||||
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.*
|
||||
@@ -12,18 +14,21 @@ 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
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
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.*
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
@@ -44,20 +49,68 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
MaintainIncognitoState(chatModel)
|
||||
|
||||
if (user != null) {
|
||||
val requireAuth = remember { chatModel.controller.appPrefs.performLA.state }
|
||||
val context = LocalContext.current
|
||||
SettingsLayout(
|
||||
profile = user.profile,
|
||||
stopped,
|
||||
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) } } },
|
||||
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
|
||||
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
|
||||
showVersion = {
|
||||
withApi {
|
||||
val info = chatModel.controller.apiGetVersion()
|
||||
if (info != null) {
|
||||
ModalManager.shared.showModal { VersionInfoView(info) }
|
||||
}
|
||||
}
|
||||
},
|
||||
withAuth = { block ->
|
||||
if (!requireAuth.value) {
|
||||
block()
|
||||
} else {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
val onFinishAuth = { success: Boolean ->
|
||||
if (success) {
|
||||
close()
|
||||
block()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
runAuth(context, onFinishAuth)
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.auth_unlock),
|
||||
icon = Icons.Outlined.Lock,
|
||||
click = {
|
||||
runAuth(context, onFinishAuth)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,16 +118,6 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
val simplexTeamUri =
|
||||
"simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
|
||||
|
||||
// TODO pass close
|
||||
//fun showSectionedModal(chatModel: ChatModel, modalView: (@Composable (ChatModel) -> Unit)) {
|
||||
// ModalManager.shared.showCustomModal { close ->
|
||||
// ModalView(close = close, modifier = Modifier,
|
||||
// background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
|
||||
// modalView(chatModel)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@Composable
|
||||
fun SettingsLayout(
|
||||
profile: LocalProfile,
|
||||
@@ -82,14 +125,14 @@ fun SettingsLayout(
|
||||
encrypted: Boolean,
|
||||
incognito: MutableState<Boolean>,
|
||||
incognitoPref: SharedPreference<Boolean>,
|
||||
developerTools: SharedPreference<Boolean>,
|
||||
userDisplayName: String,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModalWithSearch: (@Composable (ChatModel, MutableState<String>) -> Unit) -> Unit,
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
showTerminal: () -> Unit,
|
||||
// showVideoChatPrototype: () -> Unit
|
||||
showVersion: () -> Unit,
|
||||
withAuth: (block: () -> Unit) -> Unit
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Surface(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
@@ -113,24 +156,27 @@ fun SettingsLayout(
|
||||
ProfilePreview(profile, stopped = stopped)
|
||||
}
|
||||
SectionDivider()
|
||||
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()
|
||||
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
ChatPreferencesItem(showCustomModal)
|
||||
ChatPreferencesItem(showCustomModal, stopped = stopped)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -143,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()
|
||||
|
||||
@@ -159,18 +205,11 @@ 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(showTerminal)
|
||||
SectionDivider()
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
SectionDivider()
|
||||
}
|
||||
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
|
||||
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it) })
|
||||
// SectionDivider()
|
||||
AppVersionItem()
|
||||
AppVersionItem(showVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,17 +279,18 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit))) {
|
||||
@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit)), stopped: Boolean) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.chat_preferences),
|
||||
click = {
|
||||
click = if (stopped) null else ({
|
||||
withApi {
|
||||
showCustomModal { m, close ->
|
||||
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}),
|
||||
disabled = stopped
|
||||
)
|
||||
}
|
||||
|
||||
@@ -282,7 +322,7 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
@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",
|
||||
@@ -295,8 +335,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(
|
||||
@@ -310,7 +350,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",
|
||||
@@ -321,7 +361,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),
|
||||
@@ -333,8 +373,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",
|
||||
@@ -345,10 +385,12 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun AppVersionItem() {
|
||||
SectionItemView() {
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
}
|
||||
@Composable private fun AppVersionItem(showVersion: () -> Unit) {
|
||||
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) {
|
||||
@@ -408,7 +450,7 @@ fun SettingsPreferenceItemWithInfo(
|
||||
pref: SharedPreference<Boolean>,
|
||||
prefState: MutableState<Boolean>? = null
|
||||
) {
|
||||
SectionItemView(onClickInfo) {
|
||||
SectionItemView(if (stopped) null else onClickInfo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, text, tint = if (stopped) HighOrLowlight else iconTint)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
@@ -469,6 +511,17 @@ fun PreferenceToggleWithIcon(
|
||||
}
|
||||
}
|
||||
|
||||
private fun runAuth(context: Context, onFinish: (success: Boolean) -> Unit) {
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_open_chat_console),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
onFinish(laResult == LAResult.Success || laResult == LAResult.Unavailable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
@@ -484,14 +537,14 @@ fun PreviewSettingsLayout() {
|
||||
encrypted = false,
|
||||
incognito = remember { mutableStateOf(false) },
|
||||
incognitoPref = SharedPreference({ false }, {}),
|
||||
developerTools = SharedPreference({ false }, {}),
|
||||
userDisplayName = "Alice",
|
||||
setPerformLA = {},
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showSettingsModalWithSearch = { },
|
||||
showCustomModal = { {} },
|
||||
showTerminal = {},
|
||||
// showVideoChatPrototype = {}
|
||||
showVersion = {},
|
||||
withAuth = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionViewSelectable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ThemeSelectorView() {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val allThemes by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { ValueTitleDesc(it.second, it.third, "") }) }
|
||||
|
||||
ThemeSelectorLayout(
|
||||
allThemes,
|
||||
onSelectTheme = {
|
||||
ThemeManager.applyTheme(it.name, darkTheme)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeSelectorLayout(
|
||||
allThemes: List<ValueTitleDesc<DefaultTheme>>,
|
||||
onSelectTheme: (DefaultTheme) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.settings_section_title_themes).lowercase().capitalize(Locale.current))
|
||||
val currentTheme by CurrentColors.collectAsState()
|
||||
val state = remember { derivedStateOf { currentTheme.second } }
|
||||
SectionViewSelectable(null, state, allThemes, onSelectTheme)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,9 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
|
||||
close,
|
||||
saveProfile = { displayName, fullName, image ->
|
||||
withApi {
|
||||
val p = Profile(displayName, fullName, image)
|
||||
val newProfile = chatModel.controller.apiUpdateProfile(p)
|
||||
val newProfile = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName, fullName = fullName, image = image))
|
||||
if (newProfile != null) {
|
||||
chatModel.currentUser.value?.profile?.profileId?.let {
|
||||
chatModel.updateUserProfile(newProfile.toLocalProfile(it))
|
||||
}
|
||||
chatModel.updateCurrentUser(newProfile)
|
||||
profile = newProfile
|
||||
}
|
||||
editProfile.value = false
|
||||
@@ -97,7 +94,7 @@ fun UserProfileLayout(
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.your_chat_profile), false)
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
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.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
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
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),
|
||||
addUser = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
CreateProfile(m, close)
|
||||
}
|
||||
},
|
||||
activateUser = { user ->
|
||||
withBGApi {
|
||||
m.controller.changeActiveUser(user.userId, userViewPassword(user, searchTextOrPassword.value.trim()))
|
||||
}
|
||||
},
|
||||
removeUser = { user ->
|
||||
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(":")
|
||||
}
|
||||
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), color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, false, searchTextOrPassword.value.trim())
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.users_delete_data_only), 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserProfilesView(
|
||||
users: List<User>,
|
||||
filteredUsers: List<User>,
|
||||
searchTextOrPassword: MutableState<String>,
|
||||
profileHidden: MutableState<Boolean>,
|
||||
visibleUsersCount: Int,
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.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 filteredUsers) {
|
||||
UserView(user, users, visibleUsersCount, activateUser, removeUser, unhideUser, muteUser, unmuteUser, showHiddenProfile)
|
||||
SectionDivider()
|
||||
}
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserView(
|
||||
user: User,
|
||||
users: List<User>,
|
||||
visibleUsersCount: Int,
|
||||
activateUser: (User) -> Unit,
|
||||
removeUser: (User) -> Unit,
|
||||
unhideUser: (User) -> Unit,
|
||||
muteUser: (User) -> Unit,
|
||||
unmuteUser: (User) -> Unit,
|
||||
showHiddenProfile: (User) -> Unit,
|
||||
) {
|
||||
var showDropdownMenu by remember { mutableStateOf(false) }
|
||||
UserProfilePickerItem(user, onLongClick = { if (users.size > 1) showDropdownMenu = true }) {
|
||||
activateUser(user)
|
||||
}
|
||||
Box(Modifier.padding(horizontal = 16.dp)) {
|
||||
DropdownMenu(
|
||||
expanded = showDropdownMenu,
|
||||
onDismissRequest = { showDropdownMenu = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
if (user.hidden) {
|
||||
ItemAction(stringResource(R.string.user_unhide), Icons.Outlined.LockOpen, onClick = {
|
||||
showDropdownMenu = false
|
||||
unhideUser(user)
|
||||
})
|
||||
} else {
|
||||
if (visibleUsersCount > 1) {
|
||||
ItemAction(stringResource(R.string.user_hide), Icons.Outlined.Lock, onClick = {
|
||||
showDropdownMenu = false
|
||||
showHiddenProfile(user)
|
||||
})
|
||||
}
|
||||
if (user.showNtfs) {
|
||||
ItemAction(stringResource(R.string.user_mute), Icons.Outlined.NotificationsOff, onClick = {
|
||||
showDropdownMenu = false
|
||||
muteUser(user)
|
||||
})
|
||||
} else {
|
||||
ItemAction(stringResource(R.string.user_unmute), Icons.Outlined.Notifications, onClick = {
|
||||
showDropdownMenu = false
|
||||
unmuteUser(user)
|
||||
})
|
||||
}
|
||||
}
|
||||
ItemAction(stringResource(R.string.delete_verb), Icons.Outlined.Delete, color = Color.Red, onClick = {
|
||||
removeUser(user)
|
||||
showDropdownMenu = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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))
|
||||
}
|
||||
} 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CoreVersionInfo
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
|
||||
@Composable
|
||||
fun VersionInfoView(info: CoreVersionInfo) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.app_version_title), false)
|
||||
Text(String.format(stringResource(R.string.app_version_name), BuildConfig.VERSION_NAME))
|
||||
Text(String.format(stringResource(R.string.app_version_code), BuildConfig.VERSION_CODE))
|
||||
Text(String.format(stringResource(R.string.core_version), info.version))
|
||||
Text(String.format(stringResource(R.string.core_build_timestamp), info.buildTimestamp))
|
||||
val simplexmqCommit = if (info.simplexmqCommit.length >= 7) info.simplexmqCommit.substring(startIndex = 0, endIndex = 7) else info.simplexmqCommit
|
||||
Text(String.format(stringResource(R.string.core_simplexmq_version), info.simplexmqVersion, simplexmqCommit))
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 10 KiB |