Compare commits

..

44 Commits

Author SHA1 Message Date
Evgeny Poberezkin bcce6a18a1 Delete README.md 2023-04-30 09:17:02 +01:00
lastimeoceanisfall 3bce760e05 Create audio-video-calls.md (#2254) 2023-04-30 09:14:44 +01:00
lastimeoceanisfall a7c60f0721 Create app-settings.md (#2253) 2023-04-30 09:13:57 +01:00
lastimeoceanisfall 94ad68155f Create WEBRTC.md (#2251) 2023-04-30 09:13:37 +01:00
lastimeoceanisfall cb2b5ff97b Create TRANSLATIONS.md (#2250) 2023-04-30 09:13:12 +01:00
lastimeoceanisfall 313c40590f Create SQL.md (#2249) 2023-04-30 09:12:53 +01:00
lastimeoceanisfall d806efc347 Create SIMPLEX.md (#2248) 2023-04-30 09:12:34 +01:00
lastimeoceanisfall 743db49215 Create SERVER.md (#2247) 2023-04-30 09:12:08 +01:00
lastimeoceanisfall 4fdffdb8aa Create README.md (#2246) 2023-04-30 09:11:46 +01:00
lastimeoceanisfall 1699aae906 Create CLI.md (#2242) 2023-04-30 09:10:25 +01:00
lastimeoceanisfall 82eba49b95 Create chat-profiles.md (#2255) 2023-04-30 09:08:48 +01:00
lastimeoceanisfall 218b1a8b7e Create making-connections.md (#2256) 2023-04-30 09:08:22 +01:00
lastimeoceanisfall 6c1bba55b4 Create managing-data.md (#2257) 2023-04-30 09:08:02 +01:00
lastimeoceanisfall c9fc1547b0 Create privacy-security.md (#2258) 2023-04-30 09:07:44 +01:00
lastimeoceanisfall 8a3a8455d4 Create secret-groups.md (#2260) 2023-04-30 09:07:15 +01:00
lastimeoceanisfall 9c437f6204 Create README.md (#2259) 2023-04-30 09:06:47 +01:00
lastimeoceanisfall ef698e58d4 Create send-messages.md (#2261) 2023-04-30 09:06:18 +01:00
lastimeoceanisfall 01c0ac3d13 Create CONTRIBUTING.md (#2241) 2023-04-30 09:03:57 +01:00
lastimeoceanisfall ed7089902c Create README.md (#2272) 2023-04-30 09:02:29 +01:00
lastimeoceanisfall 1ddd4193a0 Create 20201022-simplex-chat.md (#2274) 2023-04-30 09:01:57 +01:00
lastimeoceanisfall 932875fd9d Create 20210512-simplex-chat-terminal-ui.md (#2275) 2023-04-30 09:01:35 +01:00
lastimeoceanisfall 1899c84ee2 Create 20210914-simplex-chat-v0.4-released.md (#2277) 2023-04-30 09:01:07 +01:00
lastimeoceanisfall 91e03a016f Create 20211208-simplex-chat-v0.5-released.md (#2279) 2023-04-30 09:00:48 +01:00
lastimeoceanisfall a978c12ca3 Create 20220112-simplex-chat-v1-released.md (#2280) 2023-04-30 09:00:30 +01:00
lastimeoceanisfall da1f2d9ed6 Create 20220214-simplex-chat-ios-public-beta.md (#2282) 2023-04-30 09:00:04 +01:00
lastimeoceanisfall 1ed6179782 Create 20220308-simplex-chat-mobile-apps.md (#2284) 2023-04-30 08:59:44 +01:00
lastimeoceanisfall e15251e354 Create 20220404-simplex-chat-instant-notifications.md (#2285) 2023-04-30 08:58:55 +01:00
lastimeoceanisfall c348dd765f Create 20220511-simplex-chat-v2-images-files.md (#2286) 2023-04-30 08:56:02 +01:00
lastimeoceanisfall 78a23e6b02 Create 20220524-simplex-chat-better-privacy.md (#2288) 2023-04-30 08:55:40 +01:00
lastimeoceanisfall 590499684d Create 20220604-simplex-chat-new-privacy-security-settings.md (#2289) 2023-04-30 08:55:16 +01:00
lastimeoceanisfall 59c50f8088 Create 20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md (#2291) 2023-04-30 08:54:59 +01:00
lastimeoceanisfall ee4c759706 Create 20220723-simplex-chat-v3.1-tor-groups-efficiency.md (#2293) 2023-04-30 08:54:37 +01:00
lastimeoceanisfall d5bd3a7d68 Create 20220808-simplex-chat-v3.1-chat-groups.md (#2294)
* Create 20220808-simplex-chat-v3.1-chat-groups.md

* Update 20220808-simplex-chat-v3.1-chat-groups.md
2023-04-30 08:54:21 +01:00
lastimeoceanisfall 7159195cb9 Create 20220901-simplex-chat-v3.2-incognito-mode.md (#2295) 2023-04-30 08:54:04 +01:00
lastimeoceanisfall 45d3855d84 Create 20220928-simplex-chat-v4-encrypted-database.md (#2296) 2023-04-30 08:53:48 +01:00
lastimeoceanisfall 650a6f0758 Create 20221108-simplex-chat-v4.2-security-audit-new-website.md (#2297) 2023-04-30 08:53:25 +01:00
lastimeoceanisfall ae6ba0cfb5 Create 20221206-simplex-chat-v4.3-voice-messages.md (#2298) 2023-04-30 08:53:03 +01:00
lastimeoceanisfall d9a2317f82 Create 20230103-simplex-chat-v4.4-disappearing-messages.md (#2299) 2023-04-30 08:52:47 +01:00
lastimeoceanisfall 3d8b521c0c Create 20230204-simplex-chat-v4-5-user-chat-profiles.md (#2300) 2023-04-30 08:52:28 +01:00
lastimeoceanisfall 1fd5bbbc4f Create 20230301-simplex-file-transfer-protocol.md (#2301) 2023-04-30 08:52:07 +01:00
lastimeoceanisfall 62299cbf0b Create 20230328-simplex-chat-v4-6-hidden-profiles.md (#2302) 2023-04-30 08:51:47 +01:00
lastimeoceanisfall 3eb969d3c5 Create 20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md (#2303) 2023-04-30 08:51:09 +01:00
lastimeoceanisfall 85dba0f36b Create README.md (#2304) 2023-04-30 08:50:39 +01:00
lastimeoceanisfall cb6490ed59 Create ANDROID.md (#2240) 2023-04-30 08:50:18 +01:00
462 changed files with 18164 additions and 74369 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
name: Bug
description: File a bug report/issue
title: "[Bug]: "
labels: ["bug", "triage"]
labels: ["type:bug", "type:triage"]
body:
- type: checkboxes
attributes:
+1 -1
View File
@@ -1,7 +1,7 @@
name: Feature
description: Suggest your feature
title: "[Feature]: "
labels: ["enhancement", "triage"]
labels: ["type:enhancement", "type:triage"]
body:
- type: checkboxes
attributes:
+1 -1
View File
@@ -1,7 +1,7 @@
name: Question
description: Ask your question
title: "[Q]: "
labels: ["question", "triage"]
labels: ["type:question", "type:triage"]
body:
- type: markdown
attributes:
+11 -19
View File
@@ -16,11 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clone project
uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Build changelog
id: build_changelog
uses: mikepenz/release-changelog-builder-action@v4
uses: mikepenz/release-changelog-builder-action@v1
with:
configuration: .github/changelog_conf.json
failOnError: true
@@ -62,25 +62,17 @@ jobs:
cache_path: C:/cabal
asset_name: simplex-chat-windows-x86-64
steps:
- name: Configure pagefile (Windows)
if: matrix.os == 'windows-latest'
uses: al-cheb/configure-pagefile-action@v1.3
with:
minimum-size: 16GB
maximum-size: 16GB
disk-root: "C:"
- name: Clone project
uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Setup Haskell
uses: haskell/actions/setup@v2
uses: haskell/actions/setup@v1
with:
ghc-version: "8.10.7"
cabal-version: "latest"
- name: Cache dependencies
uses: actions/cache@v3
uses: actions/cache@v2
with:
path: |
${{ matrix.cache_path }}
@@ -119,6 +111,12 @@ jobs:
cabal build --enable-tests
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
- name: Unix test
if: matrix.os != 'windows-latest'
timeout-minutes: 30
shell: bash
run: cabal test --test-show-details=direct
- name: Unix upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: svenstaro/upload-release-action@v2
@@ -128,12 +126,6 @@ jobs:
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
- name: Unix test
if: matrix.os != 'windows-latest'
timeout-minutes: 30
shell: bash
run: cabal test --test-show-details=direct
# Unix /
# / Windows
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
strategy:
matrix:
node-version: [16.x]
node-version: [12.x]
steps:
- uses: actions/checkout@v2
+1 -1
View File
@@ -49,8 +49,8 @@ logs/
# for website
website/node_modules/
website/src/blog/
website/src/docs/
website/translations.json
website/src/_data/supported_languages.json
website/src/img/images/
website/src/images/
# Generated files
+15 -33
View File
@@ -64,18 +64,11 @@ You must:
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-4](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2Fw2GlucRXtRVgYnbt_9ZP-kmt76DekxxS%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0tJhTyMGUxznwmjb7aT24P1I1Wry_iURTuhOFlMb1Eo%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22WoPxjFqGEDlVazECOSi2dg%3D%3D%22%7D)
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) for developers who build on SimpleX platform:
- chat bots and automations
- integrations with other apps
- social apps and services
- etc.
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-3](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fp-j-D_PrY2UMDchFHEUtbSES0nmzCnvD%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA3gBfMjB_GDEmKQwjNdqGbnX91yfuZ7nRJgQijsx5Khc%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%2262MvNZ_Ec2mmlS8V0QNtLQ%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-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-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).
[\#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.
@@ -108,10 +101,8 @@ Join our translators to help SimpleX grow!
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/es/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/es/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/es/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/fr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fr/website/svg-badge.svg)](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)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/it/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/it/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|🇯🇵 ja|Japanese ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ja/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/nl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/nl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/nl/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/pl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|🇧🇷 pt-BR|Português||[![android app](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[![website](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|🇷🇺 ru|Русский ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/ru/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br>&nbsp;|<br><br>[![website](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
@@ -121,7 +112,6 @@ Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https:/
We would love to have you join the development! You can help us with:
- [share the color theme](./docs/THEMES.md) you use in Android app!
- 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.
@@ -207,8 +197,6 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent updates:
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.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).
@@ -257,15 +245,13 @@ See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of m
SimpleX Chat is a work in progress we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
We compiled a [glossary of terms](./docs/GLOSSARY.md) used to describe communication systems to help understand some terms below and to help compare advantages and disadvantages of various communication systems.
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](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (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](./docs/GLOSSARY.md#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](./docs/GLOSSARY.md#double-ratchet-algorithm) 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](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
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).
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
5. Several levels of content padding to frustrate message size attacks.
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
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.
@@ -290,8 +276,6 @@ You can:
If you are considering developing with SimpleX platform please get in touch for any advice and support.
Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) group to ask any questions and share your success stories.
## Roadmap
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
@@ -332,27 +316,25 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
- ✅ Video messages.
- ✅ App access passcode.
- Improved Android app UI design.
- ✅ Optional alternative access password.
- ✅ Message reactions
- ✅ Message editing history
- ✅ Reduced battery and traffic usage in large groups.
- 🏗 Desktop client.
- 🏗 Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
- SMP queue redundancy and rotation (manual is supported).
- 🏗 Improved Android app UI design.
- 🏗 SMP queue redundancy and rotation (manual is supported).
- 🏗 Reduced battery and traffic usage in large groups.
- Include optional message into connection request sent via contact address.
- Ephemeral/disappearing/OTR conversations with the existing contacts.
- Optional alternative access password.
- Local app files encryption.
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
- Large groups, communities and public channels.
- Feeds/broadcasts.
- Ephemeral/disappearing/OTR conversations with the existing contacts.
- 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.).
- Supporting the same profile on multiple devices.
- Desktop client.
- 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.
- 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.
+1 -12
View File
@@ -4,6 +4,7 @@ This readme is currently a stub and as such is in development.
Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app.
## Gotchas
#### SHA Signature for verification for app links/deep links
@@ -22,15 +23,3 @@ To find your SHA certificate fingerprint perform the following steps.
More information is available [here](https://developer.android.com/training/app-links/verify-site-associations#manual-verification). If there is no response when running the `pm get-app-links` command, the intents in `AndroidManifest.xml` are likely misspecified. A verification attempt can be triggered using `adb shell pm verify-app-links --re-verify chat.simplex.app`.
Note that this is not an issue for the app store build of the app as this is signed with our app store credentials and thus there is a stable signature over users. Developers do not have general access to these credentials for development and testing.
## Adding icons
1. Find a [Material symbol](https://fonts.google.com/icons?icon.style=Rounded) in Rounded category.
2. Set weight to 400, grade to -25 and size to 48px.
3. Click on the icon, choose Android and download XML file.
4. Update the color to black (#FF000000) and the size to "24.dp", as in other icons.
For example, this is [add reaction icon](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:add_reaction:FILL@0;wght@300;GRAD@-25;opsz@24&icon.style=Rounded).
+2 -10
View File
@@ -11,10 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 26
targetSdk 32
// !!!
// skip version code after release to F-Droid, as it uses two version codes
versionCode 129
versionName "5.2-beta.0"
versionCode 117
versionName "5.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -86,10 +84,8 @@ android {
"es",
"fr",
"it",
"ja",
"nl",
"pl",
"pt-rBR",
"ru",
"zh-rCN"
)
@@ -162,15 +158,11 @@ dependencies {
// Video support
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
// Wheel picker
implementation 'com.github.zj565061763:compose-wheel-picker:1.0.0-alpha10'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation "com.jakewharton:process-phoenix:2.1.2"
}
// Don't do anything if no compression is needed
@@ -10,12 +10,14 @@ import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
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.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -30,12 +32,10 @@ import chat.simplex.app.views.SplashView
import chat.simplex.app.views.call.ActiveCallView
import chat.simplex.app.views.call.IncomingCallAlertView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chat.group.ProgressIndicator
import chat.simplex.app.views.chatlist.*
import chat.simplex.app.views.database.DatabaseErrorView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword
import chat.simplex.app.views.localauth.SetAppPasscodeView
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.onboarding.*
@@ -61,7 +61,6 @@ class MainActivity: FragmentActivity() {
}
}
private val vm by viewModels<SimplexViewModel>()
private val destroyedAfterBackPress = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -84,17 +83,14 @@ class MainActivity: FragmentActivity() {
}
setContent {
SimpleXTheme {
Surface(color = MaterialTheme.colors.background) {
MainPage(
m,
userAuthorized,
laFailed,
destroyedAfterBackPress,
::runAuthenticate,
::setPerformLA,
showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown, this) }
)
}
MainPage(
m,
userAuthorized,
laFailed,
::runAuthenticate,
::setPerformLA,
showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown, this) }
)
}
}
SimplexApp.context.schedulePeriodicServiceRestartWorker()
@@ -112,12 +108,7 @@ class MainActivity: FragmentActivity() {
val enteredBackgroundVal = enteredBackground.value
val delay = vm.chatModel.controller.appPrefs.laLockDelay.get()
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
if (userAuthorized.value != false) {
/** [runAuthenticate] will be called in [MainPage] if needed. Making like this prevents double showing of passcode on start */
setAuthState()
} else if (!vm.chatModel.activeCallViewIsVisible.value) {
runAuthenticate()
}
runAuthenticate()
}
}
@@ -151,7 +142,6 @@ class MainActivity: FragmentActivity() {
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
clearAuthState()
laFailed.value = true
destroyedAfterBackPress.value = true
}
if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
// Drop shared content
@@ -159,14 +149,13 @@ class MainActivity: FragmentActivity() {
}
}
private fun setAuthState() {
userAuthorized.value = !vm.chatModel.controller.appPrefs.performLA.get()
}
private fun runAuthenticate() {
val m = vm.chatModel
setAuthState()
if (userAuthorized.value == false) {
if (!m.controller.appPrefs.performLA.get()) {
userAuthorized.value = true
} else {
userAuthorized.value = false
ModalManager.shared.closeModals()
// 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)
@@ -180,7 +169,6 @@ class MainActivity: FragmentActivity() {
generalGetString(R.string.auth_log_in_using_credential)
else
generalGetString(R.string.auth_unlock),
selfDestruct = true,
this@MainActivity,
completed = { laResult ->
when (laResult) {
@@ -250,7 +238,7 @@ class MainActivity: FragmentActivity() {
authenticate(
generalGetString(R.string.auth_enable_simplex_lock),
generalGetString(R.string.auth_confirm_credential),
activity = activity,
activity,
completed = { laResult ->
when (laResult) {
LAResult.Success -> {
@@ -291,8 +279,7 @@ class MainActivity: FragmentActivity() {
appPrefs.performLA.set(false)
laPasscodeNotSetAlert()
},
close = close
)
close)
}
}
}
@@ -317,7 +304,7 @@ class MainActivity: FragmentActivity() {
generalGetString(R.string.auth_confirm_credential)
else
"",
activity = activity,
activity,
completed = { laResult ->
val prefPerformLA = m.controller.appPrefs.performLA
when (laResult) {
@@ -353,17 +340,14 @@ class MainActivity: FragmentActivity() {
generalGetString(R.string.auth_confirm_credential)
else
generalGetString(R.string.auth_disable_simplex_lock),
activity = activity,
activity,
completed = { laResult ->
val prefPerformLA = m.controller.appPrefs.performLA
val selfDestructPref = m.controller.appPrefs.selfDestruct
when (laResult) {
LAResult.Success -> {
m.performLA.value = false
prefPerformLA.set(false)
ksAppPassword.remove()
selfDestructPref.set(false)
ksSelfDestructPassword.remove()
}
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
is LAResult.Error -> {
@@ -392,7 +376,6 @@ fun MainPage(
chatModel: ChatModel,
userAuthorized: MutableState<Boolean?>,
laFailed: MutableState<Boolean>,
destroyedAfterBackPress: MutableState<Boolean>,
runAuthenticate: () -> Unit,
setPerformLA: (Boolean, FragmentActivity) -> Unit,
showLANotice: () -> Unit
@@ -429,38 +412,43 @@ fun MainPage(
}
@Composable
fun AuthView() {
Surface(color = MaterialTheme.colors.background) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(R.string.auth_unlock),
icon = painterResource(R.drawable.ic_lock),
click = {
laFailed.value = false
runAuthenticate()
}
)
}
fun authView() {
Box(
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(R.string.auth_unlock),
icon = painterResource(R.drawable.ic_lock),
click = {
laFailed.value = false
runAuthenticate()
}
)
}
}
Box {
val onboarding = chatModel.onboardingStage.value
val userCreated = chatModel.userCreated.value
var showInitializationView by remember { mutableStateOf(false) }
when {
chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView()
showChatDatabaseError -> {
chatModel.chatDbStatus.value?.let {
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
}
}
onboarding == null || userCreated == null -> SplashView()
userAuthorized.value != true -> {
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
authView()
} else {
SplashView()
}
}
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
Box {
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else {
showAdvertiseLAAlert = true
BoxWithConstraints {
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
@@ -506,41 +494,16 @@ fun MainPage(
}
}
}
}
}
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel)
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
onboarding == OnboardingStage.Step3_SetNotificationsMode -> SetNotificationsMode(chatModel)
}
ModalManager.shared.showInView()
val unauthorized = remember { derivedStateOf { userAuthorized.value != true } }
if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) {
LaunchedEffect(Unit) {
// With these constrains when user presses back button while on ChatList, activity destroys and shows auth request
// while the screen moves to a launcher. Detect it and prevent showing the auth
if (!(destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) {
runAuthenticate()
}
}
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
AuthView()
} else {
SplashView()
}
} else if (chatModel.showCallView.value) {
ActiveCallView(chatModel)
}
ModalManager.shared.showPasscodeInView()
val invitation = chatModel.activeCallInvitation.value
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
AlertManager.shared.showInView()
LaunchedEffect(Unit) {
delay(1000)
if (chatModel.chatDbStatus.value == null) {
showInitializationView = true
}
}
}
DisposableEffectOnRotate {
@@ -552,22 +515,6 @@ fun MainPage(
}
}
@Composable
private fun InitializationView() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
Modifier
.padding(bottom = DEFAULT_PADDING)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
)
Text(stringResource(R.string.opening_database))
}
}
}
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
val userId = getUserIdFromIntent(intent)
when (intent?.action) {
@@ -582,7 +529,7 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
}
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(cInfo, chatModel)
if (cInfo != null) openChat(cInfo, chatModel)
}
}
}
@@ -10,7 +10,6 @@ import chat.simplex.app.ui.theme.DefaultTheme
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.NotificationsMode
import com.jakewharton.processphoenix.ProcessPhoenix
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import java.io.*
@@ -37,11 +36,13 @@ 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
val defaultLocale: Locale = Locale.getDefault()
suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
@@ -52,7 +53,11 @@ class SimplexApp: Application(), LifecycleEventObserver {
val ctrl = if (res is DBMigrationResult.OK) {
migrated[1] as Long
} else null
chatController.ctrl = ctrl
if (::chatController.isInitialized) {
chatController.ctrl = ctrl
} else {
chatController = ChatController(ctrl, ntfManager, applicationContext, appPreferences)
}
chatModel.chatDbEncrypted.value = dbKey != ""
chatModel.chatDbStatus.value = res
if (res != DBMigrationResult.OK) {
@@ -60,22 +65,12 @@ class SimplexApp: Application(), LifecycleEventObserver {
} else if (startChat) {
// If we migrated successfully means previous re-encryption process on database level finished successfully too
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
val user = chatController.apiGetActiveUser()
if (user == null) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
chatModel.currentUser.value = null
chatModel.users.clear()
} else {
val savedOnboardingStage = appPreferences.onboardingStage.get()
chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
OnboardingStage.Step3_CreateSimpleXAddress
withApi {
val user = chatController.apiGetActiveUser()
if (user == null) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
savedOnboardingStage
}
chatController.startChat(user)
// Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
chatController.startChat(user)
chatController.showBackgroundServiceNoticeIfNeeded()
if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
SimplexService.start(applicationContext)
@@ -95,23 +90,13 @@ class SimplexApp: Application(), LifecycleEventObserver {
AppPreferences(applicationContext)
}
val chatController: ChatController by lazy {
ChatController(0L, ntfManager, applicationContext, appPreferences)
}
override fun onCreate() {
super.onCreate()
if (ProcessPhoenix.isPhoenixProcess(this)) {
return;
}
context = this
initChatController()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
withBGApi {
initChatController()
runMigrations()
}
ProcessLifecycleOwner.get().lifecycle.addObserver(this@SimplexApp)
runMigrations()
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
@@ -9,9 +9,9 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.model.ChatController
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// based on:
// https://robertohuertas.com/2019/06/29/android_foreground_services/
@@ -86,7 +86,6 @@ class SimplexService: Service() {
isStartingService = true
withApi {
val chatController = (application as SimplexApp).chatController
waitDbMigrationEnds(chatController)
try {
Log.w(TAG, "Starting foreground service")
val chatDbStatus = chatController.chatModel.chatDbStatus.value
@@ -166,6 +165,7 @@ class SimplexService: Service() {
it.setPackage(packageName)
};
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE);
applicationContext.getSystemService(Context.ALARM_SERVICE);
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
}
@@ -324,18 +324,6 @@ class SimplexService: Service() {
notificationManager.cancel(PASSPHRASE_NOTIFICATION_ID)
}
/*
* When the app starts the database is in migration process. It can take from seconds to tens of seconds.
* It happens in background thread, so other places that relies on database should wait til the end of migration
* */
suspend fun waitDbMigrationEnds(chatController: ChatController) {
var maxWaitTime = 50_000
while (chatController.chatModel.chatDbStatus.value == null && maxWaitTime > 0) {
delay(50)
maxWaitTime -= 50
}
}
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
}
}
@@ -4,6 +4,8 @@ import android.net.Uri
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
@@ -17,16 +19,12 @@ import chat.simplex.app.views.usersettings.NotificationPreviewMode
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
import kotlinx.datetime.*
import kotlinx.datetime.TimeZone
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
import java.io.File
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.*
import kotlin.random.Random
import kotlin.time.*
@@ -43,6 +41,7 @@ class ChatModel(val controller: ChatController) {
val chatDbChanged = mutableStateOf<Boolean>(false)
val chatDbEncrypted = mutableStateOf<Boolean?>(false)
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>()
@@ -76,7 +75,6 @@ class ChatModel(val controller: ChatController) {
val callInvitations = mutableStateMapOf<String, RcvCallInvitation>()
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
val activeCall = mutableStateOf<Call?>(null)
val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
val callCommand = mutableStateOf<WCallCommand?>(null)
val showCallView = mutableStateOf(false)
val switchingCall = mutableStateOf(false)
@@ -240,17 +238,6 @@ class ChatModel(val controller: ChatController) {
}
}
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) {
if (chatId.value == cInfo.id) {
withContext(Dispatchers.Main) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
chatItems[itemIndex] = cItem
}
}
}
}
fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
if (cItem.isRcvNew) {
decreaseCounterInChat(cInfo.id)
@@ -474,8 +461,6 @@ data class User(
val showNotifications: Boolean = activeUser || showNtfs
val addressShared: Boolean = profile.contactLink != null
companion object {
val sampleData = User(
userId = 1,
@@ -695,13 +680,6 @@ sealed class ChatInfo: SomeChat, NamedChat {
private val invalidChatName = generalGetString(R.string.invalid_chat)
}
}
val chatSettings
get() = when(this) {
is Direct -> contact.chatSettings
is Group -> groupInfo.chatSettings
else -> null
}
}
@Serializable
@@ -749,7 +727,6 @@ data class Contact(
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
ChatFeature.Reactions -> mergedPreferences.reactions.enabled.forUser
ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
ChatFeature.Calls -> mergedPreferences.calls.enabled.forUser
}
@@ -757,7 +734,6 @@ data class Contact(
override val displayName get() = localAlias.ifEmpty { profile.displayName }
override val fullName get() = profile.fullName
override val image get() = profile.image
val contactLink: String? = profile.contactLink
override val localAlias get() = profile.localAlias
val verified get() = activeConn.connectionCode != null
@@ -771,14 +747,12 @@ data class Contact(
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Reactions -> mergedPreferences.reactions.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Calls -> mergedPreferences.calls.contactPreference.allow != FeatureAllowed.NO
}
fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Reactions -> mergedPreferences.reactions.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Calls -> mergedPreferences.calls.userPreference.pref.allow != FeatureAllowed.NO
}
@@ -790,7 +764,7 @@ data class Contact(
profile = LocalProfile.sampleData,
activeConn = Connection.sampleData,
contactUsed = true,
chatSettings = ChatSettings(true, false),
chatSettings = ChatSettings(true),
userPreferences = ChatPreferences.sampleData,
mergedPreferences = ContactUserPreferences.sampleData,
createdAt = Clock.System.now(),
@@ -840,7 +814,6 @@ data class Profile(
override val fullName: String,
override val image: String? = null,
override val localAlias : String = "",
val contactLink: String? = null,
val preferences: ChatPreferences? = null
): NamedChat {
val profileViewName: String
@@ -848,7 +821,7 @@ data class Profile(
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
}
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias, contactLink, preferences)
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias, preferences)
companion object {
val sampleData = Profile(
@@ -859,18 +832,17 @@ data class Profile(
}
@Serializable
data class LocalProfile(
class LocalProfile(
val profileId: Long,
override val displayName: String,
override val fullName: String,
override val image: String? = null,
override val localAlias: String,
val contactLink: String? = null,
val preferences: ChatPreferences? = null
): NamedChat {
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias, contactLink, preferences)
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias, preferences)
companion object {
val sampleData = LocalProfile(
@@ -911,7 +883,6 @@ data class GroupInfo (
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
ChatFeature.Reactions -> fullGroupPreferences.reactions.on
ChatFeature.Voice -> fullGroupPreferences.voice.on
ChatFeature.Calls -> false
}
@@ -938,7 +909,7 @@ data class GroupInfo (
fullGroupPreferences = FullGroupPreferences.sampleData,
membership = GroupMember.sampleData,
hostConnCustomUserProfileId = null,
chatSettings = ChatSettings(true, false),
chatSettings = ChatSettings(true),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
@@ -981,7 +952,6 @@ data class GroupMember (
val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName }
val fullName: String get() = memberProfile.fullName
val image: String? get() = memberProfile.image
val contactLink: String? = memberProfile.contactLink
val verified get() = activeConn?.connectionCode != null
val chatViewName: String
@@ -1281,20 +1251,6 @@ class AChatItem (
val chatItem: ChatItem
)
@Serializable
class ACIReaction(
val chatInfo: ChatInfo,
val chatReaction: CIReaction
)
@Serializable
class CIReaction(
val chatDir: CIDirection,
val chatItem: ChatItem,
val sentAt: Instant,
val reaction: MsgReaction
)
@Serializable @Stable
data class ChatItem (
val chatDir: CIDirection,
@@ -1302,7 +1258,6 @@ data class ChatItem (
val content: CIContent,
val formattedText: List<FormattedText>? = null,
val quotedItem: CIQuote? = null,
val reactions: List<CIReactionCount>,
val file: CIFile? = null
) {
val id: Long get() = meta.itemId
@@ -1319,11 +1274,6 @@ data class ChatItem (
val isRcvNew: Boolean get() = meta.isRcvNew
val allowAddReaction: Boolean get() =
meta.itemDeleted == null && !isLiveDummy && (reactions.count { it.userReacted } < 3)
private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID
val memberDisplayName: String? get() =
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
else null
@@ -1413,7 +1363,6 @@ data class ChatItem (
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, itemTimed, editable),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
quotedItem = quotedItem,
reactions = listOf(),
file = file
)
@@ -1429,7 +1378,6 @@ data class ChatItem (
meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead()),
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile(text)),
quotedItem = null,
reactions = listOf(),
file = CIFile.getSample(fileName = fileName, fileSize = fileSize, fileStatus = fileStatus)
)
@@ -1445,7 +1393,6 @@ data class ChatItem (
meta = CIMeta.getSample(id, ts, text, status),
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
quotedItem = null,
reactions = listOf(),
file = null
)
@@ -1455,7 +1402,6 @@ data class ChatItem (
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,
reactions = listOf(),
file = null
)
@@ -1465,7 +1411,6 @@ data class ChatItem (
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,
reactions = listOf(),
file = null
)
@@ -1476,7 +1421,6 @@ data class ChatItem (
meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead()),
content = content,
quotedItem = null,
reactions = listOf(),
file = null
)
}
@@ -1502,7 +1446,6 @@ data class ChatItem (
),
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
quotedItem = null,
reactions = listOf(),
file = null
)
@@ -1523,7 +1466,6 @@ data class ChatItem (
),
content = CIContent.SndMsgContent(MsgContent.MCText("")),
quotedItem = null,
reactions = listOf(),
file = null
)
@@ -1533,7 +1475,6 @@ data class ChatItem (
meta = meta ?: CIMeta.invalidJSON(),
content = CIContent.InvalidJSON(json),
quotedItem = null,
reactions = listOf(),
file = null
)
}
@@ -1631,31 +1572,10 @@ fun getTimestampText(t: Instant): String {
val tz = TimeZone.currentSystemDefault()
val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
val time: LocalDateTime = t.toLocalDateTime(tz)
val period = now.date.minus(time.date)
val recent = now.date == time.date ||
(period.years == 0 && period.months == 0 && period.days == 1 && now.hour < 12 && time.hour >= 18 )
val dateFormatter =
if (recent) {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
} else {
DateTimeFormatter.ofPattern(
when (Locale.getDefault().country) {
"US" -> "M/dd"
"DE" -> "dd.MM"
"RU" -> "dd.MM"
else -> "dd/MM"
}
)
// DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
}
return time.toJavaLocalDateTime().format(dateFormatter)
}
fun localTimestamp(t: Instant): String {
val tz = TimeZone.currentSystemDefault()
val ts: LocalDateTime = t.toLocalDateTime(tz)
val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
return ts.toJavaLocalDateTime().format(dateFormatter)
(now.date.minus(time.date).days == 1 && now.hour < 12 && time.hour >= 18 )
return if (recent) String.format("%02d:%02d", time.hour, time.minute)
else String.format("%02d/%02d", time.dayOfMonth, time.monthNumber)
}
@Serializable
@@ -1670,8 +1590,8 @@ sealed class CIStatus {
@Serializable
sealed class CIDeleted {
@Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted()
@Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted()
@Serializable @SerialName("deleted") class Deleted: CIDeleted()
@Serializable @SerialName("moderated") class Moderated(val byGroupMember: GroupMember): CIDeleted()
}
@Serializable
@@ -1745,18 +1665,18 @@ sealed class CIContent: ItemContent {
companion object {
fun featureText(feature: Feature, enabled: String, param: Int?): String =
if (feature.hasParam) {
"${feature.text}: ${timeText(param)}"
"${feature.text}: ${TimedMessagesPreference.ttlText(param)}"
} else {
"${feature.text}: $enabled"
}
fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when {
allowed != FeatureAllowed.NO && feature.hasParam && param != null ->
String.format(generalGetString(R.string.feature_offered_item_with_param), feature.text, timeText(param))
String.format(generalGetString(R.string.feature_offered_item_with_param), feature.text, TimedMessagesPreference.ttlText(param))
allowed != FeatureAllowed.NO ->
String.format(generalGetString(R.string.feature_offered_item), feature.text, timeText(param))
String.format(generalGetString(R.string.feature_offered_item), feature.text, TimedMessagesPreference.ttlText(param))
else ->
String.format(generalGetString(R.string.feature_cancelled_item), feature.text, timeText(param))
String.format(generalGetString(R.string.feature_cancelled_item), feature.text, TimedMessagesPreference.ttlText(param))
}
}
}
@@ -1803,75 +1723,6 @@ class CIQuote (
}
}
@Serializable
class CIReactionCount(val reaction: MsgReaction, val userReacted: Boolean, val totalReacted: Int)
@Serializable(with = MsgReactionSerializer::class)
sealed class MsgReaction {
@Serializable(with = MsgReactionSerializer::class) class Emoji(val emoji: MREmojiChar): MsgReaction()
@Serializable(with = MsgReactionSerializer::class) class Unknown(val type: String? = null, val json: JsonElement): MsgReaction()
val text: String get() = when (this) {
is Emoji -> when (emoji) {
MREmojiChar.Heart -> "❤️"
else -> emoji.value
}
is Unknown -> ""
}
companion object {
val values: List<MsgReaction> get() = MREmojiChar.values().map(::Emoji)
}
}
object MsgReactionSerializer : KSerializer<MsgReaction> {
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgReaction", PolymorphicKind.SEALED) {
element("Emoji", buildClassSerialDescriptor("Emoji") {
element<String>("emoji")
})
element("Unknown", buildClassSerialDescriptor("Unknown"))
}
override fun deserialize(decoder: Decoder): MsgReaction {
require(decoder is JsonDecoder)
val json = decoder.decodeJsonElement()
return if (json is JsonObject && "type" in json) {
when(val t = json["type"]?.jsonPrimitive?.content ?: "") {
"emoji" -> {
val emoji = Json.decodeFromString<MREmojiChar>(json["emoji"].toString())
if (emoji == null) MsgReaction.Unknown(t, json) else MsgReaction.Emoji(emoji)
}
else -> MsgReaction.Unknown(t, json)
}
} else {
MsgReaction.Unknown("", json)
}
}
override fun serialize(encoder: Encoder, value: MsgReaction) {
require(encoder is JsonEncoder)
val json = when (value) {
is MsgReaction.Emoji ->
buildJsonObject {
put("type", "emoji")
put("emoji", json.encodeToJsonElement(value.emoji))
}
is MsgReaction.Unknown -> value.json
}
encoder.encodeJsonElement(json)
}
}
@Serializable
enum class MREmojiChar(val value: String) {
@SerialName("👍") ThumbsUp("👍"),
@SerialName("👎") ThumbsDown("👎"),
@SerialName("😀") Smile("😀"),
@SerialName("😢") Sad("😢"),
@SerialName("") Heart(""),
@SerialName("🚀") Launch("🚀");
}
@Serializable
class CIFile(
val fileId: Long,
@@ -2359,7 +2210,6 @@ sealed class SndConnEvent {
enum class SwitchPhase {
@SerialName("started") Started,
@SerialName("confirmed") Confirmed,
@SerialName("secured") Secured,
@SerialName("completed") Completed
}
@@ -2393,17 +2243,3 @@ sealed class ChatItemTTL: Comparable<ChatItemTTL?> {
}
}
}
@Serializable
class ChatItemInfo(
val itemVersions: List<ChatItemVersion>,
)
@Serializable
data class ChatItemVersion(
val chatItemVersionId: Long,
val msgContent: MsgContent,
val formattedText: List<FormattedText>?,
val itemVersionTs: Instant,
val createdAt: Instant,
)
@@ -231,10 +231,6 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
manager.cancel(CallNotificationId)
}
fun cancelAllNotifications() {
manager.cancelAll()
}
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
private fun hideSecrets(cItem: ChatItem) : String {
@@ -106,7 +106,6 @@ class AppPreferences(val context: Context) {
)
val privacyFullBackup = mkBoolPreference(SHARED_PREFS_PRIVACY_FULL_BACKUP, false)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
@@ -142,19 +141,14 @@ class AppPreferences(val context: Context) {
val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true)
val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
val onboardingStage = mkEnumPreference(SHARED_PREFS_ONBOARDING_STAGE, OnboardingStage.OnboardingComplete) { OnboardingStage.values().firstOrNull { it.name == this } }
val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true)
val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false)
val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null)
val initializationVectorDBPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE, null)
val encryptedAppPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_APP_PASSPHRASE, null)
val initializationVectorAppPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_APP_PASSPHRASE, null)
val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null)
val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null)
val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true)
val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false)
val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false)
val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null)
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.SIMPLEX.name)
@@ -166,7 +160,6 @@ class AppPreferences(val context: Context) {
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0)
val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300)
private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference(
@@ -250,11 +243,9 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
internal const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage"
private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage"
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
@@ -281,18 +272,13 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE = "InitializationVectorDBPassphrase"
private const val SHARED_PREFS_ENCRYPTED_APP_PASSPHRASE = "EncryptedAppPassphrase"
private const val SHARED_PREFS_INITIALIZATION_VECTOR_APP_PASSPHRASE = "InitializationVectorAppPassphrase"
private const val SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE = "EncryptedSelfDestructPassphrase"
private const val SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE = "InitializationVectorSelfDestructPassphrase"
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades"
private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct"
private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName"
private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme"
private const val SHARED_PREFS_SYSTEM_DARK_THEME = "SystemDarkTheme"
private const val SHARED_PREFS_THEMES = "Themes"
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode"
private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime"
}
}
@@ -340,6 +326,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.userCreated.value = true
apiSetIncognito(chatModel.incognito.value)
getUserChatData()
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
startReceiver()
@@ -446,8 +433,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiCreateActiveUser(p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? {
val r = sendCmd(CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp))
suspend fun apiCreateActiveUser(p: Profile): User? {
val r = sendCmd(CC.CreateActiveUser(p))
if (r is CR.ActiveUser) return r.user
else if (
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName ||
@@ -547,9 +534,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
throw Error("failed to export archive: ${r.responseType} ${r.details}")
}
suspend fun apiImportArchive(config: ArchiveConfig): List<ArchiveError> {
suspend fun apiImportArchive(config: ArchiveConfig) {
val r = sendCmd(CC.ApiImportArchive(config))
if (r is CR.ArchiveImported) return r.archiveErrors
if (r is CR.CmdOk) return
throw Error("failed to import archive: ${r.responseType} ${r.details}")
}
@@ -583,8 +570,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? {
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl)
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false): AChatItem? {
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live)
val r = sendCmd(cmd)
return when (r) {
is CR.NewChatItem -> r.chatItem
@@ -597,16 +584,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
suspend fun apiGetChatItemInfo(type: ChatType, id: Long, itemId: Long): ChatItemInfo? {
return when (val r = sendCmd(CC.ApiGetChatItemInfo(type, id, itemId))) {
is CR.ApiChatItemInfo -> r.chatItemInfo
else -> {
apiErrorAlert("apiGetChatItemInfo", generalGetString(R.string.error_loading_details), r)
null
}
}
}
suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? {
val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc, live))
if (r is CR.ChatItemUpdated) return r.chatItem
@@ -614,13 +591,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiChatItemReaction(type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? {
val r = sendCmd(CC.ApiChatItemReaction(type, id, itemId, add, reaction))
if (r is CR.ChatItemReaction) return r.reaction.chatReaction.chatItem
Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? {
val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode))
if (r is CR.ChatItemDeleted) return r
@@ -738,32 +708,22 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiSwitchContact(contactId: Long): ConnectionStats? {
val r = sendCmd(CC.APISwitchContact(contactId))
if (r is CR.ContactSwitchStarted) return r.connectionStats
apiErrorAlert("apiSwitchContact", generalGetString(R.string.error_changing_address), r)
return null
suspend fun apiSwitchContact(contactId: Long) {
return when (val r = sendCmd(CC.APISwitchContact(contactId))) {
is CR.CmdOk -> {}
else -> {
apiErrorAlert("apiSwitchContact", generalGetString(R.string.connection_error), r)
}
}
}
suspend fun apiSwitchGroupMember(groupId: Long, groupMemberId: Long): ConnectionStats? {
val r = sendCmd(CC.APISwitchGroupMember(groupId, groupMemberId))
if (r is CR.GroupMemberSwitchStarted) return r.connectionStats
apiErrorAlert("apiSwitchGroupMember", generalGetString(R.string.error_changing_address), r)
return null
}
suspend fun apiAbortSwitchContact(contactId: Long): ConnectionStats? {
val r = sendCmd(CC.APIAbortSwitchContact(contactId))
if (r is CR.ContactSwitchAborted) return r.connectionStats
apiErrorAlert("apiAbortSwitchContact", generalGetString(R.string.error_aborting_address_change), r)
return null
}
suspend fun apiAbortSwitchGroupMember(groupId: Long, groupMemberId: Long): ConnectionStats? {
val r = sendCmd(CC.APIAbortSwitchGroupMember(groupId, groupMemberId))
if (r is CR.GroupMemberSwitchAborted) return r.connectionStats
apiErrorAlert("apiAbortSwitchGroupMember", generalGetString(R.string.error_aborting_address_change), r)
return null
suspend fun apiSwitchGroupMember(groupId: Long, groupMemberId: Long) {
return when (val r = sendCmd(CC.APISwitchGroupMember(groupId, groupMemberId))) {
is CR.CmdOk -> {}
else -> {
apiErrorAlert("apiSwitchGroupMember", generalGetString(R.string.error_changing_address), r)
}
}
}
suspend fun apiGetContactCode(contactId: Long): Pair<Contact, String> {
@@ -895,15 +855,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiSetProfileAddress(on: Boolean): User? {
val userId = try { currentUserId("apiSetProfileAddress") } catch (e: Exception) { return null }
return when (val r = sendCmd(CC.ApiSetProfileAddress(userId, on))) {
is CR.UserProfileNoChange -> null
is CR.UserProfileUpdated -> r.user
else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}")
}
}
suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? {
val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs))
if (r is CR.ContactPrefsUpdated) return r.toContact
@@ -939,12 +890,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
suspend fun apiDeleteUserAddress(): User? {
val userId = try { currentUserId("apiDeleteUserAddress") } catch (e: Exception) { return null }
suspend fun apiDeleteUserAddress(): Boolean {
val userId = kotlin.runCatching { currentUserId("apiDeleteUserAddress") }.getOrElse { return false }
val r = sendCmd(CC.ApiDeleteMyAddress(userId))
if (r is CR.UserContactLinkDeleted) return r.user
if (r is CR.UserContactLinkDeleted) return true
Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}")
return null
return false
}
private suspend fun apiGetUserAddress(): UserContactLinkRec? {
@@ -1386,12 +1337,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
val file = cItem.file
val mc = cItem.content.msgContent
if (file != null &&
appPrefs.privacyAcceptImages.get() &&
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
withApi { receiveFile(r.user, file.fileId) }
if (file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) {
val acceptImages = appPrefs.privacyAcceptImages.get()
if ((mc is MsgContent.MCImage && acceptImages)
|| (mc is MsgContent.MCVoice && ((file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && acceptImages) || cInfo is ChatInfo.Group))) {
withApi { receiveFile(r.user, file.fileId) } // TODO check inlineFileMode != IFMSent
}
}
if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
@@ -1409,11 +1360,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
is CR.ChatItemUpdated ->
chatItemSimpleUpdate(r.user, r.chatItem)
is CR.ChatItemReaction -> {
if (active(r.user)) {
chatModel.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
}
}
is CR.ChatItemDeleted -> {
if (!active(r.user)) {
if (r.toChatItem == null && r.deletedChatItem.chatItem.isRcvNew && r.deletedChatItem.chatInfo.ntfsEnabled) {
@@ -1492,14 +1438,10 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
if (active(r.user)) {
chatModel.upsertGroupMember(r.groupInfo, r.member)
}
is CR.ConnectedToGroupMember -> {
is CR.ConnectedToGroupMember ->
if (active(r.user)) {
chatModel.upsertGroupMember(r.groupInfo, r.member)
}
if (r.memberContact != null) {
chatModel.setContactNetworkStatus(r.memberContact, NetworkStatus.Connected())
}
}
is CR.GroupUpdated ->
if (active(r.user)) {
chatModel.updateGroup(r.toGroup)
@@ -1861,9 +1803,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
)
}
/**
* [AppPreferences.networkProxyHostPort] is not changed here, use appPrefs to set it
* */
fun setNetCfg(cfg: NetCfg) {
appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy)
appPrefs.networkHostMode.set(cfg.hostMode.name)
@@ -1901,7 +1840,7 @@ class SharedPreference<T>(val get: () -> T, set: (T) -> Unit) {
sealed class CC {
class Console(val cmd: String): CC()
class ShowActiveUser: CC()
class CreateActiveUser(val profile: Profile?, val sameServers: Boolean, val pastTimestamp: Boolean): CC()
class CreateActiveUser(val profile: Profile): CC()
class ListUsers: CC()
class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC()
class ApiHideUser(val userId: Long, val viewPwd: String): CC()
@@ -1921,12 +1860,10 @@ sealed class CC {
class ApiStorageEncryption(val config: DBEncryptionConfig): CC()
class ApiGetChats(val userId: Long): CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC()
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
@@ -1951,8 +1888,6 @@ sealed class CC {
class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC()
class APISwitchContact(val contactId: Long): CC()
class APISwitchGroupMember(val groupId: Long, val groupMemberId: Long): CC()
class APIAbortSwitchContact(val contactId: Long): CC()
class APIAbortSwitchGroupMember(val groupId: Long, val groupMemberId: Long): CC()
class APIGetContactCode(val contactId: Long): CC()
class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC()
class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC()
@@ -1970,7 +1905,6 @@ sealed class CC {
class ApiCreateMyAddress(val userId: Long): CC()
class ApiDeleteMyAddress(val userId: Long): CC()
class ApiShowMyAddress(val userId: Long): CC()
class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC()
class ApiAddressAutoAccept(val userId: Long, val autoAccept: AutoAccept?): CC()
class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC()
class ApiRejectCall(val contact: Contact): CC()
@@ -1990,10 +1924,7 @@ sealed class CC {
val cmdString: String get() = when (this) {
is Console -> cmd
is ShowActiveUser -> "/u"
is CreateActiveUser -> {
val user = NewUser(profile, sameServers = sameServers, pastTimestamp = pastTimestamp)
"/_create user ${json.encodeToString(user)}"
}
is CreateActiveUser -> "/create user ${profile.displayName} ${profile.fullName}"
is ListUsers -> "/users"
is ApiSetActiveUser -> "/_user $userId${maybePwd(viewPwd)}"
is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}"
@@ -2013,15 +1944,10 @@ sealed class CC {
is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}"
is ApiGetChats -> "/_get chats $userId pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId"
is ApiSendMessage -> {
val ttlStr = if (ttl != null) "$ttl" else "default"
"/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
}
is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId"
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
@@ -2046,8 +1972,6 @@ sealed class CC {
is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
is APISwitchContact -> "/_switch @$contactId"
is APISwitchGroupMember -> "/_switch #$groupId $groupMemberId"
is APIAbortSwitchContact -> "/_abort switch @$contactId"
is APIAbortSwitchGroupMember -> "/_abort switch #$groupId $groupMemberId"
is APIGetContactCode -> "/_get code @$contactId"
is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId"
is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else ""
@@ -2065,7 +1989,6 @@ sealed class CC {
is ApiCreateMyAddress -> "/_address $userId"
is ApiDeleteMyAddress -> "/_delete_address $userId"
is ApiShowMyAddress -> "/_show_address $userId"
is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}"
is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}"
is ApiAcceptContact -> "/_accept $contactReqId"
is ApiRejectContact -> "/_reject $contactReqId"
@@ -2106,12 +2029,10 @@ sealed class CC {
is ApiStorageEncryption -> "apiStorageEncryption"
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiGetChatItemInfo -> "apiGetChatItemInfo"
is ApiSendMessage -> "apiSendMessage"
is ApiUpdateChatItem -> "apiUpdateChatItem"
is ApiDeleteChatItem -> "apiDeleteChatItem"
is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem"
is ApiChatItemReaction -> "apiChatItemReaction"
is ApiNewGroup -> "apiNewGroup"
is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup"
@@ -2136,8 +2057,6 @@ sealed class CC {
is APIGroupMemberInfo -> "apiGroupMemberInfo"
is APISwitchContact -> "apiSwitchContact"
is APISwitchGroupMember -> "apiSwitchGroupMember"
is APIAbortSwitchContact -> "apiAbortSwitchContact"
is APIAbortSwitchGroupMember -> "apiAbortSwitchGroupMember"
is APIGetContactCode -> "apiGetContactCode"
is APIGetGroupMemberCode -> "apiGetGroupMemberCode"
is APIVerifyContact -> "apiVerifyContact"
@@ -2155,7 +2074,6 @@ sealed class CC {
is ApiCreateMyAddress -> "apiCreateMyAddress"
is ApiDeleteMyAddress -> "apiDeleteMyAddress"
is ApiShowMyAddress -> "apiShowMyAddress"
is ApiSetProfileAddress -> "apiSetProfileAddress"
is ApiAddressAutoAccept -> "apiAddressAutoAccept"
is ApiAcceptContact -> "apiAcceptContact"
is ApiRejectContact -> "apiRejectContact"
@@ -2210,13 +2128,6 @@ sealed class CC {
}
}
@Serializable
data class NewUser(
val profile: Profile?,
val sameServers: Boolean,
val pastTimestamp: Boolean
)
sealed class ChatPagination {
class Last(val count: Int): ChatPagination()
class After(val chatItemId: Long, val count: Int): ChatPagination()
@@ -2432,15 +2343,6 @@ data class NetCfg(
val useSocksProxy: Boolean get() = socksProxy != null
val enableKeepAlive: Boolean get() = tcpKeepAlive != null
fun withHostPort(hostPort: String?, default: String? = ":9050"): NetCfg {
val socksProxy = if (hostPort?.startsWith("localhost:") == true) {
hostPort.removePrefix("localhost")
} else {
hostPort ?: default
}
return copy(socksProxy = socksProxy)
}
companion object {
val defaults: NetCfg =
NetCfg(
@@ -2521,35 +2423,22 @@ data class KeepAliveOpts(
@Serializable
data class ChatSettings(
val enableNtfs: Boolean,
val favorite: Boolean
) {
companion object {
val defaults: ChatSettings = ChatSettings(enableNtfs = true, favorite = false)
}
}
val enableNtfs: Boolean
)
@Serializable
data class FullChatPreferences(
val timedMessages: TimedMessagesPreference,
val fullDelete: SimpleChatPreference,
val reactions: SimpleChatPreference,
val voice: SimpleChatPreference,
val calls: SimpleChatPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(
timedMessages = timedMessages,
fullDelete = fullDelete,
reactions = reactions,
voice = voice,
calls = calls
)
fun toPreferences(): ChatPreferences = ChatPreferences(timedMessages = timedMessages, fullDelete = fullDelete, voice = voice, calls = calls)
companion object {
val sampleData = FullChatPreferences(
timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO),
fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
reactions = SimpleChatPreference(allow = FeatureAllowed.YES),
voice = SimpleChatPreference(allow = FeatureAllowed.YES),
calls = SimpleChatPreference(allow = FeatureAllowed.YES),
)
@@ -2560,7 +2449,6 @@ data class FullChatPreferences(
data class ChatPreferences(
val timedMessages: TimedMessagesPreference?,
val fullDelete: SimpleChatPreference?,
val reactions: SimpleChatPreference?,
val voice: SimpleChatPreference?,
val calls: SimpleChatPreference?,
) {
@@ -2568,7 +2456,6 @@ data class ChatPreferences(
when (feature) {
ChatFeature.TimedMessages -> this.copy(timedMessages = TimedMessagesPreference(allow = allowed, ttl = param ?: this.timedMessages?.ttl))
ChatFeature.FullDelete -> this.copy(fullDelete = SimpleChatPreference(allow = allowed))
ChatFeature.Reactions -> this.copy(reactions = SimpleChatPreference(allow = allowed))
ChatFeature.Voice -> this.copy(voice = SimpleChatPreference(allow = allowed))
ChatFeature.Calls -> this.copy(calls = SimpleChatPreference(allow = allowed))
}
@@ -2577,7 +2464,6 @@ data class ChatPreferences(
val sampleData = ChatPreferences(
timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO),
fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
reactions = SimpleChatPreference(allow = FeatureAllowed.YES),
voice = SimpleChatPreference(allow = FeatureAllowed.YES),
calls = SimpleChatPreference(allow = FeatureAllowed.YES),
)
@@ -2600,115 +2486,62 @@ data class TimedMessagesPreference(
): ChatPreference {
companion object {
val ttlValues: List<Int?>
get() = listOf(600, 3600, 86400, 7 * 86400, 30 * 86400, 3 * 30 * 86400, null)
}
}
get() = listOf(30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, null)
sealed class CustomTimeUnit {
object Second: CustomTimeUnit()
object Minute: CustomTimeUnit()
object Hour: CustomTimeUnit()
object Day: CustomTimeUnit()
object Week: CustomTimeUnit()
object Month: CustomTimeUnit()
val toSeconds: Int
get() =
when (this) {
Second -> 1
Minute -> 60
Hour -> 3600
Day -> 86400
Week -> 7 * 86400
Month -> 30 * 86400
}
val text: String
get() =
when (this) {
Second -> generalGetString(R.string.custom_time_unit_seconds)
Minute -> generalGetString(R.string.custom_time_unit_minutes)
Hour -> generalGetString(R.string.custom_time_unit_hours)
Day -> generalGetString(R.string.custom_time_unit_days)
Week -> generalGetString(R.string.custom_time_unit_weeks)
Month -> generalGetString(R.string.custom_time_unit_months)
}
companion object {
fun toTimeUnit(seconds: Int): Pair<CustomTimeUnit, Int> {
val tryUnits = listOf(Month, Week, Day, Hour, Minute)
var selectedUnit: Pair<CustomTimeUnit, Int>? = null
for (unit in tryUnits) {
val (v, r) = divMod(seconds, unit.toSeconds)
if (r == 0) {
selectedUnit = Pair(unit, v)
break
}
}
return selectedUnit ?: Pair(Second, seconds)
fun ttlText(ttl: Int?): String {
ttl ?: return generalGetString(R.string.feature_off)
if (ttl == 0) return String.format(generalGetString(R.string.ttl_sec), 0)
val (m_, s) = divMod(ttl, 60)
val (h_, m) = divMod(m_, 60)
val (d_, h) = divMod(h_, 24)
val (mm, d) = divMod(d_, 30)
return maybe(mm, if (mm == 1) String.format(generalGetString(R.string.ttl_month), 1) else String.format(generalGetString(R.string.ttl_months), mm)) +
maybe(d, if (d == 1) String.format(generalGetString(R.string.ttl_day), 1) else if (d == 7) String.format(generalGetString(R.string.ttl_week), 1) else if (d == 14) String.format(generalGetString(R.string.ttl_weeks), 2) else String.format(generalGetString(R.string.ttl_days), d)) +
maybe(h, if (h == 1) String.format(generalGetString(R.string.ttl_hour), 1) else String.format(generalGetString(R.string.ttl_hours), h)) +
maybe(m, String.format(generalGetString(R.string.ttl_min), m)) +
maybe(s, String.format(generalGetString(R.string.ttl_sec), s))
}
private fun divMod(n: Int, d: Int): Pair<Int, Int> =
fun shortTtlText(ttl: Int?): String {
ttl ?: return generalGetString(R.string.feature_off)
val m = ttl / 60
if (m == 0) {
return String.format(generalGetString(R.string.ttl_s), ttl)
}
val h = m / 60
if (h == 0) {
return String.format(generalGetString(R.string.ttl_m), m)
}
val d = h / 24
if (d == 0) {
return String.format(generalGetString(R.string.ttl_h), h)
}
val mm = d / 30
if (mm > 0) {
return String.format(generalGetString(R.string.ttl_mth), mm)
}
val w = d / 7
return if (w == 0 || d % 7 != 0) String.format(generalGetString(R.string.ttl_d), d) else String.format(generalGetString(R.string.ttl_w), w)
}
fun divMod(n: Int, d: Int): Pair<Int, Int> =
n / d to n % d
fun toText(seconds: Int): String {
val (unit, value) = toTimeUnit(seconds)
return when (unit) {
Second -> String.format(generalGetString(R.string.ttl_sec), value)
Minute -> String.format(generalGetString(R.string.ttl_min), value)
Hour -> if (value == 1) String.format(generalGetString(R.string.ttl_hour), 1) else String.format(generalGetString(R.string.ttl_hours), value)
Day -> if (value == 1) String.format(generalGetString(R.string.ttl_day), 1) else String.format(generalGetString(R.string.ttl_days), value)
Week -> if (value == 1) String.format(generalGetString(R.string.ttl_week), 1) else String.format(generalGetString(R.string.ttl_weeks), value)
Month -> if (value == 1) String.format(generalGetString(R.string.ttl_month), 1) else String.format(generalGetString(R.string.ttl_months), value)
}
}
fun toShortText(seconds: Int): String {
val (unit, value) = toTimeUnit(seconds)
return when (unit) {
Second -> String.format(generalGetString(R.string.ttl_s), value)
Minute -> String.format(generalGetString(R.string.ttl_m), value)
Hour -> String.format(generalGetString(R.string.ttl_h), value)
Day -> String.format(generalGetString(R.string.ttl_d), value)
Week -> String.format(generalGetString(R.string.ttl_w), value)
Month -> String.format(generalGetString(R.string.ttl_mth), value)
}
}
fun maybe(n: Int, s: String): String =
if (n == 0) "" else s
}
}
fun timeText(seconds: Int?): String {
if (seconds == null) {
return generalGetString(R.string.feature_off)
}
if (seconds == 0) {
String.format(generalGetString(R.string.ttl_sec), 0)
}
return CustomTimeUnit.toText(seconds)
}
fun shortTimeText(seconds: Int?): String {
if (seconds == null) {
return generalGetString(R.string.feature_off)
}
if (seconds == 0) {
String.format(generalGetString(R.string.ttl_s), 0)
}
return CustomTimeUnit.toShortText(seconds)
}
@Serializable
data class ContactUserPreferences(
val timedMessages: ContactUserPreferenceTimed,
val fullDelete: ContactUserPreference,
val reactions: ContactUserPreference,
val voice: ContactUserPreference,
val calls: ContactUserPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(
timedMessages = timedMessages.userPreference.pref,
fullDelete = fullDelete.userPreference.pref,
reactions = reactions.userPreference.pref,
voice = voice.userPreference.pref,
calls = calls.userPreference.pref
)
@@ -2725,11 +2558,6 @@ data class ContactUserPreferences(
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.NO)),
contactPreference = SimpleChatPreference(allow = FeatureAllowed.NO)
),
reactions = ContactUserPreference(
enabled = FeatureEnabled(forUser = true, forContact = true),
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)),
contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES)
),
voice = ContactUserPreference(
enabled = FeatureEnabled(forUser = true, forContact = true),
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)),
@@ -2826,7 +2654,6 @@ interface Feature {
enum class ChatFeature: Feature {
@SerialName("timedMessages") TimedMessages,
@SerialName("fullDelete") FullDelete,
@SerialName("reactions") Reactions,
@SerialName("voice") Voice,
@SerialName("calls") Calls;
@@ -2844,7 +2671,6 @@ enum class ChatFeature: Feature {
get() = when(this) {
TimedMessages -> generalGetString(R.string.timed_messages)
FullDelete -> generalGetString(R.string.full_deletion)
Reactions -> generalGetString(R.string.message_reactions)
Voice -> generalGetString(R.string.voice_messages)
Calls -> generalGetString(R.string.audio_video_calls)
}
@@ -2853,7 +2679,6 @@ enum class ChatFeature: Feature {
@Composable get() = when(this) {
TimedMessages -> painterResource(R.drawable.ic_timer)
FullDelete -> painterResource(R.drawable.ic_delete_forever)
Reactions -> painterResource(R.drawable.ic_add_reaction)
Voice -> painterResource(R.drawable.ic_keyboard_voice)
Calls -> painterResource(R.drawable.ic_call)
}
@@ -2862,7 +2687,6 @@ enum class ChatFeature: Feature {
override fun iconFilled(): Painter = when(this) {
TimedMessages -> painterResource(R.drawable.ic_timer_filled)
FullDelete -> painterResource(R.drawable.ic_delete_forever_filled)
Reactions -> painterResource(R.drawable.ic_add_reaction_filled)
Voice -> painterResource(R.drawable.ic_keyboard_voice_filled)
Calls -> painterResource(R.drawable.ic_call_filled)
}
@@ -2879,12 +2703,7 @@ enum class ChatFeature: Feature {
FeatureAllowed.YES -> generalGetString(R.string.allow_irreversible_message_deletion_only_if)
FeatureAllowed.NO -> generalGetString(R.string.contacts_can_mark_messages_for_deletion)
}
Reactions -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_adding_message_reactions)
FeatureAllowed.YES -> generalGetString(R.string.allow_message_reactions_only_if)
FeatureAllowed.NO -> generalGetString(R.string.prohibit_message_reactions)
}
Voice -> when (allowed) {
Voice -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_to_send_voice_messages)
FeatureAllowed.YES -> generalGetString(R.string.allow_voice_messages_only_if)
FeatureAllowed.NO -> generalGetString(R.string.prohibit_sending_voice_messages)
@@ -2910,12 +2729,6 @@ enum class ChatFeature: Feature {
enabled.forContact -> generalGetString(R.string.only_your_contact_can_delete)
else -> generalGetString(R.string.message_deletion_prohibited)
}
Reactions -> when {
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_add_message_reactions)
enabled.forUser -> generalGetString(R.string.only_you_can_add_message_reactions)
enabled.forContact -> generalGetString(R.string.only_your_contact_can_add_message_reactions)
else -> generalGetString(R.string.message_reactions_prohibited_in_this_chat)
}
Voice -> when {
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_send_voice)
enabled.forUser -> generalGetString(R.string.only_you_can_send_voice)
@@ -2936,9 +2749,7 @@ enum class GroupFeature: Feature {
@SerialName("timedMessages") TimedMessages,
@SerialName("directMessages") DirectMessages,
@SerialName("fullDelete") FullDelete,
@SerialName("reactions") Reactions,
@SerialName("voice") Voice,
@SerialName("files") Files;
@SerialName("voice") Voice;
override val hasParam: Boolean get() = when(this) {
TimedMessages -> true
@@ -2950,9 +2761,7 @@ enum class GroupFeature: Feature {
TimedMessages -> generalGetString(R.string.timed_messages)
DirectMessages -> generalGetString(R.string.direct_messages)
FullDelete -> generalGetString(R.string.full_deletion)
Reactions -> generalGetString(R.string.message_reactions)
Voice -> generalGetString(R.string.voice_messages)
Files -> generalGetString(R.string.files_and_media)
}
val icon: Painter
@@ -2960,9 +2769,7 @@ enum class GroupFeature: Feature {
TimedMessages -> painterResource(R.drawable.ic_timer)
DirectMessages -> painterResource(R.drawable.ic_swap_horizontal_circle)
FullDelete -> painterResource(R.drawable.ic_delete_forever)
Reactions -> painterResource(R.drawable.ic_add_reaction)
Voice -> painterResource(R.drawable.ic_keyboard_voice)
Files -> painterResource(R.drawable.ic_draft)
}
@Composable
@@ -2970,9 +2777,7 @@ enum class GroupFeature: Feature {
TimedMessages -> painterResource(R.drawable.ic_timer_filled)
DirectMessages -> painterResource(R.drawable.ic_swap_horizontal_circle_filled)
FullDelete -> painterResource(R.drawable.ic_delete_forever_filled)
Reactions -> painterResource(R.drawable.ic_add_reaction_filled)
Voice -> painterResource(R.drawable.ic_keyboard_voice_filled)
Files -> painterResource(R.drawable.ic_draft_filled)
}
fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String =
@@ -2990,18 +2795,10 @@ enum class GroupFeature: Feature {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_delete_messages)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_deletion)
}
Reactions -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_message_reactions)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_reactions_group)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_voice)
}
Files -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_files)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_files)
}
}
} else {
when(this) {
@@ -3017,18 +2814,10 @@ enum class GroupFeature: Feature {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_delete)
GroupFeatureEnabled.OFF -> generalGetString(R.string.message_deletion_prohibited_in_chat)
}
Reactions -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_add_message_reactions)
GroupFeatureEnabled.OFF -> generalGetString(R.string.message_reactions_are_prohibited)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.voice_messages_are_prohibited)
}
Files -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_files)
GroupFeatureEnabled.OFF -> generalGetString(R.string.files_are_prohibited_in_group)
}
}
}
}
@@ -3065,7 +2854,6 @@ data class ContactFeaturesAllowed(
val timedMessagesAllowed: Boolean,
val timedMessagesTTL: Int?,
val fullDelete: ContactFeatureAllowed,
val reactions: ContactFeatureAllowed,
val voice: ContactFeatureAllowed,
val calls: ContactFeatureAllowed,
) {
@@ -3074,7 +2862,6 @@ data class ContactFeaturesAllowed(
timedMessagesAllowed = false,
timedMessagesTTL = null,
fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO),
reactions = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES),
voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES),
calls = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES),
)
@@ -3088,7 +2875,6 @@ fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPrefere
timedMessagesAllowed = allow == FeatureAllowed.YES || allow == FeatureAllowed.ALWAYS,
timedMessagesTTL = pref.pref.ttl,
fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
reactions = contactUserPrefToFeatureAllowed(contactUserPreferences.reactions),
voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice),
calls = contactUserPrefToFeatureAllowed(contactUserPreferences.calls),
)
@@ -3108,7 +2894,6 @@ fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed
ChatPreferences(
timedMessages = TimedMessagesPreference(if (contactFeaturesAllowed.timedMessagesAllowed) FeatureAllowed.YES else FeatureAllowed.NO, contactFeaturesAllowed.timedMessagesTTL),
fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
reactions = contactFeatureAllowedToPref(contactFeaturesAllowed.reactions),
voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice),
calls = contactFeatureAllowedToPref(contactFeaturesAllowed.calls),
)
@@ -3140,28 +2925,17 @@ data class FullGroupPreferences(
val timedMessages: TimedMessagesGroupPreference,
val directMessages: GroupPreference,
val fullDelete: GroupPreference,
val reactions: GroupPreference,
val voice: GroupPreference,
val files: GroupPreference,
val voice: GroupPreference
) {
fun toGroupPreferences(): GroupPreferences =
GroupPreferences(
timedMessages = timedMessages,
directMessages = directMessages,
fullDelete = fullDelete,
reactions = reactions,
voice = voice,
files = files,
)
GroupPreferences(timedMessages = timedMessages, directMessages = directMessages, fullDelete = fullDelete, voice = voice)
companion object {
val sampleData = FullGroupPreferences(
timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
directMessages = GroupPreference(GroupFeatureEnabled.OFF),
fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
reactions = GroupPreference(GroupFeatureEnabled.ON),
voice = GroupPreference(GroupFeatureEnabled.ON),
files = GroupPreference(GroupFeatureEnabled.ON),
voice = GroupPreference(GroupFeatureEnabled.ON)
)
}
}
@@ -3171,18 +2945,14 @@ data class GroupPreferences(
val timedMessages: TimedMessagesGroupPreference?,
val directMessages: GroupPreference?,
val fullDelete: GroupPreference?,
val reactions: GroupPreference?,
val voice: GroupPreference?,
val files: GroupPreference?,
val voice: GroupPreference?
) {
companion object {
val sampleData = GroupPreferences(
timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
directMessages = GroupPreference(GroupFeatureEnabled.OFF),
fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
reactions = GroupPreference(GroupFeatureEnabled.ON),
voice = GroupPreference(GroupFeatureEnabled.ON),
files = GroupPreference(GroupFeatureEnabled.ON),
voice = GroupPreference(GroupFeatureEnabled.ON)
)
}
}
@@ -3311,17 +3081,12 @@ sealed class CR {
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
@Serializable @SerialName("apiChats") class ApiChats(val user: User, val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val user: User, val chat: Chat): CR()
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: User, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
@Serializable @SerialName("userProtoServers") class UserProtoServers(val user: User, val servers: UserProtocolServers): CR()
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: User, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
@Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: User, val chatItemTTL: Long? = null): CR()
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
@Serializable @SerialName("contactInfo") class ContactInfo(val user: User, val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR()
@Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats? = null): CR()
@Serializable @SerialName("contactSwitchStarted") class ContactSwitchStarted(val user: User, val contact: Contact, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("groupMemberSwitchStarted") class GroupMemberSwitchStarted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("contactSwitchAborted") class ContactSwitchAborted(val user: User, val contact: Contact, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("groupMemberSwitchAborted") class GroupMemberSwitchAborted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR()
@Serializable @SerialName("contactCode") class ContactCode(val user: User, val contact: Contact, val connectionCode: String): CR()
@Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR()
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: User, val verified: Boolean, val expectedCode: String): CR()
@@ -3358,7 +3123,6 @@ sealed class CR {
@Serializable @SerialName("newChatItem") class NewChatItem(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: User, val added: Boolean, val reaction: ACIReaction): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: User, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR()
@Serializable @SerialName("contactsList") class ContactsList(val user: User, val contacts: List<Contact>): CR()
// group events
@@ -3381,7 +3145,7 @@ sealed class CR {
@Serializable @SerialName("groupInvitation") class GroupInvitation(val user: User, val groupInfo: GroupInfo): CR() // unused
@Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: User, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR()
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("groupRemoved") class GroupRemoved(val user: User, val groupInfo: GroupInfo): CR() // unused
@Serializable @SerialName("groupUpdated") class GroupUpdated(val user: User, val toGroup: GroupInfo): CR()
@Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR()
@@ -3417,7 +3181,6 @@ sealed class CR {
@Serializable @SerialName("cmdOk") class CmdOk(val user: User?): CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: User?, val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val user_: User?, val chatError: ChatError): CR()
@Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List<ArchiveError>): CR()
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
@@ -3429,17 +3192,12 @@ sealed class CR {
is ChatStopped -> "chatStopped"
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
is ApiChatItemInfo -> "chatItemInfo"
is UserProtoServers -> "userProtoServers"
is ServerTestResult -> "serverTestResult"
is ChatItemTTL -> "chatItemTTL"
is NetworkConfig -> "networkConfig"
is ContactInfo -> "contactInfo"
is GroupMemberInfo -> "groupMemberInfo"
is ContactSwitchStarted -> "contactSwitchStarted"
is GroupMemberSwitchStarted -> "groupMemberSwitchStarted"
is ContactSwitchAborted -> "contactSwitchAborted"
is GroupMemberSwitchAborted -> "groupMemberSwitchAborted"
is ContactCode -> "contactCode"
is GroupMemberCode -> "groupMemberCode"
is ConnectionVerified -> "connectionVerified"
@@ -3476,7 +3234,6 @@ sealed class CR {
is NewChatItem -> "newChatItem"
is ChatItemStatusUpdated -> "chatItemStatusUpdated"
is ChatItemUpdated -> "chatItemUpdated"
is ChatItemReaction -> "chatItemReaction"
is ChatItemDeleted -> "chatItemDeleted"
is ContactsList -> "contactsList"
is GroupCreated -> "groupCreated"
@@ -3531,7 +3288,6 @@ sealed class CR {
is CmdOk -> "cmdOk"
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
is ArchiveImported -> "archiveImported"
is Response -> "* $type"
is Invalid -> "* invalid json"
}
@@ -3544,17 +3300,12 @@ sealed class CR {
is ChatStopped -> noDetails()
is ApiChats -> withUser(user, json.encodeToString(chats))
is ApiChat -> withUser(user, json.encodeToString(chat))
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(AChatItem)}\n${json.encodeToString(chatItemInfo)}")
is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}")
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL))
is NetworkConfig -> json.encodeToString(networkConfig)
is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}")
is GroupMemberInfo -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}")
is ContactSwitchStarted -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}")
is GroupMemberSwitchStarted -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats)}")
is ContactSwitchAborted -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}")
is GroupMemberSwitchAborted -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats)}")
is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode")
is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode")
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
@@ -3592,7 +3343,6 @@ sealed class CR {
is NewChatItem -> withUser(user, json.encodeToString(chatItem))
is ChatItemStatusUpdated -> withUser(user, json.encodeToString(chatItem))
is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem))
is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}")
is ChatItemDeleted -> withUser(user, "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser")
is ContactsList -> withUser(user, json.encodeToString(contacts))
is GroupCreated -> withUser(user, json.encodeToString(groupInfo))
@@ -3614,7 +3364,7 @@ sealed class CR {
is GroupInvitation -> withUser(user, json.encodeToString(groupInfo))
is UserJoinedGroup -> withUser(user, json.encodeToString(groupInfo))
is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact")
is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is GroupRemoved -> withUser(user, json.encodeToString(groupInfo))
is GroupUpdated -> withUser(user, json.encodeToString(toGroup))
is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole")
@@ -3648,7 +3398,6 @@ sealed class CR {
is CmdOk -> withUser(user, noDetails())
is ChatCmdError -> withUser(user_, chatError.string)
is ChatRespError -> withUser(user_, chatError.string)
is ArchiveImported -> "${archiveErrors.map { it.string } }"
is Response -> json
is Invalid -> str
}
@@ -3686,34 +3435,7 @@ abstract class TerminalItem {
}
@Serializable
class ConnectionStats(val rcvQueuesInfo: List<RcvQueueInfo>, val sndQueuesInfo: List<SndQueueInfo>)
@Serializable
class RcvQueueInfo(
val rcvServer: String,
val rcvSwitchStatus: RcvSwitchStatus?,
var canAbortSwitch: Boolean
)
@Serializable
enum class RcvSwitchStatus {
@SerialName("switch_started") SwitchStarted,
@SerialName("sending_qadd") SendingQADD,
@SerialName("sending_quse") SendingQUSE,
@SerialName("received_message") ReceivedMessage
}
@Serializable
class SndQueueInfo(
val sndServer: String,
val sndSwitchStatus: SndSwitchStatus?
)
@Serializable
enum class SndSwitchStatus {
@SerialName("sending_qkey") SendingQKEY,
@SerialName("sending_qtest") SendingQTEST
}
class ConnectionStats(val rcvServers: List<String>?, val sndServers: List<String>?)
@Serializable
class UserContactLinkRec(val connReqContact: String, val autoAccept: AutoAccept? = null) {
@@ -3764,7 +3486,6 @@ sealed class ChatErrorType {
is InvalidConnReq -> "invalidConnReq"
is FileAlreadyReceiving -> "fileAlreadyReceiving"
is СommandError -> "commandError $message"
is CEException -> "exception $message"
}
@Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType()
@Serializable @SerialName("differentActiveUser") class DifferentActiveUser: ChatErrorType()
@@ -3772,7 +3493,6 @@ sealed class ChatErrorType {
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
@Serializable @SerialName("fileAlreadyReceiving") class FileAlreadyReceiving: ChatErrorType()
@Serializable @SerialName("commandError") class СommandError(val message: String): ChatErrorType()
@Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType()
}
@Serializable
@@ -3984,13 +3704,3 @@ sealed class XFTPErrorType {
@Serializable @SerialName("FILE_IO") object FILE_IO: XFTPErrorType()
@Serializable @SerialName("INTERNAL") object INTERNAL: XFTPErrorType()
}
@Serializable
sealed class ArchiveError {
val string: String get() = when (this) {
is ArchiveErrorImport -> "import ${chatError.string}"
is ArchiveErrorImportFile -> "importFile $file ${chatError.string}"
}
@Serializable @SerialName("import") class ArchiveErrorImport(val chatError: ChatError): ArchiveError()
@Serializable @SerialName("importFile") class ArchiveErrorImportFile(val file: String, val chatError: ChatError): ArchiveError()
}
@@ -21,6 +21,7 @@ val ToolbarDark = Color(80, 80, 80, 12)
val SettingsSecondaryLight = Color(200, 196, 195, 90)
val GroupDark = Color(80, 80, 80, 60)
val IncomingCallLight = Color(239, 237, 236, 255)
val IncomingCallDark = Color(34, 30, 29, 255)
val WarningOrange = Color(255, 127, 0, 255)
val WarningYellow = Color(255, 192, 0, 255)
val FileLight = Color(183, 190, 199, 255)
@@ -16,7 +16,6 @@ import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.internal.toHexString
enum class DefaultTheme {
SYSTEM, LIGHT, DARK, SIMPLEX;
@@ -129,29 +128,11 @@ data class ThemeColors(
receivedMessage = receivedMessage?.colorFromReadableHex() ?: baseColors.receivedMessage,
)
}
fun withFilledColors(base: DefaultTheme): ThemeColors {
val c = toColors(base)
val ac = toAppColors(base)
return ThemeColors(
primary = c.primary.toReadableHex(),
primaryVariant = c.primaryVariant.toReadableHex(),
secondary = c.secondary.toReadableHex(),
secondaryVariant = c.secondaryVariant.toReadableHex(),
background = c.background.toReadableHex(),
surface = c.surface.toReadableHex(),
title = ac.title.toReadableHex(),
sentMessage = ac.sentMessage.toReadableHex(),
receivedMessage = ac.receivedMessage.toReadableHex()
)
}
}
private fun String.colorFromReadableHex(): Color =
Color(this.replace("#", "").toLongOrNull(16) ?: Color.White.toArgb().toLong())
private fun Color.toReadableHex(): String = "#" + toArgb().toHexString()
@Serializable
data class ThemeOverrides (
val base: DefaultTheme,
@@ -172,6 +153,9 @@ data class ThemeOverrides (
}
}
@Serializable
data class ThemeData (val colors: ThemeColors)
fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, shape: Shape = RectangleShape): Modifier {
return if (baseTheme == DefaultTheme.SIMPLEX) {
this.background(brush = Brush.linearGradient(
@@ -44,16 +44,19 @@ object ThemeManager {
return ActiveTheme(themeName, baseTheme.first, theme.colors.toColors(theme.base), theme.colors.toAppColors(theme.base))
}
fun currentThemeOverridesForExport(darkForSystemTheme: Boolean): ThemeOverrides {
val themeName = appPrefs.currentTheme.get()!!
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
themeName
} else {
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
}
val overrides = appPrefs.themeOverrides.get().toMutableMap()
val nonFilledTheme = overrides[nonSystemThemeName] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
return nonFilledTheme.copy(colors = nonFilledTheme.colors.withFilledColors(CurrentColors.value.base))
fun currentThemeData(darkForSystemTheme: Boolean): ThemeData {
val t = currentColors(darkForSystemTheme)
return ThemeData(colors = ThemeColors(
primary = t.colors.primary.toReadableHex(),
primaryVariant = t.colors.primaryVariant.toReadableHex(),
secondary = t.colors.secondary.toReadableHex(),
secondaryVariant = t.colors.secondaryVariant.toReadableHex(),
background = t.colors.background.toReadableHex(),
surface = t.colors.surface.toReadableHex(),
title = t.appColors.title.toReadableHex(),
sentMessage = t.appColors.sentMessage.toReadableHex(),
receivedMessage = t.appColors.receivedMessage.toReadableHex()
))
}
// colors, default theme enum, localized name of theme
@@ -125,12 +128,11 @@ object ThemeManager {
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
}
fun saveAndApplyThemeOverrides(theme: ThemeOverrides, darkForSystemTheme: Boolean) {
fun saveAndApplyThemeData(name: String, theme: ThemeData, darkForSystemTheme: Boolean) {
val overrides = appPrefs.themeOverrides.get().toMutableMap()
val prevValue = overrides[theme.base.name] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
overrides[theme.base.name] = prevValue.copy(colors = theme.colors)
val prevValue = overrides[name] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
overrides[name] = prevValue.copy(colors = theme.colors)
appPrefs.themeOverrides.set(overrides)
appPrefs.currentTheme.set(theme.base.name)
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
}
@@ -33,11 +33,6 @@ val Typography = Typography(
fontWeight = FontWeight.Normal,
fontSize = 18.5.sp
),
h4 = TextStyle(
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 17.5.sp
),
body1 = TextStyle(
fontFamily = Inter,
fontWeight = FontWeight.Normal,
@@ -86,7 +86,7 @@ fun TerminalLayout(
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {},
sendMessage = { sendCommand() },
sendMessage = sendCommand,
sendLiveMessage = null,
updateLiveMessage = null,
onMessageChange = ::onMessageChange,
@@ -99,8 +99,8 @@ fun TerminalLayout(
Surface(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth(),
color = MaterialTheme.colors.background
.fillMaxWidth()
.themedBackground()
) {
TerminalLog(terminalItems)
}
@@ -44,75 +44,77 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
val fullName = rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
) {
/*CloseSheetBar(close = {
if (chatModel.users.isEmpty()) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
close()
}
})*/
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(R.string.create_profile), bottomPadding = DEFAULT_PADDING)
ReadableText(R.string.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1)
ReadableText(R.string.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1)
Spacer(Modifier.height(DEFAULT_PADDING))
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
stringResource(R.string.display_name),
fontSize = 16.sp
)
if (!isValidDisplayName(displayName.value)) {
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
) {
/*CloseSheetBar(close = {
if (chatModel.users.isEmpty()) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
close()
}
})*/
Column(Modifier.padding(horizontal = DEFAULT_PADDING * 1f)) {
AppBarTitle(stringResource(R.string.create_profile))
ReadableText(R.string.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues())
ReadableText(R.string.profile_is_only_shared_with_your_contacts, TextAlign.Center)
Spacer(Modifier.height(DEFAULT_PADDING * 1.5f))
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
stringResource(R.string.no_spaces),
fontSize = 16.sp,
color = Color.Red
stringResource(R.string.display_name),
fontSize = 16.sp
)
if (!isValidDisplayName(displayName.value)) {
Text(
stringResource(R.string.no_spaces),
fontSize = 16.sp,
color = Color.Red
)
}
}
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
Spacer(Modifier.height(DEFAULT_PADDING))
Text(
stringResource(R.string.full_name_optional__prompt),
fontSize = 16.sp,
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
)
ProfileNameField(fullName, "", ::isValidDisplayName)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Row {
if (chatModel.users.isEmpty()) {
SimpleButtonDecorated(
text = stringResource(R.string.about_simplex),
icon = painterResource(R.drawable.ic_arrow_back_ios_new),
textDecoration = TextDecoration.None,
fontWeight = FontWeight.Medium
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
}
Spacer(Modifier.fillMaxWidth().weight(1f))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
val createModifier: Modifier
val createColor: Color
if (enabled) {
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp)
createColor = MaterialTheme.colors.primary
} else {
createModifier = Modifier.padding(8.dp)
createColor = MaterialTheme.colors.secondary
}
Surface(shape = RoundedCornerShape(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
Icon(painterResource(R.drawable.ic_arrow_forward_ios), stringResource(R.string.create_profile_button), tint = createColor)
}
}
}
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
Spacer(Modifier.height(DEFAULT_PADDING))
Text(
stringResource(R.string.full_name_optional__prompt),
fontSize = 16.sp,
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
)
ProfileNameField(fullName, "", ::isValidDisplayName)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Row {
if (chatModel.users.isEmpty()) {
SimpleButtonDecorated(
text = stringResource(R.string.about_simplex),
icon = painterResource(R.drawable.ic_arrow_back_ios_new),
textDecoration = TextDecoration.None,
fontWeight = FontWeight.Medium
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
}
Spacer(Modifier.fillMaxWidth().weight(1f))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
val createModifier: Modifier
val createColor: Color
if (enabled) {
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp)
createColor = MaterialTheme.colors.primary
} else {
createModifier = Modifier.padding(8.dp)
createColor = MaterialTheme.colors.secondary
}
Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
Icon(painterResource(R.drawable.ic_arrow_forward_ios), stringResource(R.string.create_profile_button), tint = createColor)
}
}
}
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
}
}
}
@@ -125,17 +127,13 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, c
chatModel.currentUser.value = user
if (chatModel.users.isEmpty()) {
chatModel.controller.startChat(user)
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
chatModel.onboardingStage.value = OnboardingStage.Step3_CreateSimpleXAddress
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()
// the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen,
// this will get it unstuck.
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
close()
}
}
@@ -26,6 +26,7 @@ class CallManager(val chatModel: ChatModel) {
}
fun acceptIncomingCall(invitation: RcvCallInvitation) {
ModalManager.shared.closeModals()
val call = chatModel.activeCall.value
if (call == null) {
justAcceptIncomingCall(invitation = invitation)
@@ -185,12 +185,10 @@ fun ActiveCallView(chatModel: ChatModel) {
activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
// Lock orientation to portrait in order to have good experience with calls
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
chatModel.activeCallViewIsVisible.value = true
onDispose {
activity.volumeControlStream = prevVolumeControlStream
// Unlock orientation
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
chatModel.activeCallViewIsVisible.value = false
}
}
}
@@ -97,9 +97,8 @@ fun IncomingCallActivityView(m: ChatModel) {
SimpleXTheme {
Surface(
Modifier
.fillMaxSize(),
color = MaterialTheme.colors.background
) {
.themedBackground()
.fillMaxSize()) {
if (showCallView) {
Box {
ActiveCallView(m)
@@ -227,9 +226,8 @@ fun PreviewIncomingCallLockScreenAlert() {
SimpleXTheme(true) {
Surface(
Modifier
.fillMaxSize(),
color = MaterialTheme.colors.background
) {
.themedBackground()
.fillMaxSize()) {
IncomingCallLockScreenAlertLayout(
invitation = RcvCallInvitation(
user = User.sampleData,
@@ -50,13 +50,13 @@ fun IncomingCallAlertLayout(
ignoreCall: () -> Unit,
acceptCall: () -> Unit
) {
val color = if (isInDarkTheme()) MaterialTheme.colors.surface else IncomingCallLight
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
Column(Modifier.fillMaxWidth().background(color).padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
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) {
ProfilePreview(profileOf = invitation.contact, size = 64.dp)
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
}
Row(verticalAlignment = Alignment.CenterVertically) {
CallButton(stringResource(R.string.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
@@ -78,7 +78,7 @@ fun IncomingCallInfo(invitation: RcvCallInvitation, chatModel: ChatModel) {
if (invitation.callType.media == CallMediaType.Video) CallIcon(painterResource(R.drawable.ic_videocam_filled), stringResource(R.string.icon_descr_video_call))
else CallIcon(painterResource(R.drawable.ic_call_filled), stringResource(R.string.icon_descr_audio_call))
Spacer(Modifier.width(4.dp))
Text(invitation.callTypeText, color = MaterialTheme.colors.onBackground)
Text(invitation.callTypeText)
}
}
@@ -6,7 +6,6 @@ import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import android.widget.Toast
import androidx.activity.compose.BackHandler
@@ -19,7 +18,8 @@ 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.platform.*
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -34,7 +34,6 @@ import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
import chat.simplex.app.views.usersettings.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@@ -44,7 +43,7 @@ import kotlinx.datetime.Clock
fun ChatInfoView(
chatModel: ChatModel,
contact: Contact,
connectionStats: ConnectionStats?,
connStats: ConnectionStats?,
customUserProfile: Profile?,
localAlias: String,
connectionCode: String?,
@@ -52,7 +51,6 @@ fun ChatInfoView(
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val connStats = remember { mutableStateOf(connectionStats) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null) {
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
@@ -61,7 +59,7 @@ fun ChatInfoView(
ChatInfoLayout(
chat,
contact,
connStats = connStats,
connStats,
contactNetworkStatus.value,
customUserProfile,
localAlias,
@@ -81,18 +79,7 @@ fun ChatInfoView(
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
switchContactAddress = {
showSwitchAddressAlert(switchAddress = {
withApi {
connStats.value = chatModel.controller.apiSwitchContact(contact.contactId)
}
})
},
abortSwitchContactAddress = {
showAbortSwitchAddressAlert(abortSwitchAddress = {
withApi {
connStats.value = chatModel.controller.apiAbortSwitchContact(contact.contactId)
}
})
showSwitchContactAddressAlert(chatModel, contact.contactId)
},
verifyClicked = {
ModalManager.shared.showModalCloseable { close ->
@@ -166,7 +153,7 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit
fun ChatInfoLayout(
chat: Chat,
contact: Contact,
connStats: MutableState<ConnectionStats?>,
connStats: ConnectionStats?,
contactNetworkStatus: NetworkStatus,
customUserProfile: Profile?,
localAlias: String,
@@ -177,10 +164,8 @@ fun ChatInfoLayout(
deleteContact: () -> Unit,
clearChat: () -> Unit,
switchContactAddress: () -> Unit,
abortSwitchContactAddress: () -> Unit,
verifyClicked: () -> Unit,
) {
val cStats = connStats.value
Column(
Modifier
.fillMaxWidth()
@@ -210,41 +195,23 @@ fun ChatInfoLayout(
}
SectionDividerSpaced()
if (contact.contactLink != null) {
val context = LocalContext.current
SectionView(stringResource(R.string.address_section_title).uppercase()) {
QRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f))
ShareAddressButton { shareText(context, contact.contactLink) }
SectionTextFooter(stringResource(R.string.you_can_share_this_address_with_your_contacts).format(contact.displayName))
}
SectionDividerSpaced()
}
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SectionItemView({
AlertManager.shared.showAlertMsg(
generalGetString(R.string.network_status),
contactNetworkStatus.statusExplanation
)}) {
NetworkStatusRow(contactNetworkStatus)
}
if (cStats != null) {
SwitchAddressButton(
disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null },
switchAddress = switchContactAddress
)
if (cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }) {
AbortSwitchAddressButton(
disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch },
abortSwitchAddress = abortSwitchContactAddress
)
SwitchAddressButton(switchContactAddress)
if (connStats != null) {
SectionItemView({
AlertManager.shared.showAlertMsg(
generalGetString(R.string.network_status),
contactNetworkStatus.statusExplanation
)}) {
NetworkStatusRow(contactNetworkStatus)
}
val rcvServers = cStats.rcvQueuesInfo.map { it.rcvServer }
if (rcvServers.isNotEmpty()) {
val rcvServers = connStats.rcvServers
if (rcvServers != null && rcvServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
}
val sndServers = cStats.sndQueuesInfo.map { it.sndServer }
if (sndServers.isNotEmpty()) {
val sndServers = connStats.sndServers
if (sndServers != null && sndServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.sending_via), sndServers)
}
}
@@ -381,7 +348,7 @@ private fun ServerImage(networkStatus: NetworkStatus) {
Box(Modifier.size(18.dp)) {
when (networkStatus) {
is NetworkStatus.Connected ->
Icon(painterResource(R.drawable.ic_circle_filled), stringResource(R.string.icon_descr_server_status_connected), tint = Color.Green)
Icon(painterResource(R.drawable.ic_circle_filled), stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
is NetworkStatus.Disconnected ->
Icon(painterResource(R.drawable.ic_pending_filled), stringResource(R.string.icon_descr_server_status_disconnected), tint = MaterialTheme.colors.secondary)
is NetworkStatus.Error ->
@@ -402,22 +369,9 @@ fun SimplexServers(text: String, servers: List<String>) {
}
@Composable
fun SwitchAddressButton(disabled: Boolean, switchAddress: () -> Unit) {
SectionItemView(switchAddress) {
Text(
stringResource(R.string.switch_receiving_address),
color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
@Composable
fun AbortSwitchAddressButton(disabled: Boolean, abortSwitchAddress: () -> Unit) {
SectionItemView(abortSwitchAddress) {
Text(
stringResource(R.string.abort_switch_receiving_address),
color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
fun SwitchAddressButton(onClick: () -> Unit) {
SectionItemView(onClick) {
Text(stringResource(R.string.switch_receiving_address), color = MaterialTheme.colors.primary)
}
}
@@ -462,40 +416,26 @@ private fun DeleteContactButton(onClick: () -> Unit) {
)
}
@Composable
fun ShareAddressButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(R.drawable.ic_share_filled),
stringResource(R.string.share_address),
onClick,
iconColor = MaterialTheme.colors.primary,
textColor = MaterialTheme.colors.primary,
)
}
private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel) = withApi {
chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let {
chatModel.updateContact(it)
}
}
fun showSwitchAddressAlert(switchAddress: () -> Unit) {
private fun showSwitchContactAddressAlert(m: ChatModel, contactId: Long) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.switch_receiving_address_question),
text = generalGetString(R.string.switch_receiving_address_desc),
confirmText = generalGetString(R.string.change_verb),
onConfirm = switchAddress
confirmText = generalGetString(R.string.switch_verb),
onConfirm = {
switchContactAddress(m, contactId)
},
destructive = true,
)
}
fun showAbortSwitchAddressAlert(abortSwitchAddress: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.abort_switch_receiving_address_question),
text = generalGetString(R.string.abort_switch_receiving_address_desc),
confirmText = generalGetString(R.string.abort_switch_receiving_address_confirm),
onConfirm = abortSwitchAddress,
destructive = true,
)
private fun switchContactAddress(m: ChatModel, contactId: Long) = withApi {
m.controller.apiSwitchContact(contactId)
}
@Preview
@@ -511,7 +451,7 @@ fun PreviewChatInfoLayout() {
localAlias = "",
connectionCode = "123",
developerTools = false,
connStats = remember { mutableStateOf(null) },
connStats = null,
contactNetworkStatus = NetworkStatus.Connected(),
onLocalAliasChanged = {},
customUserProfile = null,
@@ -519,7 +459,6 @@ fun PreviewChatInfoLayout() {
deleteContact = {},
clearChat = {},
switchContactAddress = {},
abortSwitchContactAddress = {},
verifyClicked = {},
)
}
@@ -1,186 +0,0 @@
package chat.simplex.app.views.chat
import InfoRow
import SectionBottomSpacer
import SectionDividerSpaced
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.CurrentColors
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.*
@Composable
fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
val sent = ci.chatDir.sent
val appColors = CurrentColors.collectAsState().value.appColors
val itemColor = if (sent) appColors.sentMessage else appColors.receivedMessage
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
@Composable
fun ItemVersionView(ciVersion: ChatItemVersion, current: Boolean) {
val showMenu = remember { mutableStateOf(false) }
val text = ciVersion.msgContent.text
@Composable
fun VersionText() {
if (text != "") {
MarkdownText(
text, if (text.isEmpty()) emptyList() else ciVersion.formattedText,
linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler,
onLinkLongClick = { showMenu.value = true }
)
} else {
Text(
generalGetString(R.string.item_info_no_text),
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary, lineHeight = 22.sp, fontStyle = FontStyle.Italic)
)
}
}
Column {
Box(
Modifier.clip(RoundedCornerShape(18.dp)).background(itemColor).padding(bottom = 3.dp)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
VersionText()
}
}
Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) {
Text(
localTimestamp(ciVersion.itemVersionTs),
fontSize = 12.sp,
color = MaterialTheme.colors.secondary,
modifier = Modifier.padding(end = 6.dp)
)
if (current && ci.meta.itemDeleted == null) {
Text(
stringResource(R.string.item_info_current),
fontSize = 12.sp,
color = MaterialTheme.colors.secondary
)
}
}
if (text != "") {
DefaultDropdownMenu(showMenu) {
ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = {
shareText(context, text)
showMenu.value = false
})
ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = {
copyText(context, text)
showMenu.value = false
})
}
}
}
}
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {
AppBarTitle(stringResource(if (sent) R.string.sent_message else R.string.received_message))
SectionView {
InfoRow(stringResource(R.string.info_row_sent_at), localTimestamp(ci.meta.itemTs))
if (!sent) {
InfoRow(stringResource(R.string.info_row_received_at), localTimestamp(ci.meta.createdAt))
}
when (val itemDeleted = ci.meta.itemDeleted) {
is CIDeleted.Deleted ->
if (itemDeleted.deletedTs != null) {
InfoRow(stringResource(R.string.info_row_deleted_at), localTimestamp(itemDeleted.deletedTs))
}
is CIDeleted.Moderated ->
if (itemDeleted.deletedTs != null) {
InfoRow(stringResource(R.string.info_row_moderated_at), localTimestamp(itemDeleted.deletedTs))
}
else -> {}
}
val deleteAt = ci.meta.itemTimed?.deleteAt
if (deleteAt != null) {
InfoRow(stringResource(R.string.info_row_disappears_at), localTimestamp(deleteAt))
}
if (devTools) {
InfoRow(stringResource(R.string.info_row_database_id), ci.meta.itemId.toString())
InfoRow(stringResource(R.string.info_row_updated_at), localTimestamp(ci.meta.updatedAt))
}
}
val versions = ciInfo.itemVersions
if (versions.isNotEmpty()) {
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(stringResource(R.string.edit_history), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING))
versions.forEachIndexed { i, ciVersion ->
ItemVersionView(ciVersion, current = i == 0)
}
}
}
SectionBottomSpacer()
}
}
fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolean): String {
val meta = ci.meta
val sent = ci.chatDir.sent
val shareText = mutableListOf<String>(generalGetString(if (sent) R.string.sent_message else R.string.received_message), "")
shareText.add(String.format(generalGetString(R.string.share_text_sent_at), localTimestamp(meta.itemTs)))
if (!ci.chatDir.sent) {
shareText.add(String.format(generalGetString(R.string.share_text_received_at), localTimestamp(meta.createdAt)))
}
when (val itemDeleted = ci.meta.itemDeleted) {
is CIDeleted.Deleted ->
if (itemDeleted.deletedTs != null) {
shareText.add(String.format(generalGetString(R.string.share_text_deleted_at), localTimestamp(itemDeleted.deletedTs)))
}
is CIDeleted.Moderated ->
if (itemDeleted.deletedTs != null) {
shareText.add(String.format(generalGetString(R.string.share_text_moderated_at), localTimestamp(itemDeleted.deletedTs)))
}
else -> {}
}
val deleteAt = ci.meta.itemTimed?.deleteAt
if (deleteAt != null) {
shareText.add(String.format(generalGetString(R.string.share_text_disappears_at), localTimestamp(deleteAt)))
}
if (devTools) {
shareText.add(String.format(generalGetString(R.string.share_text_database_id), meta.itemId))
shareText.add(String.format(generalGetString(R.string.share_text_updated_at), meta.updatedAt))
}
val versions = chatItemInfo.itemVersions
if (versions.isNotEmpty()) {
shareText.add("")
shareText.add(generalGetString(R.string.edit_history))
versions.forEachIndexed { index, itemVersion ->
val ts = localTimestamp(itemVersion.itemVersionTs)
shareText.add("")
shareText.add(
if (index == 0 && ci.meta.itemDeleted == null) {
String.format(generalGetString(R.string.current_version_timestamp), ts)
} else {
localTimestamp(itemVersion.itemVersionTs)
}
)
val t = itemVersion.msgContent.text
shareText.add(if (t != "") t else generalGetString(R.string.item_info_no_text))
}
}
return shareText.joinToString(separator = "\n")
}
@@ -101,7 +101,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
}
}
val view = LocalView.current
val context = LocalContext.current
if (activeChat.value == null || user == null) {
chatModel.chatId.value = null
} else {
@@ -259,32 +258,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModel.controller.allowFeatureToContact(contact, feature, param)
}
},
setReaction = { cInfo, cItem, add, reaction ->
withApi {
val updatedCI = chatModel.controller.apiChatItemReaction(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = cItem.id,
add = add,
reaction = reaction
)
if (updatedCI != null) {
chatModel.updateChatItem(cInfo, updatedCI)
}
}
},
showItemDetails = { cInfo, cItem ->
withApi {
val ciInfo = chatModel.controller.apiGetChatItemInfo(cInfo.chatType, cInfo.apiId, cItem.id)
if (ciInfo != null) {
ModalManager.shared.showModal(endButtons = { ShareButton {
shareText(context, itemInfoShareText(cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get()))
} }) {
ChatItemInfoView(cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
}
}
}
},
addMembers = { groupInfo ->
hideKeyboard(view)
withApi {
@@ -305,7 +278,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
)
}
},
changeNtfsState = { enabled, currentValue -> toggleNotifications(chat, enabled, chatModel, currentValue) },
changeNtfsState = { enabled, currentValue -> changeNtfsStatePerChat(enabled, currentValue, chat, chatModel) },
onSearchValueChanged = { value ->
if (searchText.value == value) return@ChatLayout
val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout
@@ -343,8 +316,6 @@ fun ChatLayout(
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
addMembers: (GroupInfo) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
@@ -355,6 +326,7 @@ fun ChatLayout(
Box(
Modifier
.fillMaxWidth()
.themedBackground()
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
@@ -380,14 +352,11 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
) { contentPadding ->
BoxWithConstraints(Modifier
.fillMaxHeight()
.padding(contentPadding)
) {
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, setReaction, showItemDetails, markRead, setFloatingButton, onComposed,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
)
}
}
@@ -560,8 +529,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: () -> Unit,
@@ -649,13 +616,12 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
}
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
val member = cItem.chatDir.groupMember
val showMember = showMemberImage(member, prevItem)
Row(Modifier.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp).then(swipeableModifier)) {
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
if (showMember) {
val contactId = member.memberContactId
if (contactId == null) {
@@ -675,22 +641,22 @@ 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, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails)
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 = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails)
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, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
}
}
} else { // direct message
val sent = cItem.chatDir.sent
Box(
Modifier.padding(
start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp,
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
start = if (sent) 76.dp else 12.dp,
end = if (sent) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
}
}
@@ -889,7 +855,6 @@ private fun TopEndFloatingButton(
FloatingActionButton(
{}, // no action here
modifier.size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp),
interactionSource = interactionSource,
) {
@@ -916,8 +881,7 @@ private fun bottomEndFloatingButton(
FloatingActionButton(
onClick = onClickCounter,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
modifier = Modifier.size(48.dp)
) {
Text(
unreadCountStr(unreadCount),
@@ -932,8 +896,7 @@ private fun bottomEndFloatingButton(
FloatingActionButton(
onClick = onClickArrowDown,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
modifier = Modifier.size(48.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_keyboard_arrow_down),
@@ -1113,8 +1076,6 @@ fun PreviewChatLayout() {
startCall = {},
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
@@ -1175,8 +1136,6 @@ fun PreviewGroupChatLayout() {
startCall = {},
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
@@ -16,7 +16,6 @@ import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
@@ -119,7 +118,7 @@ data class ComposeState(
val attachmentDisabled: Boolean
get() {
if (editing || liveMessage != null || inProgress) return true
if (editing || liveMessage != null) return true
return when (preview) {
ComposePreview.NoPreview -> false
is ComposePreview.CLinkPreview -> false
@@ -367,27 +366,22 @@ fun ComposeView(
chatModel.filesToDelete.clear()
}
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false, ttl: Int?): ChatItem? {
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
file = file,
quotedItemId = quoted,
mc = mc,
live = live,
ttl = ttl
live = live
)
if (aChatItem != null) {
chatModel.addChatItem(cInfo, aChatItem.chatItem)
return aChatItem.chatItem
}
if (file != null) removeFile(context, file)
return null
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
return aChatItem?.chatItem
}
suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): ChatItem? {
suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? {
val cInfo = chat.chatInfo
val cs = composeState.value
var sent: ChatItem?
@@ -501,8 +495,7 @@ fun ComposeView(
msgs.forEachIndexed { index, content ->
if (index > 0) delay(100)
sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index),
live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false,
ttl = ttl
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
)
}
if (sent == null &&
@@ -510,16 +503,16 @@ fun ComposeView(
cs.preview is ComposePreview.FilePreview ||
cs.preview is ComposePreview.VoicePreview)
) {
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl)
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
}
}
clearState(live)
return sent
}
fun sendMessage(ttl: Int?) {
fun sendMessage() {
withBGApi {
sendMessageAsync(null, false, ttl)
sendMessageAsync(null, false)
}
}
@@ -595,7 +588,7 @@ fun ComposeView(
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, ttl = null)
val ci = sendMessageAsync(typedMsg, live = true)
if (ci != null) {
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
}
@@ -616,7 +609,7 @@ fun ComposeView(
if (liveMessage != null) {
val sentMsg = liveMessageToSend(liveMessage, typedMsg)
if (sentMsg != null) {
val ci = sendMessageAsync(sentMsg, live = true, ttl = null)
val ci = sendMessageAsync(sentMsg, live = true)
if (ci != null) {
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true))
}
@@ -630,27 +623,23 @@ fun ComposeView(
fun previewView() {
when (val preview = composeState.value.preview) {
ComposePreview.NoPreview -> {}
is ComposePreview.CLinkPreview -> ComposeLinkView(
preview.linkPreview,
::cancelLinkPreview,
cancelEnabled = !composeState.value.inProgress
)
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
is ComposePreview.MediaPreview -> ComposeImageView(
preview,
::cancelImages,
cancelEnabled = !composeState.value.editing && !composeState.value.inProgress
cancelEnabled = !composeState.value.editing
)
is ComposePreview.VoicePreview -> ComposeVoiceView(
preview.voice,
preview.durationMs,
preview.finished,
cancelEnabled = !composeState.value.editing && !composeState.value.inProgress,
cancelEnabled = !composeState.value.editing,
::cancelVoice
)
is ComposePreview.FilePreview -> ComposeFileView(
preview.fileName,
::cancelFile,
cancelEnabled = !composeState.value.editing && !composeState.value.inProgress
cancelEnabled = !composeState.value.editing
)
}
}
@@ -668,14 +657,6 @@ fun ComposeView(
}
}
// In case a user sent something, state is in progress, the user rotates a screen to different orientation.
// Without clearing the state the user will be unable to send anything until re-enters ChatView
LaunchedEffect(Unit) {
if (composeState.value.inProgress) {
clearState()
}
}
LaunchedEffect(chatModel.sharedContent.value) {
// Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
if (chatModel.chatId.value == null) return@LaunchedEffect
@@ -693,43 +674,21 @@ fun ComposeView(
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
Column {
if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) {
contextItemView()
when {
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
else -> previewView()
}
} else {
Box {
Box(Modifier.align(Alignment.TopStart).padding(bottom = 69.dp)) {
contextItemView()
}
Box(Modifier.align(Alignment.BottomStart)) {
previewView()
}
}
contextItemView()
when {
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
else -> previewView()
}
Row(
modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.Bottom,
) {
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on
val attachmentClicked = if (isGroupAndProhibitedFiles) {
{
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.files_and_media_prohibited),
text = generalGetString(R.string.only_owners_can_enable_files_and_media)
)
}
} else {
showChooseAttachment
}
IconButton(attachmentClicked, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
Icon(
painterResource(R.drawable.ic_attach_file_filled_500),
contentDescription = stringResource(R.string.attach),
tint = if (!composeState.value.attachmentDisabled && userCanSend.value && !isGroupAndProhibitedFiles) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
tint = if (!composeState.value.attachmentDisabled && userCanSend.value) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
@@ -781,12 +740,10 @@ fun ComposeView(
if (orientation == activity.resources.configuration.orientation) {
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
sendMessage()
resetLinkPreview()
clearCurrentDraft()
deleteUnusedFiles()
} else if (composeState.value.inProgress) {
clearCurrentDraft()
} else if (!composeState.value.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
@@ -802,7 +759,6 @@ fun ComposeView(
}
}
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
SendMsgView(
composeState,
showVoiceRecordIcon = true,
@@ -814,10 +770,8 @@ fun ComposeView(
allowVoiceToContact = ::allowVoiceToContact,
userIsObserver = userIsObserver.value,
userCanSend = userCanSend.value,
timedMessageAllowed = timedMessageAllowed,
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
sendMessage = { ttl ->
sendMessage(ttl)
sendMessage = {
sendMessage()
resetLinkPreview()
},
sendLiveMessage = ::sendLiveMessage,
@@ -6,10 +6,6 @@ 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.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@@ -29,135 +25,99 @@ fun ComposeVoiceView(
cancelEnabled: Boolean,
cancelVoice: () -> Unit
) {
val progress = rememberSaveable { mutableStateOf(0) }
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
Box {
Box(
BoxWithConstraints(Modifier
.fillMaxWidth()
) {
val audioPlaying = rememberSaveable { mutableStateOf(false) }
val progress = rememberSaveable { mutableStateOf(0) }
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
val progressBarWidth = remember { Animatable(0f) }
LaunchedEffect(recordedDurationMs, finishedRecording) {
snapshotFlow { progress.value }
.distinctUntilChanged()
.collect {
val startTime = when {
finishedRecording -> progress.value
else -> recordedDurationMs
}
val endTime = when {
finishedRecording -> duration.value
audioPlaying.value -> recordedDurationMs
else -> MAX_VOICE_MILLIS_FOR_SENDING
}
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
}
}
Spacer(
Modifier
.fillMaxWidth().padding(top = 22.dp)
.requiredWidth(progressBarWidth.value.dp)
.padding(top = 58.dp)
.height(3.dp)
.background(MaterialTheme.colors.primary)
)
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
Row(
Modifier
.height(60.dp)
.fillMaxWidth()
.padding(top = 8.dp)
.background(sentColor),
verticalAlignment = Alignment.CenterVertically
) {
val audioPlaying = rememberSaveable { mutableStateOf(false) }
Row(
Modifier
.height(57.dp)
.fillMaxWidth()
.background(sentColor)
.padding(top = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
if (!audioPlaying.value) {
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
} else {
AudioPlayer.pause(audioPlaying, progress)
}
},
enabled = finishedRecording) {
Icon(
if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
stringResource(R.string.icon_descr_file),
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
}
val numberInText = remember(recordedDurationMs, progress.value) {
derivedStateOf {
when {
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
finishedRecording -> progress.value / 1000
else -> recordedDurationMs / 1000
}
}
}
Text(
durationText(numberInText.value),
fontSize = 18.sp,
color = MaterialTheme.colors.secondary,
)
Spacer(Modifier.weight(1f))
if (cancelEnabled) {
IconButton(
onClick = {
if (!audioPlaying.value) {
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
} else {
AudioPlayer.pause(audioPlaying, progress)
}
AudioPlayer.stop(filePath)
cancelVoice()
},
enabled = finishedRecording
modifier = Modifier.padding(0.dp)
) {
Icon(
if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
stringResource(R.string.icon_descr_file),
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
painterResource(R.drawable.ic_close),
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
val numberInText = remember(recordedDurationMs, progress.value) {
derivedStateOf {
when {
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
finishedRecording -> progress.value / 1000
else -> recordedDurationMs / 1000
}
}
}
Text(
durationText(numberInText.value),
fontSize = 18.sp,
color = MaterialTheme.colors.secondary,
)
Spacer(Modifier.weight(1f))
if (cancelEnabled) {
IconButton(
onClick = {
AudioPlayer.stop(filePath)
cancelVoice()
},
modifier = Modifier.padding(0.dp)
) {
Icon(
painterResource(R.drawable.ic_close),
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
}
if (finishedRecording) {
FinishedRecordingSlider(sentColor, progress, duration, filePath)
} else {
RecordingInProgressSlider(recordedDurationMs)
}
}
}
@Composable
fun FinishedRecordingSlider(backgroundColor: Color, progress: MutableState<Int>, duration: MutableState<Int>, filePath: String) {
val dp4 = with(LocalDensity.current) { 4.dp.toPx() }
val dp10 = with(LocalDensity.current) { 10.dp.toPx() }
val primary = MaterialTheme.colors.primary
val inactiveTrackColor = MaterialTheme.colors.primary.mixWith(
backgroundColor.copy(1f).mixWith(MaterialTheme.colors.background, backgroundColor.alpha),
0.24f)
Slider(
progress.value.toFloat(),
onValueChange = { AudioPlayer.seekTo(it.toInt(), progress, filePath) },
Modifier
.fillMaxWidth()
.drawBehind {
drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
drawRect(inactiveTrackColor, Offset(size.width - dp10, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
},
colors = SliderDefaults.colors(inactiveTrackColor = inactiveTrackColor),
valueRange = 0f..duration.value.toFloat()
)
}
@Composable
fun RecordingInProgressSlider(recordedDurationMs: Int) {
val thumbPosition = remember { Animatable(0f) }
val recDuration = rememberUpdatedState(recordedDurationMs)
LaunchedEffect(Unit) {
snapshotFlow { recDuration.value }
.distinctUntilChanged()
.collect {
thumbPosition.animateTo(it.toFloat(), audioProgressBarAnimationSpec())
}
}
val dp4 = with(LocalDensity.current) { 4.dp.toPx() }
val dp10 = with(LocalDensity.current) { 10.dp.toPx() }
val primary = MaterialTheme.colors.primary
val inactiveTrackColor = Color.Transparent
Slider(
thumbPosition.value,
onValueChange = {},
Modifier
.fillMaxWidth()
.drawBehind {
drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
},
colors = SliderDefaults.colors(disabledInactiveTrackColor = inactiveTrackColor, disabledActiveTrackColor = primary, thumbColor = Color.Transparent, disabledThumbColor = Color.Transparent),
enabled = false,
valueRange = 0f..MAX_VOICE_MILLIS_FOR_SENDING.toFloat()
)
}
@Preview
@Composable
fun PreviewComposeAudioView() {
@@ -95,11 +95,6 @@ private fun ContactPreferencesLayout(
applyPrefs(featuresAllowed.copy(fullDelete = it))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowReactions: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.reactions) }
FeatureSection(ChatFeature.Reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, allowReactions) {
applyPrefs(featuresAllowed.copy(reactions = it))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
applyPrefs(featuresAllowed.copy(voice = it))
@@ -144,6 +139,7 @@ private fun FeatureSection(
ContactFeatureAllowed.values(userDefault).map { it to it.text },
allowFeature,
icon = null,
enabled = remember { mutableStateOf(feature != ChatFeature.Calls) },
onSelected = onSelected
)
InfoRow(
@@ -151,7 +147,7 @@ private fun FeatureSection(
pref.contactPreference.allow.text
)
}
SectionTextFooter(feature.enabledDescription(enabled))
SectionTextFooter(feature.enabledDescription(enabled) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else ""))
}
@Composable
@@ -186,17 +182,9 @@ private fun TimedMessagesFeatureSection(
)
if (featuresAllowed.timedMessagesAllowed) {
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
DropdownCustomTimePickerSettingRow(
selection = ttl,
propagateExternalSelectionUpdate = true, // for Reset
label = generalGetString(R.string.delete_after),
dropdownValues = TimedMessagesPreference.ttlValues,
customPickerTitle = generalGetString(R.string.delete_after),
customPickerConfirmButtonText = generalGetString(R.string.custom_time_picker_select),
onSelected = onTTLUpdated
)
TimedMessagesTTLPicker(ttl, onTTLUpdated)
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
InfoRow(generalGetString(R.string.delete_after), timeText(pref.contactPreference.ttl))
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
}
}
SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled))
@@ -214,6 +202,18 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool
}
}
@Composable
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
val ttlValues = TimedMessagesPreference.ttlValues
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
ExposedDropDownSettingRow(
generalGetString(R.string.delete_after),
values.map { it to TimedMessagesPreference.ttlText(it) },
selection,
onSelected = onSelected
)
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(R.string.save_preferences_question),
@@ -18,7 +18,7 @@ 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.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
@@ -29,14 +29,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.*
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
@@ -44,7 +44,8 @@ import androidx.core.widget.*
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.ui.theme.CurrentColors
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberMultiplePermissionsState
@@ -63,25 +64,13 @@ fun SendMsgView(
userIsObserver: Boolean,
userCanSend: Boolean,
allowVoiceToContact: () -> Unit,
timedMessageAllowed: Boolean = false,
customDisappearingMessageTimePref: SharedPreference<Int>? = null,
sendMessage: (Int?) -> Unit,
sendMessage: () -> Unit,
sendLiveMessage: (suspend () -> Unit)? = null,
updateLiveMessage: (suspend () -> Unit)? = null,
cancelLiveMessage: (() -> Unit)? = null,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) }
if (showCustomDisappearingMessageDialog.value) {
CustomDisappearingMessageDialog(
sendMessage = sendMessage,
setShowDialog = { showCustomDisappearingMessageDialog.value = it },
customDisappearingMessageTimePref = customDisappearingMessageTimePref
)
}
Box(Modifier.padding(vertical = 8.dp)) {
val cs = composeState.value
val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview)
@@ -90,16 +79,15 @@ fun SendMsgView(
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
// Disable clicks on text field
if (cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress) {
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 (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) {
@@ -136,11 +124,10 @@ fun SendMsgView(
else ->
RecordVoiceView(recState, stopRecOnNextClick)
}
if (sendLiveMessage != null
&& updateLiveMessage != null
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
&& cs.contextItem is ComposeContextItem.NoContextItem
) {
if (sendLiveMessage != null
&& updateLiveMessage != null
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
&& cs.contextItem is ComposeContextItem.NoContextItem) {
Spacer(Modifier.width(10.dp))
StartLiveMessageButton(userCanSend) {
if (composeState.value.preview is ComposePreview.NoPreview) {
@@ -159,53 +146,27 @@ fun SendMsgView(
val cs = composeState.value
val icon = if (cs.editing || cs.liveMessage != null) painterResource(R.drawable.ic_check_filled) else painterResource(R.drawable.ic_arrow_upward)
val disabled = !cs.sendEnabled() ||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
cs.endLiveDisabled
val showDropdown = rememberSaveable { mutableStateOf(false) }
@Composable
fun MenuItems(): List<@Composable () -> Unit> {
val menuItems = mutableListOf<@Composable () -> Unit>()
if (cs.liveMessage == null && !cs.editing) {
if (
cs.preview !is ComposePreview.VoicePreview &&
cs.contextItem is ComposeContextItem.NoContextItem &&
sendLiveMessage != null && updateLiveMessage != null
) {
menuItems.add {
ItemAction(
generalGetString(R.string.send_live_message),
BoltFilled,
onClick = {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
showDropdown.value = false
}
)
}
}
if (timedMessageAllowed) {
menuItems.add {
ItemAction(
generalGetString(R.string.disappearing_message),
painterResource(R.drawable.ic_timer),
onClick = {
showCustomDisappearingMessageDialog.value = true
showDropdown.value = false
}
)
}
}
}
return menuItems
}
val menuItems = MenuItems()
if (menuItems.isNotEmpty()) {
(!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
) {
val showDropdown = rememberSaveable { mutableStateOf(false) }
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true }
DefaultDropdownMenu(showDropdown) {
menuItems.forEach { composable -> composable() }
DefaultDropdownMenu(
showDropdown,
) {
ItemAction(
generalGetString(R.string.send_live_message),
BoltFilled,
onClick = {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
showDropdown.value = false
}
)
}
} else {
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage)
@@ -216,99 +177,6 @@ fun SendMsgView(
}
}
@Composable
private fun CustomDisappearingMessageDialog(
sendMessage: (Int?) -> Unit,
setShowDialog: (Boolean) -> Unit,
customDisappearingMessageTimePref: SharedPreference<Int>?
) {
val showCustomTimePicker = remember { mutableStateOf(false) }
if (showCustomTimePicker.value) {
val selectedDisappearingMessageTime = remember {
mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300)
}
CustomTimePickerDialog(
selectedDisappearingMessageTime,
title = generalGetString(R.string.delete_after),
confirmButtonText = generalGetString(R.string.send_disappearing_message_send),
confirmButtonAction = { ttl ->
sendMessage(ttl)
customDisappearingMessageTimePref?.set?.invoke(ttl)
setShowDialog(false)
},
cancel = { setShowDialog(false) }
)
} else {
@Composable
fun ChoiceButton(
text: String,
onClick: () -> Unit
) {
TextButton(onClick) {
Text(
text,
fontSize = 18.sp,
color = MaterialTheme.colors.primary
)
}
}
Dialog(onDismissRequest = { setShowDialog(false) }) {
Surface(
shape = RoundedCornerShape(corner = CornerSize(25.dp))
) {
Box(
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.padding(DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(" ") // centers title
Text(
generalGetString(R.string.send_disappearing_message),
fontSize = 16.sp,
color = MaterialTheme.colors.secondary
)
Icon(
painterResource(R.drawable.ic_close),
generalGetString(R.string.icon_descr_close_button),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(25.dp)
.clickable { setShowDialog(false) }
)
}
ChoiceButton(generalGetString(R.string.send_disappearing_message_30_seconds)) {
sendMessage(30)
setShowDialog(false)
}
ChoiceButton(generalGetString(R.string.send_disappearing_message_1_minute)) {
sendMessage(60)
setShowDialog(false)
}
ChoiceButton(generalGetString(R.string.send_disappearing_message_5_minutes)) {
sendMessage(300)
setShowDialog(false)
}
ChoiceButton(generalGetString(R.string.send_disappearing_message_custom_time)) {
showCustomTimePicker.value = true
}
}
}
}
}
}
}
@Composable
private fun NativeKeyboard(
composeState: MutableState<ComposeState>,
@@ -382,13 +250,7 @@ private fun NativeKeyboard(
Log.e(chat.simplex.app.TAG, e.stackTraceToString())
}
}
editText.doOnTextChanged { text, _, _, _ ->
if (!composeState.value.inProgress) {
onMessageChange(text.toString())
} else if (text.toString() != composeState.value.message) {
editText.setText(composeState.value.message)
}
}
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
editText
}) {
@@ -408,7 +270,7 @@ private fun NativeKeyboard(
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
showKeyboard = false
}
showDeleteTextButton.value = it.lineCount >= 4 && !cs.inProgress
showDeleteTextButton.value = it.lineCount >= 4
}
if (composeState.value.preview is ComposePreview.VoicePreview) {
ComposeOverlay(R.string.voice_message_send_text, textStyle, padding)
@@ -439,7 +301,7 @@ private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>)
@Composable
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
val rec: Recorder = remember { RecorderNative() }
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
DisposableEffect(Unit) { onDispose { rec.stop() } }
val stopRecordingAndAddAudio: () -> Unit = {
recState.value.filePathNullable?.let {
@@ -587,14 +449,14 @@ private fun SendMsgButton(
sizeDp: Animatable<Float, AnimationVector1D>,
alpha: Animatable<Float, AnimationVector1D>,
enabled: Boolean,
sendMessage: (Int?) -> Unit,
sendMessage: () -> Unit,
onLongClick: (() -> Unit)? = null
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier.requiredSize(36.dp)
.combinedClickable(
onClick = { sendMessage(null) },
onClick = sendMessage,
onLongClick = onLongClick,
enabled = enabled,
role = Role.Button,
@@ -739,7 +601,6 @@ fun PreviewSendMsgView() {
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {},
timedMessageAllowed = false,
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
@@ -770,7 +631,6 @@ fun PreviewSendMsgViewEditing() {
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {},
timedMessageAllowed = false,
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
@@ -801,7 +661,6 @@ fun PreviewSendMsgViewInProgress() {
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {},
timedMessageAllowed = false,
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
@@ -11,16 +11,12 @@ import androidx.compose.foundation.*
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.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -38,17 +34,15 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
val selectedContacts = remember { mutableStateListOf<Long>() }
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
var allowModifyMembers by remember { mutableStateOf(true) }
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
BackHandler(onBack = close)
AddGroupMembersLayout(
chatModel.incognito.value,
groupInfo = groupInfo,
creatingGroup = creatingGroup,
contactsToAdd = getContactsToAdd(chatModel, searchText.value.text),
contactsToAdd = getContactsToAdd(chatModel),
selectedContacts = selectedContacts,
selectedRole = selectedRole,
allowModifyMembers = allowModifyMembers,
searchText,
openPreferences = {
ModalManager.shared.showCustomModal { close ->
GroupPreferencesView(chatModel, groupInfo.id, close)
@@ -75,8 +69,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
)
}
fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
val s = search.trim().lowercase()
fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
val memberContactIds = chatModel.groupMembers
.filter { it.memberCurrent }
.mapNotNull { it.memberContactId }
@@ -85,7 +78,7 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
.map { it.chatInfo }
.filterIsInstance<ChatInfo.Direct>()
.map { it.contact }
.filter { it.contactId !in memberContactIds && it.chatViewName.lowercase().contains(s) }
.filter { it.contactId !in memberContactIds }
.sortedBy { it.displayName.lowercase() }
.toList()
}
@@ -99,7 +92,6 @@ fun AddGroupMembersLayout(
selectedContacts: List<Long>,
selectedRole: MutableState<GroupMemberRole>,
allowModifyMembers: Boolean,
searchText: MutableState<TextFieldValue>,
openPreferences: () -> Unit,
inviteMembers: () -> Unit,
clearSelection: () -> Unit,
@@ -133,7 +125,7 @@ fun AddGroupMembersLayout(
}
SectionSpacer()
if (contactsToAdd.isEmpty() && searchText.value.text.isEmpty()) {
if (contactsToAdd.isEmpty()) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
@@ -162,10 +154,8 @@ fun AddGroupMembersLayout(
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
}
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(R.string.select_contacts)) {
SectionItemView(padding = PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText, selectedContacts.size)
}
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
}
}
@@ -173,25 +163,6 @@ fun AddGroupMembersLayout(
}
}
@Composable
private fun SearchRowView(
searchText: MutableState<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) },
selectedContactsSize: Int
) {
Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) {
Icon(painterResource(R.drawable.ic_search), stringResource(android.R.string.search_go), tint = MaterialTheme.colors.secondary)
}
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
SearchTextField(Modifier.fillMaxWidth(), searchText = searchText, alwaysVisible = true) {
searchText.value = searchText.value.copy(it)
}
val view = LocalView.current
LaunchedEffect(selectedContactsSize) {
searchText.value = searchText.value.copy("")
hideKeyboard(view)
}
}
@Composable
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>, enabled: Boolean) {
Row(
@@ -354,7 +325,6 @@ fun PreviewAddGroupMembersLayout() {
selectedContacts = remember { mutableStateListOf() },
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
allowModifyMembers = true,
searchText = remember { mutableStateOf(TextFieldValue("")) },
openPreferences = {},
inviteMembers = {},
clearSelection = {},
@@ -13,14 +13,12 @@ import androidx.compose.foundation.*
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.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -177,8 +175,6 @@ fun GroupChatInfoLayout(
SectionView {
if (groupInfo.canEdit) {
EditGroupProfileButton(editGroupProfile)
}
if (groupInfo.groupProfile.description != null || groupInfo.canEdit) {
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
}
GroupPreferencesButton(openPreferences)
@@ -198,17 +194,10 @@ fun GroupChatInfoLayout(
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
AddMembersButton(tint, onAddMembersClick)
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
val filteredMembers = derivedStateOf { members.filter { it.chatViewName.lowercase().contains(searchText.value.text.trim()) } }
if (members.size > 8) {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText)
}
}
SectionItemView(minHeight = 54.dp) {
MemberRow(groupInfo.membership, user = true)
}
MembersList(filteredMembers.value, showMemberInfo)
MembersList(members, showMemberInfo)
}
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
SectionView {
@@ -402,19 +391,6 @@ private fun DeleteGroupButton(onClick: () -> Unit) {
)
}
@Composable
private fun SearchRowView(
searchText: MutableState<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
) {
Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) {
Icon(painterResource(R.drawable.ic_search), stringResource(android.R.string.search_go), tint = MaterialTheme.colors.secondary)
}
Spacer(Modifier.width(14.dp))
SearchTextField(Modifier.fillMaxWidth(), searchText = searchText, alwaysVisible = true) {
searchText.value = searchText.value.copy(it)
}
}
@Preview
@Composable
fun PreviewGroupChatInfoLayout() {
@@ -4,10 +4,7 @@ import InfoRow
import SectionBottomSpacer
import SectionDividerSpaced
import SectionSpacer
import SectionTextFooter
import SectionView
import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -16,20 +13,17 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.chat.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import kotlinx.datetime.Clock
@@ -37,7 +31,7 @@ import kotlinx.datetime.Clock
fun GroupMemberInfoView(
groupInfo: GroupInfo,
member: GroupMember,
connectionStats: ConnectionStats?,
connStats: ConnectionStats?,
connectionCode: String?,
chatModel: ChatModel,
close: () -> Unit,
@@ -45,7 +39,6 @@ fun GroupMemberInfoView(
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val connStats = remember { mutableStateOf(connectionStats) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null) {
val newRole = remember { mutableStateOf(member.memberRole) }
@@ -71,15 +64,6 @@ fun GroupMemberInfoView(
}
}
},
connectViaAddress = { connReqUri ->
val uri = Uri.parse(connReqUri)
withUriAction(uri) { linkType ->
withApi {
Log.d(TAG, "connectViaUri: connecting")
connectViaUri(chatModel, linkType, uri)
}
}
},
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
onRoleSelected = {
if (it == newRole.value) return@GroupMemberInfoLayout
@@ -99,18 +83,7 @@ fun GroupMemberInfoView(
}
},
switchMemberAddress = {
showSwitchAddressAlert(switchAddress = {
withApi {
connStats.value = chatModel.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
}
})
},
abortSwitchMemberAddress = {
showAbortSwitchAddressAlert(abortSwitchAddress = {
withApi {
connStats.value = chatModel.controller.apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
}
})
switchMemberAddress(chatModel, groupInfo, member)
},
verifyClicked = {
ModalManager.shared.showModalCloseable { close ->
@@ -164,29 +137,17 @@ fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: Cha
fun GroupMemberInfoLayout(
groupInfo: GroupInfo,
member: GroupMember,
connStats: MutableState<ConnectionStats?>,
connStats: ConnectionStats?,
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
connectionCode: String?,
getContactChat: (Long) -> Chat?,
openDirectChat: (Long) -> Unit,
connectViaAddress: (String) -> Unit,
removeMember: () -> Unit,
onRoleSelected: (GroupMemberRole) -> Unit,
switchMemberAddress: () -> Unit,
abortSwitchMemberAddress: () -> Unit,
verifyClicked: () -> Unit,
) {
val cStats = connStats.value
fun knownDirectChat(contactId: Long): Chat? {
val chat = getContactChat(contactId)
return if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
chat
} else {
null
}
}
Column(
Modifier
.fillMaxWidth()
@@ -200,39 +161,22 @@ fun GroupMemberInfoLayout(
}
SectionSpacer()
val contactId = member.memberContactId
if (member.memberActive) {
val contactId = member.memberContactId
if (contactId != null) {
SectionView {
if (knownDirectChat(contactId) != null || groupInfo.fullGroupPreferences.directMessages.on) {
val chat = getContactChat(contactId)
if ((chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) || groupInfo.fullGroupPreferences.directMessages.on) {
OpenChatButton(onClick = { openDirectChat(contactId) })
}
if (connectionCode != null) {
VerifyCodeButton(member.verified, verifyClicked)
}
}
SectionDividerSpaced()
SectionSpacer()
}
}
if (member.contactLink != null) {
val context = LocalContext.current
SectionView(stringResource(R.string.address_section_title).uppercase()) {
QRCode(member.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f))
ShareAddressButton { shareText(context, member.contactLink) }
if (contactId != null) {
if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on) {
ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) })
}
} else {
ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) })
}
SectionTextFooter(stringResource(R.string.you_can_share_this_address_with_your_contacts).format(member.displayName))
}
SectionDividerSpaced()
}
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
val roles = remember { member.canChangeRoleTo(groupInfo) }
@@ -249,26 +193,21 @@ fun GroupMemberInfoLayout(
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
}
}
if (cStats != null) {
if (connStats != null) {
SectionDividerSpaced()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SwitchAddressButton(
disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null },
switchAddress = switchMemberAddress
)
if (cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }) {
AbortSwitchAddressButton(
disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch },
abortSwitchAddress = abortSwitchMemberAddress
)
}
val rcvServers = cStats.rcvQueuesInfo.map { it.rcvServer }
if (rcvServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
}
val sndServers = cStats.sndQueuesInfo.map { it.sndServer }
if (sndServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.sending_via), sndServers)
SwitchAddressButton(switchMemberAddress)
val rcvServers = connStats.rcvServers
val sndServers = connStats.sndServers
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
if (rcvServers != null && rcvServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
if (sndServers != null && sndServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.sending_via), sndServers)
}
} else if (sndServers != null && sndServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.sending_via), sndServers)
}
}
}
}
@@ -342,17 +281,6 @@ fun OpenChatButton(onClick: () -> Unit) {
)
}
@Composable
fun ConnectViaAddressButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(R.drawable.ic_link),
stringResource(R.string.connect_button),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
)
}
@Composable
private fun RoleSelectionRow(
roles: List<GroupMemberRole>,
@@ -395,6 +323,10 @@ private fun updateMemberRoleDialog(
)
}
private fun switchMemberAddress(m: ChatModel, groupInfo: GroupInfo, member: GroupMember) = withApi {
m.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
}
@Preview
@Composable
fun PreviewGroupMemberInfoLayout() {
@@ -402,17 +334,15 @@ fun PreviewGroupMemberInfoLayout() {
GroupMemberInfoLayout(
groupInfo = GroupInfo.sampleData,
member = GroupMember.sampleData,
connStats = remember { mutableStateOf(null) },
connStats = null,
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
connectionCode = "123",
getContactChat = { Chat.sampleData },
openDirectChat = {},
connectViaAddress = {},
removeMember = {},
onRoleSelected = {},
switchMemberAddress = {},
abortSwitchMemberAddress = {},
verifyClicked = {},
)
}
@@ -17,6 +17,7 @@ import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.TimedMessagesTTLPicker
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
@@ -30,9 +31,9 @@ fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
fun savePrefs(afterSave: () -> Unit = {}) {
withApi {
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
val g = m.controller.apiUpdateGroup(gInfo.groupId, gp)
if (g != null) {
m.updateGroup(g)
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
if (gInfo != null) {
m.updateGroup(gInfo)
currentPreferences = preferences
}
afterSave()
@@ -74,11 +75,11 @@ private fun GroupPreferencesLayout(
AppBarTitle(stringResource(R.string.group_preferences))
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
val onTTLUpdated = { ttl: Int? ->
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl)))
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl ?: 86400)))
}
FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
if (enable == GroupFeatureEnabled.ON) {
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400)))
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400)))
} else {
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl)))
}
@@ -94,21 +95,10 @@ private fun GroupPreferencesLayout(
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.enable) }
FeatureSection(GroupFeature.Reactions, allowReactions, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(reactions = GroupPreference(enable = it)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
}
// TODO uncomment in 5.3
// SectionDividerSpaced(true, maxBottomPadding = false)
// val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) }
// FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) {
// applyPrefs(preferences.copy(files = GroupPreference(enable = it)))
// }
if (groupInfo.canEdit) {
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
ResetSaveButtons(
@@ -146,15 +136,7 @@ private fun FeatureSection(
}
if (timedOn) {
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
DropdownCustomTimePickerSettingRow(
selection = ttl,
propagateExternalSelectionUpdate = true, // for Reset
label = generalGetString(R.string.delete_after),
dropdownValues = TimedMessagesPreference.ttlValues,
customPickerTitle = generalGetString(R.string.delete_after),
customPickerConfirmButtonText = generalGetString(R.string.custom_time_picker_select),
onSelected = onTTLUpdated
)
TimedMessagesTTLPicker(ttl, onTTLUpdated)
}
} else {
InfoRow(
@@ -164,7 +146,7 @@ private fun FeatureSection(
iconTint = iconTint,
)
if (timedOn) {
InfoRow(generalGetString(R.string.delete_after), timeText(preferences.timedMessages.ttl))
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
}
}
}
@@ -1,35 +1,27 @@
package chat.simplex.app.views.chat.group
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionSpacer
import SectionView
import TextIconSpaced
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.model.*
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
@Composable
fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
var gInfo by remember { mutableStateOf(groupInfo) }
val welcomeText = remember { mutableStateOf(gInfo.groupProfile.description ?: "") }
var groupInfo by remember { mutableStateOf(groupInfo) }
val welcomeText = remember { mutableStateOf(groupInfo.groupProfile.description ?: "") }
fun save(afterSave: () -> Unit = {}) {
withApi {
@@ -37,10 +29,10 @@ fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
if (welcome?.length == 0) {
welcome = null
}
val groupProfileUpdated = gInfo.groupProfile.copy(description = welcome)
val res = m.controller.apiUpdateGroup(gInfo.groupId, groupProfileUpdated)
val groupProfileUpdated = groupInfo.groupProfile.copy(description = welcome)
val res = m.controller.apiUpdateGroup(groupInfo.groupId, groupProfileUpdated)
if (res != null) {
gInfo = res
groupInfo = res
m.updateGroup(res)
welcomeText.value = welcome ?: ""
}
@@ -50,14 +42,13 @@ fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
ModalView(
close = {
if (welcomeText.value == gInfo.groupProfile.description || (welcomeText.value == "" && gInfo.groupProfile.description == null)) close()
if (welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)) close()
else showUnsavedChangesAlert({ save(close) }, close)
},
) {
GroupWelcomeLayout(
welcomeText,
gInfo,
m.controller.appPrefs.simplexLinkMode.get(),
groupInfo,
save = ::save
)
}
@@ -67,66 +58,23 @@ fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
private fun GroupWelcomeLayout(
welcomeText: MutableState<String>,
groupInfo: GroupInfo,
linkMode: SimplexLinkMode,
save: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
) {
val editMode = remember { mutableStateOf(true) }
AppBarTitle(stringResource(R.string.group_welcome_title))
val wt = rememberSaveable { welcomeText }
if (groupInfo.canEdit) {
if (editMode.value) {
val focusRequester = remember { FocusRequester() }
TextEditor(
wt,
Modifier.height(140.dp), stringResource(R.string.enter_welcome_message),
focusRequester = focusRequester
)
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
} else {
TextPreview(wt.value, linkMode)
}
ChangeModeButton(
editMode.value,
click = {
editMode.value = !editMode.value
},
wt.value.isEmpty()
)
CopyTextButton { copyText(SimplexApp.context, wt.value) }
SectionDividerSpaced(maxBottomPadding = false)
SaveButton(
save = save,
disabled = wt.value == groupInfo.groupProfile.description || (wt.value == "" && groupInfo.groupProfile.description == null)
)
} else {
TextPreview(wt.value, linkMode)
CopyTextButton { copyText(SimplexApp.context, wt.value) }
}
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)
)
SectionBottomSpacer()
}
}
@Composable
private fun TextPreview(text: String, linkMode: SimplexLinkMode, markdown: Boolean = true) {
Column {
SelectionContainer(Modifier.fillMaxWidth()) {
MarkdownText(
text,
formattedText = if (markdown) remember(text) { parseToMarkdown(text) } else null,
modifier = Modifier.fillMaxHeight().padding(horizontal = DEFAULT_PADDING),
linkMode = linkMode,
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp)
)
}
}
}
@Composable
private fun SaveButton(save: () -> Unit, disabled: Boolean) {
SectionView {
@@ -136,35 +84,6 @@ private fun SaveButton(save: () -> Unit, disabled: Boolean) {
}
}
@Composable
private fun ChangeModeButton(editMode: Boolean, click: () -> Unit, disabled: Boolean) {
SectionItemView(click, disabled = disabled) {
Icon(
painterResource(if (editMode) R.drawable.ic_visibility else R.drawable.ic_edit),
contentDescription = generalGetString(R.string.edit_verb),
tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
)
TextIconSpaced()
Text(
stringResource(if (editMode) R.string.group_welcome_preview else R.string.edit_verb),
color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
@Composable
private fun CopyTextButton(click: () -> Unit) {
SectionItemView(click) {
Icon(
painterResource(R.drawable.ic_content_copy),
contentDescription = generalGetString(R.string.copy_verb),
tint = MaterialTheme.colors.primary,
)
TextIconSpaced()
Text(stringResource(R.string.copy_verb), color = MaterialTheme.colors.primary)
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(R.string.save_welcome_message_question),
@@ -211,7 +211,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemEdited = true),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
quotedItem = null,
reactions = listOf(),
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
)
private val fileChatItemWtFile = ChatItem(
@@ -219,7 +218,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), ),
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
quotedItem = null,
reactions = listOf(),
file = null
)
override val values = listOf(
@@ -24,7 +24,7 @@ fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = Ma
Text(
chatItem.timestampText,
color = metaColor,
fontSize = 12.sp,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
} else {
@@ -44,7 +44,7 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
StatusIconText(painterResource(R.drawable.ic_timer), color)
val ttl = meta.itemTimed?.ttl
if (ttl != chatTTL) {
Text(shortTimeText(ttl), color = color, fontSize = 12.sp)
Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
}
Spacer(Modifier.width(4.dp))
}
@@ -57,7 +57,7 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
StatusIconText(painterResource(R.drawable.ic_circle_filled), Color.Transparent)
Spacer(Modifier.width(4.dp))
}
Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(meta.timestampText, color = color, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
// the conditions in this function should match CIMetaText
@@ -69,7 +69,7 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
res += iconSpace
val ttl = meta.itemTimed.ttl
if (ttl != chatTTL) {
res += shortTimeText(ttl)
res += TimedMessagesPreference.shortTtlText(ttl)
}
}
if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) {
@@ -1,6 +1,5 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
@@ -10,19 +9,20 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
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.helpers.*
import kotlinx.coroutines.flow.distinctUntilChanged
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
@@ -36,10 +36,9 @@ fun CIVoiceView(
ci: ChatItem,
timedMessagesTTL: Int?,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
) {
Row(
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp),
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = 6.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (file != null) {
@@ -65,11 +64,9 @@ fun CIVoiceView(
durationText(time / 1000)
}
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) {
AudioPlayer.seekTo(it, progress, filePath)
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick)
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile)
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
val metaReserve = if (edited)
" "
else
@@ -93,89 +90,39 @@ private fun VoiceLayout(
timedMessagesTTL: Int?,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
onProgressChanged: (Int) -> Unit,
longClick: () -> Unit
) {
@Composable
fun RowScope.Slider(backgroundColor: Color, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
var movedManuallyTo by rememberSaveable(file.fileId) { mutableStateOf(-1) }
if (audioPlaying.value || progress.value > 0 || movedManuallyTo == progress.value) {
val dp4 = with(LocalDensity.current) { 4.dp.toPx() }
val dp10 = with(LocalDensity.current) { 10.dp.toPx() }
val primary = MaterialTheme.colors.primary
val inactiveTrackColor =
MaterialTheme.colors.primary.mixWith(
backgroundColor.copy(1f).mixWith(MaterialTheme.colors.background, backgroundColor.alpha),
0.24f)
val width = with(LocalDensity.current) { LocalView.current.width.toDp() }
val colors = SliderDefaults.colors(
inactiveTrackColor = inactiveTrackColor
)
Slider(
progress.value.toFloat(),
onValueChange = {
onProgressChanged(it.toInt())
movedManuallyTo = it.toInt()
},
Modifier
.size(width, 48.dp)
.weight(1f)
.padding(padding)
.drawBehind {
drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
drawRect(inactiveTrackColor, Offset(size.width - dp10, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
},
valueRange = 0f..duration.value.toFloat(),
colors = colors
)
LaunchedEffect(Unit) {
snapshotFlow { audioPlaying.value }
.distinctUntilChanged()
.collect {
movedManuallyTo = -1
}
}
}
}
when {
hasText -> {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
Spacer(Modifier.width(6.dp))
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
Row(verticalAlignment = Alignment.CenterVertically) {
DurationText(text, PaddingValues(start = 12.dp))
Slider(if (ci.chatDir.sent) sentColor else receivedColor)
}
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
DurationText(text, PaddingValues(start = 12.dp))
}
sent -> {
Column(horizontalAlignment = Alignment.End) {
Row {
Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) {
Spacer(Modifier.height(56.dp))
Slider(MaterialTheme.colors.background, PaddingValues(end = DEFAULT_PADDING_HALF + 3.dp))
DurationText(text, PaddingValues(end = 12.dp))
}
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
DurationText(text, PaddingValues(end = 12.dp))
}
Box(Modifier.padding(top = 6.dp, end = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
}
}
}
}
else -> {
Column(horizontalAlignment = Alignment.Start) {
Row {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
DurationText(text, PaddingValues(start = 12.dp))
Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp))
Spacer(Modifier.height(56.dp))
Row {
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
}
}
Box(Modifier.padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
Row(verticalAlignment = Alignment.CenterVertically) {
DurationText(text, PaddingValues(start = 12.dp))
Spacer(Modifier.height(56.dp))
}
}
}
@@ -246,8 +193,7 @@ private fun VoiceMsgIndicator(
error: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
longClick: () -> Unit
) {
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
@@ -266,9 +212,8 @@ private fun VoiceMsgIndicator(
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
}
} else {
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick)
} else if (file?.fileStatus is CIFileStatus.RcvTransfer
if (file?.fileStatus is CIFileStatus.RcvInvitation
|| file?.fileStatus is CIFileStatus.RcvTransfer
|| file?.fileStatus is CIFileStatus.RcvAccepted
) {
Box(
@@ -2,8 +2,7 @@ package chat.simplex.app.views.chat.item
import android.Manifest
import android.os.Build
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -17,17 +16,15 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
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.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chat.ComposeContextItem
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.IncognitoView
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.datetime.Clock
@@ -48,9 +45,7 @@ fun ChatItemView(
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
scrollToItem: (Long) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
@@ -80,256 +75,186 @@ fun ChatItemView(
else -> {}
}
}
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
@Composable
fun ChatItemReactions() {
Row {
cItem.reactions.forEach { r ->
var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp))
if (cInfo.featureEnabled(ChatFeature.Reactions) && (cItem.allowAddReaction || r.userReacted)) {
modifier = modifier.clickable {
setReaction(cInfo, cItem, !r.userReacted, r.reaction)
fun deleteMessageQuestionText(): String {
return if (fullDeleteAllowed) {
generalGetString(R.string.delete_message_cannot_be_undone_warning)
} else {
generalGetString(R.string.delete_message_mark_deleted_warning)
}
}
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() {
DefaultDropdownMenu(showMenu) {
if (cItem.meta.itemDeleted == null && !live) {
ItemAction(stringResource(R.string.reply_verb), painterResource(R.drawable.ic_reply), onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
filePath != null -> shareFile(context, cItem.text, filePath)
else -> shareText(context, cItem.content.text)
}
showMenu.value = false
})
ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = {
copyText(context, cItem.content.text)
showMenu.value = false
})
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), painterResource(R.drawable.ic_download), onClick = {
when (cItem.content.msgContent) {
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
})
}
}
Row(modifier.padding(2.dp)) {
Text(r.reaction.text, fontSize = 12.sp)
if (r.totalReacted > 1) {
Spacer(Modifier.width(4.dp))
Text("${r.totalReacted}",
fontSize = 11.5.sp,
fontWeight = if (r.userReacted) FontWeight.Bold else FontWeight.Normal,
color = if (r.userReacted) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
)
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
ItemAction(stringResource(R.string.edit_verb), painterResource(R.drawable.ic_edit_filled), onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false
})
}
if (cItem.meta.itemDeleted != null && revealed.value) {
ItemAction(
stringResource(R.string.hide_verb),
painterResource(R.drawable.ic_visibility_off),
onClick = {
revealed.value = false
showMenu.value = false
}
)
}
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
if (!(live && cItem.meta.isLive)) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
val groupInfo = cItem.memberToModerate(cInfo)?.first
if (groupInfo != null) {
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
}
}
}
}
Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) {
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
fun deleteMessageQuestionText(): String {
return if (fullDeleteAllowed) {
generalGetString(R.string.delete_message_cannot_be_undone_warning)
} else {
generalGetString(R.string.delete_message_mark_deleted_warning)
}
}
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 MsgReactionsMenu() {
val rs = MsgReaction.values.mapNotNull { r ->
if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) {
r
} else {
null
}
}
if (rs.isNotEmpty()) {
Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState())) {
rs.forEach() { r ->
Box(
Modifier.size(36.dp).clickable {
setReaction(cInfo, cItem, true, r)
showMenu.value = false
},
contentAlignment = Alignment.Center
) {
Text(r.text)
}
}
}
}
}
@Composable
fun MsgContentItemDropdownMenu() {
DefaultDropdownMenu(showMenu) {
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
MsgReactionsMenu()
}
if (cItem.meta.itemDeleted == null && !live) {
ItemAction(stringResource(R.string.reply_verb), painterResource(R.drawable.ic_reply), onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
@Composable
fun MarkedDeletedItemDropdownMenu() {
DefaultDropdownMenu(showMenu) {
if (!cItem.isDeletedContent) {
ItemAction(
stringResource(R.string.reveal_verb),
painterResource(R.drawable.ic_visibility),
onClick = {
revealed.value = true
showMenu.value = false
})
}
ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
filePath != null -> shareFile(context, cItem.text, filePath)
else -> shareText(context, cItem.content.text)
}
showMenu.value = false
})
ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = {
copyText(context, cItem.content.text)
showMenu.value = false
})
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), painterResource(R.drawable.ic_download), onClick = {
when (cItem.content.msgContent) {
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 && !live) {
ItemAction(stringResource(R.string.edit_verb), painterResource(R.drawable.ic_edit_filled), onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false
})
}
if (cItem.meta.itemDeleted != null && revealed.value) {
ItemAction(
stringResource(R.string.hide_verb),
painterResource(R.drawable.ic_visibility_off),
onClick = {
revealed.value = false
showMenu.value = false
}
)
}
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
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)
}
)
}
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable
fun MarkedDeletedItemDropdownMenu() {
DefaultDropdownMenu(showMenu) {
if (!cItem.isDeletedContent) {
ItemAction(
stringResource(R.string.reveal_verb),
painterResource(R.drawable.ic_visibility),
onClick = {
revealed.value = true
showMenu.value = false
}
)
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
}
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && !revealed.value) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemDropdownMenu()
@Composable
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && !revealed.value) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemDropdownMenu()
} 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()
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
MsgContentItemDropdownMenu()
} else {
if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem, cInfo.timedMessagesTTL)
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile)
} else {
framedItemView()
}
} else {
framedItemView()
}
framedItemView()
MsgContentItemDropdownMenu()
}
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
DefaultDropdownMenu(showMenu) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable fun CallItem(status: CICallStatus, duration: Int) {
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
@Composable
fun ModeratedItem() {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
DefaultDropdownMenu(showMenu) {
DeleteItemAction(cItem, showMenu, questionText = generalGetString(R.string.delete_message_cannot_be_undone_warning), deleteMessage)
}
}
when (val c = cItem.content) {
is CIContent.SndMsgContent -> ContentItem()
is CIContent.RcvMsgContent -> ContentItem()
is CIContent.SndDeleted -> DeletedItem()
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
is CIContent.SndGroupEventContent -> CIEventView(cItem)
is CIContent.RcvConnEventContent -> CIEventView(cItem)
is CIContent.SndConnEventContent -> CIEventView(cItem)
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvChatPreference -> {
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
}
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
is CIContent.SndModerated -> ModeratedItem()
is CIContent.RcvModerated -> ModeratedItem()
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
} else {
framedItemView()
MsgContentItemDropdownMenu()
}
}
if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) {
ChatItemReactions()
@Composable fun DeletedItem() {
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
DefaultDropdownMenu(showMenu) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable fun CallItem(status: CICallStatus, duration: Int) {
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
when (val c = cItem.content) {
is CIContent.SndMsgContent -> ContentItem()
is CIContent.RcvMsgContent -> ContentItem()
is CIContent.SndDeleted -> DeletedItem()
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
is CIContent.SndGroupEventContent -> CIEventView(cItem)
is CIContent.RcvConnEventContent -> CIEventView(cItem)
is CIContent.SndConnEventContent -> CIEventView(cItem)
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvChatPreference -> {
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
}
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
}
}
}
@@ -353,24 +278,6 @@ fun CancelFileItemAction(
)
}
@Composable
fun ItemInfoAction(
cInfo: ChatInfo,
cItem: ChatItem,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
showMenu: MutableState<Boolean>
) {
ItemAction(
stringResource(R.string.info_menu),
painterResource(R.drawable.ic_info),
onClick = {
showItemDetails(cInfo, cItem)
showMenu.value = false
}
)
}
@Composable
fun DeleteItemAction(
cItem: ChatItem,
@@ -523,9 +430,7 @@ fun PreviewChatItemView() {
joinGroup = {},
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> },
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
acceptFeature = { _, _, _ -> }
)
}
}
@@ -546,9 +451,7 @@ fun PreviewChatItemViewDeletedContent() {
joinGroup = {},
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> },
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
acceptFeature = { _, _, _ -> }
)
}
}
@@ -221,7 +221,7 @@ fun FramedItemView(
}
}
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }, receiveFile)
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
if (mc.text != "") {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
}
@@ -19,7 +19,6 @@ import chat.simplex.app.model.CIDeleted
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.generalGetString
import kotlinx.datetime.Clock
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
@@ -68,7 +67,7 @@ private fun MarkedDeletedText(text: String) {
fun PreviewMarkedDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted(Clock.System.now())),
ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted()),
null
)
}
@@ -8,6 +8,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.runtime.Composable
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.SpanStyle
import androidx.compose.ui.text.font.FontWeight
@@ -17,7 +18,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.onboarding.ReadableTextWithLink
import chat.simplex.app.views.helpers.openUriCatching
import chat.simplex.app.views.usersettings.MarkdownHelpView
import chat.simplex.app.views.usersettings.simplexTeamUri
@@ -28,8 +29,17 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
val uriHandler = LocalUriHandler.current
Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp)
ReadableTextWithLink(R.string.you_can_connect_to_simplex_chat_founder, simplexTeamUri)
Text(
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
modifier = Modifier.clickable(onClick = {
uriHandler.openUriCatching(simplexTeamUri)
}),
lineHeight = 22.sp
)
Column(
Modifier.padding(top = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
@@ -145,7 +145,6 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
} else {
MarkUnreadChatAction(chat, chatModel, showMenu)
}
ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu)
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
ClearChatAction(chat, chatModel, showMenu)
DeleteContactAction(chat, chatModel, showMenu)
@@ -160,21 +159,12 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
}
}
GroupMemberStatus.MemAccepted -> {
if (groupInfo.membership.memberCurrent) {
LeaveGroupAction(groupInfo, chatModel, showMenu)
}
if (groupInfo.canDelete) {
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
}
}
else -> {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
} else {
MarkUnreadChatAction(chat, chatModel, showMenu)
}
ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu)
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
ClearChatAction(chat, chatModel, showMenu)
if (groupInfo.membership.memberCurrent) {
@@ -212,25 +202,13 @@ fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableStat
)
}
@Composable
fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState<Boolean>) {
ItemAction(
if (favorite) stringResource(R.string.unfavorite_chat) else stringResource(R.string.favorite_chat),
if (favorite) painterResource(R.drawable.ic_star_off) else painterResource(R.drawable.ic_star),
onClick = {
toggleChatFavorite(chat, !favorite, chatModel)
showMenu.value = false
}
)
}
@Composable
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
ItemAction(
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
if (ntfsEnabled) painterResource(R.drawable.ic_notifications_off) else painterResource(R.drawable.ic_notifications),
onClick = {
toggleNotifications(chat, !ntfsEnabled, chatModel)
changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
showMenu.value = false
}
)
@@ -549,23 +527,13 @@ fun groupInvitationAcceptedAlert() {
)
}
fun toggleNotifications(chat: Chat, enableNtfs: Boolean, chatModel: ChatModel, currentState: MutableState<Boolean>? = null) {
val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = enableNtfs)
updateChatSettings(chat, chatSettings, chatModel, currentState)
}
fun toggleChatFavorite(chat: Chat, favorite: Boolean, chatModel: ChatModel) {
val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(favorite = favorite)
updateChatSettings(chat, chatSettings, chatModel)
}
fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatModel, currentState: MutableState<Boolean>? = null) {
fun changeNtfsStatePerChat(enabled: Boolean, currentState: MutableState<Boolean>, chat: Chat, chatModel: ChatModel) {
val newChatInfo = when(chat.chatInfo) {
is ChatInfo.Direct -> with (chat.chatInfo) {
ChatInfo.Direct(contact.copy(chatSettings = chatSettings))
ChatInfo.Direct(contact.copy(chatSettings = contact.chatSettings.copy(enableNtfs = enabled)))
}
is ChatInfo.Group -> with(chat.chatInfo) {
ChatInfo.Group(groupInfo.copy(chatSettings = chatSettings))
ChatInfo.Group(groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(enableNtfs = enabled)))
}
else -> null
}
@@ -581,13 +549,10 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo
}
if (res && newChatInfo != null) {
chatModel.updateChatInfo(newChatInfo)
if (!chatSettings.enableNtfs) {
if (!enabled) {
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
}
val current = currentState?.value
if (current != null) {
currentState.value = !current
}
currentState.value = enabled
}
}
}
@@ -18,7 +18,6 @@ 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.TextAlign
import androidx.compose.ui.unit.*
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.*
@@ -177,7 +176,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
BackHandler(onBack = hideSearchOnBack)
}
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (chatModel.chats.size > 0) {
if (chatModel.chats.size >= 8) {
barButtons.add {
IconButton({ showSearch = true }) {
Icon(painterResource(R.drawable.ic_search_500), stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
@@ -223,6 +222,11 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(R.string.your_chats),
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
if (chatModel.incognito.value) {
Icon(
painterResource(R.drawable.ic_theater_comedy_filled),
@@ -231,14 +235,6 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
modifier = Modifier.padding(10.dp).size(26.dp)
)
}
Text(
stringResource(R.string.your_chats),
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
if (chatModel.chats.size > 0) {
ToggleFilterButton()
}
}
},
onTitleClick = null,
@@ -279,24 +275,6 @@ private fun BoxScope.unreadBadge(text: String? = "") {
)
}
@Composable
private fun ToggleFilterButton() {
val pref = remember { SimplexApp.context.chatModel.controller.appPrefs.showUnreadAndFavorites }
IconButton(onClick = { pref.set(!pref.get()) }) {
Icon(
painterResource(R.drawable.ic_filter_list),
null,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.primary,
modifier = Modifier
.padding(3.dp)
.background(color = if (pref.state.value) MaterialTheme.colors.primary else MaterialTheme.colors.background, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = MaterialTheme.colors.primary, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(16.dp)
)
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(
@@ -312,12 +290,14 @@ private var lazyListState = 0 to 0
@Composable
private fun ChatList(chatModel: ChatModel, search: String) {
val filter: (Chat) -> Boolean = { chat: Chat ->
chat.chatInfo.chatViewName.lowercase().contains(search.lowercase())
}
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val showUnreadAndFavorites = remember { chatModel.controller.appPrefs.showUnreadAndFavorites.state }.value
val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search) } }
val chats by remember(search) { derivedStateOf { if (search.isEmpty()) chatModel.chats else chatModel.chats.filter(filter) } }
LazyColumn(
modifier = Modifier.fillMaxWidth(),
listState
@@ -326,44 +306,4 @@ private fun ChatList(chatModel: ChatModel, search: String) {
ChatListNavLinkView(chat, chatModel)
}
}
if (chats.isEmpty() && !chatModel.chats.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(generalGetString(R.string.no_filtered_chats), color = MaterialTheme.colors.secondary)
}
}
}
private fun filteredChats(showUnreadAndFavorites: Boolean, searchText: String): List<Chat> {
val chatModel = SimplexApp.context.chatModel
val s = searchText.trim().lowercase()
return if (s.isEmpty() && !showUnreadAndFavorites)
chatModel.chats
else {
chatModel.chats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> if (s.isEmpty()) {
filtered(chat)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
}
is ChatInfo.Group -> if (s.isEmpty()) {
(filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited)
} else {
viewNameContains(cInfo, s)
}
is ChatInfo.ContactRequest -> s.isEmpty() || viewNameContains(cInfo, s)
is ChatInfo.ContactConnection -> s.isNotEmpty() && cInfo.contactConnection.localAlias.lowercase().contains(s)
is ChatInfo.InvalidJSON -> false
}
}
}
}
private fun filtered(chat: Chat): Boolean =
(chat.chatInfo.chatSettings?.favorite ?: false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
cInfo.chatViewName.lowercase().contains(s.lowercase())
@@ -245,21 +245,6 @@ fun ChatPreviewView(
.size(17.dp)
)
}
} else if (chat.chatInfo.chatSettings?.favorite == true) {
Box(
Modifier.padding(top = 24.dp),
contentAlignment = Alignment.Center
) {
Icon(
painterResource(R.drawable.ic_star_filled),
contentDescription = generalGetString(R.string.favorite_chat),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
.size(17.dp)
)
}
}
if (cInfo is ChatInfo.Direct) {
Box(
@@ -35,6 +35,7 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
Column(
modifier = Modifier
.fillMaxSize()
.themedBackground()
) {
if (chatModel.chats.isNotEmpty()) {
ShareList(chatModel, search = searchInList)
@@ -64,6 +64,7 @@ fun DatabaseView(
importArchiveAlert(m, context, uri, appFilesCountAndSize, progressIndicator)
}
}
val chatDbDeleted = remember { m.chatDbDeleted }
LaunchedEffect(m.chatRunning) {
runChat.value = m.chatRunning.value ?: true
}
@@ -82,6 +83,7 @@ fun DatabaseView(
chatArchiveName,
chatArchiveTime,
chatLastStart,
chatDbDeleted.value,
m.controller.appPrefs.privacyFullBackup,
appFilesCountAndSize,
chatItemTTL,
@@ -132,6 +134,7 @@ fun DatabaseLayout(
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
chatDbDeleted: Boolean,
privacyFullBackup: SharedPreference<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
chatItemTTL: MutableState<ChatItemTTL>,
@@ -170,7 +173,7 @@ fun DatabaseLayout(
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(R.string.run_chat_section)) {
RunChatSetting(runChat, stopped, startChat, stopChatAlert)
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
}
SectionDividerSpaced()
@@ -178,7 +181,8 @@ fun DatabaseLayout(
val unencrypted = chatDbEncrypted == false
SettingsActionItem(
if (unencrypted) painterResource(R.drawable.ic_lock_open) else if (useKeyChain) painterResource(R.drawable.ic_vpn_key_filled)
else painterResource(R.drawable.ic_lock),
else painterResource(R
.drawable.ic_lock),
stringResource(R.string.database_passphrase),
click = showSettingsModal() { DatabaseEncryptionView(it) },
iconColor = if (unencrypted) WarningOrange else MaterialTheme.colors.secondary,
@@ -326,6 +330,7 @@ private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onS
fun RunChatSetting(
runChat: Boolean,
stopped: Boolean,
chatDbDeleted: Boolean,
startChat: () -> Unit,
stopChatAlert: () -> Unit
) {
@@ -336,6 +341,7 @@ fun RunChatSetting(
iconColor = if (stopped) Color.Red else MaterialTheme.colors.primary,
) {
DefaultSwitch(
enabled = !chatDbDeleted,
checked = runChat,
onCheckedChange = { runChatSwitch ->
if (runChatSwitch) {
@@ -365,14 +371,9 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta
ModalManager.shared.closeModals()
return@withApi
}
if (m.currentUser.value == null) {
ModalManager.shared.closeModals()
return@withApi
} else {
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
}
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
val ts = Clock.System.now()
m.controller.appPrefs.chatLastStart.set(ts)
chatLastStart.value = ts
@@ -409,7 +410,7 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>, context:
authenticate(
generalGetString(R.string.auth_stop_chat),
generalGetString(R.string.auth_log_in_using_credential),
activity = context as FragmentActivity,
context as FragmentActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success, is LAResult.Unavailable -> {
@@ -432,28 +433,18 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>, context:
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
withApi {
try {
m.controller.apiStopChat()
runChat.value = false
stopChatAsync(m)
SimplexService.safeStopService(SimplexApp.context)
m.chatRunning.value = false
SimplexService.safeStopService(context)
MessagesFetcherWorker.cancelAll()
} catch (e: Error) {
runChat.value = true
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_stopping_chat), e.toString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
}
}
}
suspend fun stopChatAsync(m: ChatModel) {
m.controller.apiStopChat()
m.chatRunning.value = false
}
suspend fun deleteChatAsync(m: ChatModel) {
m.controller.apiDeleteStorage()
DatabaseUtils.ksDatabasePassword.remove()
m.controller.appPrefs.storeDBPassphrase.set(true)
}
private fun exportArchive(
context: Context,
m: ChatModel,
@@ -573,17 +564,11 @@ private fun importArchive(
m.controller.apiDeleteStorage()
try {
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
val archiveErrors = m.controller.apiImportArchive(config)
m.controller.apiImportArchive(config)
DatabaseUtils.ksDatabasePassword.remove()
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
if (archiveErrors.isEmpty()) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), text = generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
}
} else {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), text = generalGetString(R.string.restart_the_app_to_use_imported_chat_database) + "\n" + generalGetString(R.string.non_fatal_errors_occured_during_import))
}
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
}
} catch (e: Error) {
operationEnded(m, progressIndicator) {
@@ -634,7 +619,10 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
progressIndicator.value = true
withApi {
try {
deleteChatAsync(m)
m.controller.apiDeleteStorage()
m.chatDbDeleted.value = true
DatabaseUtils.ksDatabasePassword.remove()
m.controller.appPrefs.storeDBPassphrase.set(true)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
}
@@ -729,6 +717,7 @@ fun PreviewDatabaseLayout() {
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
chatDbDeleted = false,
privacyFullBackup = SharedPreference({ true }, {}),
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
@@ -12,7 +12,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.app.ui.theme.*
@@ -44,7 +43,7 @@ fun CloseSheetBar(close: (() -> Unit)?, endButtons: @Composable RowScope.() -> U
}
@Composable
fun AppBarTitle(title: String, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f) {
fun AppBarTitle(title: String, withPadding: Boolean = true) {
val theme = CurrentColors.collectAsState()
val titleColor = CurrentColors.collectAsState().value.appColors.title
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
@@ -55,7 +54,7 @@ fun AppBarTitle(title: String, withPadding: Boolean = true, bottomPadding: Dp =
title,
Modifier
.fillMaxWidth()
.padding(bottom = bottomPadding, start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp,),
.padding(bottom = DEFAULT_PADDING * 1.5f, start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp,),
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h1.copy(brush = brush),
color = MaterialTheme.colors.primaryVariant,
@@ -1,290 +0,0 @@
package chat.simplex.app.views.helpers
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import com.sd.lib.compose.wheel_picker.*
@Composable
fun CustomTimePicker(
selection: MutableState<Int>,
timeUnitsLimits: List<TimeUnitLimits> = TimeUnitLimits.defaultUnitsLimits
) {
fun getUnitValues(unit: CustomTimeUnit, selectedValue: Int): List<Int> {
val unitLimits = timeUnitsLimits.firstOrNull { it.timeUnit == unit } ?: TimeUnitLimits.defaultUnitLimits(unit)
val regularUnitValues = (unitLimits.minValue..unitLimits.maxValue).toList()
return regularUnitValues + if (regularUnitValues.contains(selectedValue)) emptyList() else listOf(selectedValue)
}
val (unit, duration) = CustomTimeUnit.toTimeUnit(selection.value)
val selectedUnit: MutableState<CustomTimeUnit> = remember { mutableStateOf(unit) }
val selectedDuration = remember { mutableStateOf(duration) }
val selectedUnitValues = remember { mutableStateOf(getUnitValues(selectedUnit.value, selectedDuration.value)) }
val isTriggered = remember { mutableStateOf(false) }
LaunchedEffect(selectedUnit.value) {
// on initial composition, if passed selection doesn't fit into picker bounds, so that selectedDuration is bigger than selectedUnit maxValue
// (e.g., for selection = 121 seconds: selectedUnit would be Second, selectedDuration would be 121 > selectedUnit maxValue of 120),
// selectedDuration would've been replaced by maxValue - isTriggered check prevents this by skipping LaunchedEffect on initial composition
if (isTriggered.value) {
val maxValue = timeUnitsLimits.firstOrNull { it.timeUnit == selectedUnit.value }?.maxValue
if (maxValue != null && selectedDuration.value > maxValue) {
selectedDuration.value = maxValue
selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value)
} else {
selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value)
selection.value = selectedUnit.value.toSeconds * selectedDuration.value
}
} else {
isTriggered.value = true
}
}
LaunchedEffect(selectedDuration.value) {
selection.value = selectedUnit.value.toSeconds * selectedDuration.value
}
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
Column(Modifier.weight(1f)) {
val durationPickerState = rememberFWheelPickerState(selectedUnitValues.value.indexOf(selectedDuration.value))
FVerticalWheelPicker(
count = selectedUnitValues.value.count(),
state = durationPickerState,
unfocusedCount = 2,
focus = {
FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary)
}
) { index ->
Text(
selectedUnitValues.value[index].toString(),
fontSize = 18.sp,
color = MaterialTheme.colors.primary
)
}
LaunchedEffect(durationPickerState) {
snapshotFlow { durationPickerState.currentIndex }
.collect {
selectedDuration.value = selectedUnitValues.value[it]
}
}
}
Column(Modifier.weight(1f)) {
val unitPickerState = rememberFWheelPickerState(timeUnitsLimits.indexOfFirst { it.timeUnit == selectedUnit.value })
FVerticalWheelPicker(
count = timeUnitsLimits.count(),
state = unitPickerState,
unfocusedCount = 2,
focus = {
FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary)
}
) { index ->
Text(
timeUnitsLimits[index].timeUnit.text,
fontSize = 18.sp,
color = MaterialTheme.colors.primary
)
}
LaunchedEffect(unitPickerState) {
snapshotFlow { unitPickerState.currentIndex }
.collect {
selectedUnit.value = timeUnitsLimits[it].timeUnit
}
}
}
}
}
data class TimeUnitLimits(
val timeUnit: CustomTimeUnit,
val minValue: Int = 1,
val maxValue: Int
) {
companion object {
fun defaultUnitLimits(unit: CustomTimeUnit): TimeUnitLimits {
return when (unit) {
CustomTimeUnit.Second -> TimeUnitLimits(CustomTimeUnit.Second, maxValue = 120)
CustomTimeUnit.Minute -> TimeUnitLimits(CustomTimeUnit.Minute, maxValue = 120)
CustomTimeUnit.Hour -> TimeUnitLimits(CustomTimeUnit.Hour, maxValue = 72)
CustomTimeUnit.Day -> TimeUnitLimits(CustomTimeUnit.Day, maxValue = 60)
CustomTimeUnit.Week -> TimeUnitLimits(CustomTimeUnit.Week, maxValue = 52)
CustomTimeUnit.Month -> TimeUnitLimits(CustomTimeUnit.Month, maxValue = 12)
}
}
val defaultUnitsLimits: List<TimeUnitLimits>
get() = listOf(
defaultUnitLimits(CustomTimeUnit.Second),
defaultUnitLimits(CustomTimeUnit.Minute),
defaultUnitLimits(CustomTimeUnit.Hour),
defaultUnitLimits(CustomTimeUnit.Day),
defaultUnitLimits(CustomTimeUnit.Week),
defaultUnitLimits(CustomTimeUnit.Month)
)
}
}
@Composable
fun CustomTimePickerDialog(
selection: MutableState<Int>,
timeUnitsLimits: List<TimeUnitLimits> = TimeUnitLimits.defaultUnitsLimits,
title: String,
confirmButtonText: String,
confirmButtonAction: (Int) -> Unit,
cancel: () -> Unit
) {
Dialog(onDismissRequest = cancel) {
Surface(
shape = RoundedCornerShape(corner = CornerSize(25.dp))
) {
Box(
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.padding(DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(" ") // centers title
Text(
title,
fontSize = 16.sp,
color = MaterialTheme.colors.secondary
)
Icon(
painterResource(R.drawable.ic_close),
generalGetString(R.string.icon_descr_close_button),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(25.dp)
.clickable { cancel() }
)
}
CustomTimePicker(
selection,
timeUnitsLimits
)
TextButton(onClick = { confirmButtonAction(selection.value) }) {
Text(
confirmButtonText,
fontSize = 18.sp,
color = MaterialTheme.colors.primary
)
}
}
}
}
}
}
@Composable
fun DropdownCustomTimePickerSettingRow(
selection: MutableState<Int?>,
propagateExternalSelectionUpdate: Boolean = false,
label: String,
dropdownValues: List<Int?>,
customPickerTitle: String,
customPickerConfirmButtonText: String,
customPickerTimeUnitsLimits: List<TimeUnitLimits> = TimeUnitLimits.defaultUnitsLimits,
onSelected: (Int?) -> Unit
) {
fun getValues(selectedValue: Int?): List<DropdownSelection> =
dropdownValues.map { DropdownSelection.DropdownValue(it) } +
(if (dropdownValues.contains(selectedValue)) listOf() else listOf(DropdownSelection.DropdownValue(selectedValue))) +
listOf(DropdownSelection.Custom)
val dropdownSelection: MutableState<DropdownSelection> = remember { mutableStateOf(DropdownSelection.DropdownValue(selection.value)) }
val values: MutableState<List<DropdownSelection>> = remember { mutableStateOf(getValues(selection.value)) }
val showCustomTimePicker = remember { mutableStateOf(false) }
fun updateValue(selectedValue: Int?) {
values.value = getValues(selectedValue)
dropdownSelection.value = DropdownSelection.DropdownValue(selectedValue)
onSelected(selectedValue)
}
if (propagateExternalSelectionUpdate) {
LaunchedEffect(selection.value) {
values.value = getValues(selection.value)
dropdownSelection.value = DropdownSelection.DropdownValue(selection.value)
}
}
ExposedDropDownSettingRow(
label,
values.value.map { sel: DropdownSelection ->
when (sel) {
is DropdownSelection.DropdownValue -> sel to timeText(sel.value)
DropdownSelection.Custom -> sel to generalGetString(R.string.custom_time_picker_custom)
}
},
dropdownSelection,
onSelected = { sel: DropdownSelection ->
when (sel) {
is DropdownSelection.DropdownValue -> updateValue(sel.value)
DropdownSelection.Custom -> showCustomTimePicker.value = true
}
}
)
if (showCustomTimePicker.value) {
val selectedCustomTime = remember { mutableStateOf(selection.value ?: 86400) }
CustomTimePickerDialog(
selectedCustomTime,
timeUnitsLimits = customPickerTimeUnitsLimits,
title = customPickerTitle,
confirmButtonText = customPickerConfirmButtonText,
confirmButtonAction = { time ->
updateValue(time)
showCustomTimePicker.value = false
},
cancel = {
dropdownSelection.value = DropdownSelection.DropdownValue(selection.value)
showCustomTimePicker.value = false
}
)
}
}
private sealed class DropdownSelection {
data class DropdownValue(val value: Int?): DropdownSelection()
object Custom: DropdownSelection()
override fun equals(other: Any?): Boolean =
other is DropdownSelection &&
when (other) {
is DropdownValue -> this is DropdownValue && this.value == other.value
is Custom -> this is Custom
}
override fun hashCode(): Int =
// DO NOT REMOVE the as? cast as it will turn them into recursive hashCode calls
// https://youtrack.jetbrains.com/issue/KT-31239
when (this) {
is DropdownValue -> (this as? DropdownValue).hashCode()
is Custom -> (this as? Custom).hashCode()
}
}
@@ -18,11 +18,9 @@ object DatabaseUtils {
private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
private const val APP_PASSWORD_ALIAS: String = "appPassword"
private const val SELF_DESTRUCT_PASSWORD_ALIAS: String = "selfDestructPassword"
val ksDatabasePassword = KeyStoreItem(DATABASE_PASSWORD_ALIAS, appPreferences.encryptedDBPassphrase, appPreferences.initializationVectorDBPassphrase)
val ksAppPassword = KeyStoreItem(APP_PASSWORD_ALIAS, appPreferences.encryptedAppPassphrase, appPreferences.initializationVectorAppPassphrase)
val ksSelfDestructPassword = KeyStoreItem(SELF_DESTRUCT_PASSWORD_ALIAS, appPreferences.encryptedSelfDestructPassphrase, appPreferences.initializationVectorSelfDestructPassphrase)
class KeyStoreItem(private val alias: String, val passphrase: SharedPreference<String?>, val initVector: SharedPreference<String?>) {
fun get(): String? {
@@ -33,7 +33,7 @@ fun DefaultTopAppBar(
if (!showSearch) {
title?.invoke()
} else {
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = false, onValueChange = onSearchValueChanged)
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), alwaysVisible = false, onSearchValueChanged)
}
},
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
@@ -77,7 +77,7 @@ suspend fun getLinkPreview(url: String): LinkPreview? {
}
@Composable
fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancelEnabled: Boolean) {
fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
Row(
Modifier.fillMaxWidth().padding(top = 8.dp).background(sentColor),
@@ -109,15 +109,13 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel
)
}
}
if (cancelEnabled) {
IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
Icon(
painterResource(R.drawable.ic_close),
contentDescription = stringResource(R.string.icon_descr_cancel_link_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
Icon(
painterResource(R.drawable.ic_close),
contentDescription = stringResource(R.string.icon_descr_cancel_link_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
@@ -195,7 +193,7 @@ fun PreviewChatItemLinkView() {
@Composable
fun PreviewComposeLinkView() {
SimpleXTheme {
ComposeLinkView(LinkPreview.sampleData, cancelPreview = { -> }, true)
ComposeLinkView(LinkPreview.sampleData) { -> }
}
}
@@ -203,6 +201,6 @@ fun PreviewComposeLinkView() {
@Composable
fun PreviewComposeLinkViewLoading() {
SimpleXTheme {
ComposeLinkView(null, cancelPreview = { -> }, true)
ComposeLinkView(null) { -> }
}
}
@@ -28,18 +28,16 @@ data class LocalAuthRequest (
val title: String?,
val reason: String,
val password: String,
val selfDestruct: Boolean,
val completed: (LAResult) -> Unit
) {
companion object {
val sample = LocalAuthRequest(generalGetString(R.string.la_enter_app_passcode), generalGetString(R.string.la_authenticate), "", selfDestruct = false) { }
val sample = LocalAuthRequest(generalGetString(R.string.la_enter_app_passcode), generalGetString(R.string.la_authenticate), "") { }
}
}
fun authenticate(
promptTitle: String,
promptSubtitle: String,
selfDestruct: Boolean = false,
activity: FragmentActivity,
usingLAMode: LAMode = SimplexApp.context.chatModel.controller.appPrefs.laMode.get(),
completed: (LAResult) -> Unit
@@ -55,13 +53,13 @@ fun authenticate(
}
LAMode.PASSCODE -> {
val password = ksAppPassword.get() ?: return completed(LAResult.Unavailable(generalGetString(R.string.la_no_app_password)))
ModalManager.shared.showPasscodeCustomModal { close ->
ModalManager.shared.showCustomModal(animated = false) { close ->
BackHandler {
close()
completed(LAResult.Error(generalGetString(R.string.authentication_cancelled)))
}
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && SimplexApp.context.chatModel.controller.appPrefs.selfDestruct.get()) {
LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password) {
close()
completed(it)
})
@@ -56,7 +56,6 @@ class MessagesFetcherWork(
try {
withTimeout(durationSeconds * 1000L) {
val chatController = (applicationContext as SimplexApp).chatController
SimplexService.waitDbMigrationEnds(chatController)
val chatDbStatus = chatController.chatModel.chatDbStatus.value
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(TAG, "Worker: problem with the database: $chatDbStatus")
@@ -1,6 +1,5 @@
package chat.simplex.app.views.helpers
import android.R
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
@@ -9,13 +8,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import chat.simplex.app.TAG
import chat.simplex.app.ui.theme.isInDarkTheme
import chat.simplex.app.ui.theme.themedBackground
@@ -43,7 +37,6 @@ class ModalManager {
private val modalCount = mutableStateOf(0)
private val toRemove = mutableSetOf<Int>()
private var oldViewChanging = AtomicBoolean(false)
private var passcodeView: MutableState<(@Composable (close: () -> Unit) -> Unit)?> = mutableStateOf(null)
fun showModal(settings: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit) {
showCustomModal { close ->
@@ -58,7 +51,7 @@ class ModalManager {
}
fun showCustomModal(animated: Boolean = true, modal: @Composable (close: () -> Unit) -> Unit) {
Log.d(TAG, "ModalManager.showCustomModal")
Log.d(TAG, "ModalManager.showModal")
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
// This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view
if (toRemove.isNotEmpty()) {
@@ -68,11 +61,6 @@ class ModalManager {
modalCount.value = modalViews.size - toRemove.size
}
fun showPasscodeCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
Log.d(TAG, "ModalManager.showPasscodeCustomModal")
passcodeView.value = modal
}
fun hasModalsOpen() = modalCount.value > 0
fun closeModal() {
@@ -84,9 +72,7 @@ class ModalManager {
}
fun closeModals() {
modalViews.clear()
toRemove.clear()
modalCount.value = 0
while (modalCount.value > 0) closeModal()
}
@OptIn(ExperimentalAnimationApi::class)
@@ -114,11 +100,6 @@ class ModalManager {
}
}
@Composable
fun showPasscodeInView() {
remember { passcodeView }.value?.invoke { passcodeView.value = null }
}
/**
* Allows to modify a list without getting [ConcurrentModificationException]
* */
@@ -21,7 +21,7 @@ interface Recorder {
fun stop(): Int
}
class RecorderNative(): Recorder {
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
@@ -48,8 +48,9 @@ class RecorderNative(): Recorder {
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioChannels(1)
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(32000)
rec.setAudioEncodingBitRate(16000)
rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING)
rec.setMaxFileSize(recordedBytesLimit)
val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE)
val fileToSave = File.createTempFile(generateNewFileName(SimplexApp.context, "voice", "${extension}_"), ".tmp", tmpDir)
fileToSave.deleteOnExit()
@@ -273,13 +274,6 @@ object AudioPlayer {
audioPlaying.value = false
}
fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) {
pro.value = ms
if (this.currentlyPlaying.value?.first == filePath) {
player.seekTo(ms)
}
}
fun duration(filePath: String): Int? {
var res: Int? = null
kotlin.runCatching {
@@ -29,13 +29,8 @@ import kotlinx.coroutines.delay
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SearchTextField(
modifier: Modifier,
alwaysVisible: Boolean,
searchText: MutableState<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) },
placeholder: String = stringResource(android.R.string.search_go),
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
@@ -50,7 +45,7 @@ fun SearchTextField(
DisposableEffect(Unit) {
onDispose {
if (searchText.value.text.isNotEmpty()) onValueChange("")
if (searchText.text.isNotEmpty()) onValueChange("")
}
}
@@ -64,7 +59,7 @@ fun SearchTextField(
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
val interactionSource = remember { MutableInteractionSource() }
BasicTextField(
value = searchText.value,
value = searchText,
modifier = modifier
.background(colors.backgroundColor(enabled).value, shape)
.indicatorLine(enabled, false, interactionSource, colors)
@@ -74,7 +69,7 @@ fun SearchTextField(
minHeight = TextFieldDefaults.MinHeight
),
onValueChange = {
searchText.value = it
searchText = it
onValueChange(it.text)
},
cursorBrush = SolidColor(colors.cursorColor(false).value),
@@ -89,18 +84,18 @@ fun SearchTextField(
interactionSource = interactionSource,
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = searchText.value.text,
value = searchText.text,
innerTextField = innerTextField,
placeholder = {
Text(placeholder)
},
trailingIcon = if (searchText.value.text.isNotEmpty()) {{
trailingIcon = if (searchText.text.isNotEmpty()) {{
IconButton({
if (alwaysVisible) {
keyboard?.hide()
focusManager.clearFocus()
}
searchText.value = TextFieldValue("");
searchText = TextFieldValue("");
onValueChange("")
}) {
Icon(painterResource(R.drawable.ic_close), stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
@@ -202,9 +202,9 @@ fun SectionDividerSpaced(maxTopPadding: Boolean = false, maxBottomPadding: Boole
Divider(
Modifier.padding(
start = DEFAULT_PADDING_HALF,
top = if (maxTopPadding) 37.dp else 27.dp,
top = if (maxTopPadding) 40.dp else 30.dp,
end = DEFAULT_PADDING_HALF,
bottom = if (maxBottomPadding) 37.dp else 27.dp)
bottom = if (maxBottomPadding) 40.dp else 30.dp)
)
}
@@ -4,7 +4,6 @@ import android.Manifest
import android.content.*
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
@@ -49,17 +48,6 @@ fun copyText(cxt: Context, text: String) {
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
}
fun sendEmail(context: Context, subject: String, body: CharSequence) {
val emailIntent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject)
emailIntent.putExtra(Intent.EXTRA_TEXT, body)
try {
context.startActivity(emailIntent)
} catch (e: ActivityNotFoundException) {
Log.e(TAG, "No activity was found for handling email intent")
}
}
@Composable
fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(
@@ -79,7 +79,7 @@ fun SimpleButtonIconEnded(
}
@Composable
fun SimpleButtonFrame(click: () -> Unit, modifier: Modifier = Modifier, disabled: Boolean = false, content: @Composable RowScope.() -> Unit) {
fun SimpleButtonFrame(click: () -> Unit, modifier: Modifier = Modifier, disabled: Boolean = false, content: @Composable () -> Unit) {
Box(Modifier.clip(RoundedCornerShape(20.dp))) {
val modifier = if (disabled) modifier else modifier.clickable { click() }
Row(
@@ -1,117 +1,64 @@
package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.*
import chat.simplex.app.TAG
import chat.simplex.app.chatParseMarkdown
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.MarkdownText
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.Serializable
import java.lang.Exception
import chat.simplex.app.ui.theme.DEFAULT_PADDING
@Composable
fun TextEditor(
value: MutableState<String>,
modifier: Modifier,
placeholder: String? = null,
contentPadding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING),
isValid: (String) -> Boolean = { true },
focusRequester: FocusRequester? = null
text: MutableState<String>,
border: Boolean = true,
fontSize: TextUnit = 14.sp,
background: Color = MaterialTheme.colors.background,
onChange: ((String) -> Unit)? = null
) {
var valid by rememberSaveable { mutableStateOf(true) }
var focused by rememberSaveable { mutableStateOf(false) }
val strokeColor by remember {
derivedStateOf {
if (valid) {
if (focused) {
CurrentColors.value.colors.secondary.copy(alpha = 0.6f)
} else {
CurrentColors.value.colors.secondary.copy(alpha = 0.3f)
BasicTextField(
value = text.value,
onValueChange = { text.value = it; onChange?.invoke(it) },
textStyle = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = fontSize,
color = MaterialTheme.colors.onBackground
),
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
modifier = modifier,
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
decorationBox = { innerTextField ->
Surface(
shape = if (border) RoundedCornerShape(10.dp) else RectangleShape,
border = if (border) BorderStroke(1.dp, MaterialTheme.colors.secondaryVariant) else null
) {
Row(
Modifier.background(background),
verticalAlignment = Alignment.Top
) {
Box(
Modifier
.weight(1f)
.padding(vertical = 5.dp, horizontal = if (border) 7.dp else DEFAULT_PADDING)
) {
innerTextField()
}
}
} else Color.Red
}
}
}
Box(
Modifier
.fillMaxWidth()
.padding(contentPadding)
.heightIn(min = 52.dp),
// .border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(26.dp)),
contentAlignment = Alignment.Center,
) {
val modifier = modifier
.fillMaxWidth()
.navigationBarsWithImePadding()
.onFocusChanged { focused = it.isFocused }
BasicTextField(
value = value.value,
onValueChange = { value.value = it },
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = false,
maxLines = 5,
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = value.value,
innerTextField = innerTextField,
placeholder = if (placeholder != null) {{ Text(placeholder, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary, lineHeight = 22.sp)) }} else null,
contentPadding = PaddingValues(),
label = null,
visualTransformation = VisualTransformation.None,
leadingIcon = null,
trailingIcon = null,
singleLine = false,
enabled = true,
isError = false,
interactionSource = remember { MutableInteractionSource() },
)
}
)
}
LaunchedEffect(Unit) {
snapshotFlow { value.value }
.distinctUntilChanged()
.collect {
valid = isValid(it)
}
}
}
@Serializable
data class ParsedFormattedText(
val formattedText: List<FormattedText>? = null
)
fun parseToMarkdown(text: String): List<FormattedText>? {
val formatted = chatParseMarkdown(text)
return try {
json.decodeFromString(ParsedFormattedText.serializer(), formatted).formattedText
} catch (e: Exception) {
Log.e(TAG, "Failed to parse into markdown: " + e.stackTraceToString())
null
}
)
}
@@ -23,11 +23,9 @@ import android.view.View
import android.view.ViewTreeObserver
import android.view.inputmethod.InputMethodManager
import androidx.annotation.StringRes
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.*
@@ -35,11 +33,11 @@ import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.*
import androidx.core.content.FileProvider
import androidx.core.graphics.ColorUtils
import androidx.core.text.HtmlCompat
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.ThemeData
import chat.simplex.app.ui.theme.ThemeOverrides
import com.charleskorn.kaml.decodeFromStream
import kotlinx.coroutines.*
@@ -236,16 +234,16 @@ private fun spannableStringToAnnotatedString(
}
// maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB
const val MAX_IMAGE_SIZE: Long = 236700
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_VIDEO_SIZE_AUTO_RCV: Long = 1_047_552 // 1023KB
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 300_000
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_SMP: Long = 8000000
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824
fun getFilesDirectory(context: Context): String {
return context.filesDir.toString()
@@ -393,10 +391,10 @@ fun getDrawableFromUri(uri: Uri, withAlertOnException: Boolean = true): Drawable
}
}
fun getThemeFromUri(uri: Uri, withAlertOnException: Boolean = true): ThemeOverrides? {
fun getThemeFromUri(uri: Uri, withAlertOnException: Boolean = true): ThemeData? {
SimplexApp.context.contentResolver.openInputStream(uri).use {
runCatching {
return yaml.decodeFromStream<ThemeOverrides>(it!!)
return yaml.decodeFromStream<ThemeData>(it!!)
}.onFailure {
if (withAlertOnException) {
AlertManager.shared.showAlertMsg(
@@ -590,9 +588,6 @@ fun Color.darker(factor: Float = 0.1f): Color =
fun Color.lighter(factor: Float = 0.1f): Color =
Color(min(red * (1 + factor), 1f), min(green * (1 + factor), 1f), min(blue * (1 + factor), 1f), alpha)
fun Color.mixWith(color: Color, alpha: Float): Color =
Color(ColorUtils.blendARGB(color.toArgb(), toArgb(), alpha))
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
@@ -1,80 +1,22 @@
package chat.simplex.app.views.localauth
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
import chat.simplex.app.*
import chat.simplex.app.model.*
import chat.simplex.app.views.database.deleteChatAsync
import chat.simplex.app.views.database.stopChatAsync
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.delay
@Composable
fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) {
val passcode = rememberSaveable { mutableStateOf("") }
PasscodeView(passcode, authRequest.title ?: stringResource(R.string.la_enter_app_passcode), authRequest.reason, stringResource(R.string.submit_passcode),
submit = {
val sdPassword = ksSelfDestructPassword.get()
if (sdPassword == passcode.value && authRequest.selfDestruct) {
deleteStorageAndRestart(m, sdPassword) { r ->
authRequest.completed(r)
}
} else {
val r: LAResult = if (passcode.value == authRequest.password) LAResult.Success else LAResult.Error(generalGetString(R.string.incorrect_passcode))
authRequest.completed(r)
}
val r: LAResult = if (passcode.value == authRequest.password) LAResult.Success else LAResult.Error(generalGetString(R.string.incorrect_passcode))
authRequest.completed(r)
},
cancel = {
authRequest.completed(LAResult.Error(generalGetString(R.string.authentication_cancelled)))
})
}
private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (LAResult) -> Unit) {
withBGApi {
try {
stopChatAsync(m)
deleteChatAsync(m)
ksAppPassword.set(password)
ksSelfDestructPassword.remove()
m.controller.ntfManager.cancelAllNotifications()
val selfDestructPref = m.controller.appPrefs.selfDestruct
val displayNamePref = m.controller.appPrefs.selfDestructDisplayName
val displayName = displayNamePref.get()
selfDestructPref.set(false)
displayNamePref.set(null)
m.chatDbChanged.value = true
m.chatDbStatus.value = null
try {
SimplexApp.context.initChatController(startChat = true)
} catch (e: Exception) {
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
}
m.chatDbChanged.value = false
if (m.currentUser.value != null) {
return@withBGApi
}
var profile: Profile? = null
if (!displayName.isNullOrEmpty()) {
profile = Profile(displayName = displayName, fullName = "")
}
val createdUser = m.controller.apiCreateActiveUser(profile, pastTimestamp = true)
m.currentUser.value = createdUser
m.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
m.onboardingStage.value = OnboardingStage.OnboardingComplete
if (createdUser != null) {
m.controller.startChat(createdUser)
}
ModalManager.shared.closeModals()
AlertManager.shared.hideAlert()
completed(LAResult.Success)
} catch (e: Exception) {
completed(LAResult.Error(generalGetString(R.string.incorrect_passcode)))
}
}
}
@@ -4,15 +4,11 @@ import androidx.activity.compose.BackHandler
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import chat.simplex.app.R
import chat.simplex.app.views.helpers.DatabaseUtils
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun SetAppPasscodeView(
passcodeKeychain: DatabaseUtils.KeyStoreItem = ksAppPassword,
title: String = generalGetString(R.string.new_passcode),
reason: String? = null,
submit: () -> Unit,
cancel: () -> Unit,
close: () -> Unit
@@ -27,7 +23,7 @@ fun SetAppPasscodeView(
close()
cancel()
}
PasscodeView(passcode, title = title, reason = reason, submitLabel = submitLabel, submitEnabled = submitEnabled, submit = submit) {
PasscodeView(passcode, title = title, submitLabel = submitLabel, submitEnabled = submitEnabled, submit = submit) {
close()
cancel()
}
@@ -40,7 +36,7 @@ fun SetAppPasscodeView(
submitEnabled = { pwd -> pwd == enteredPassword }
) {
if (passcode.value == enteredPassword) {
passcodeKeychain.set(passcode.value)
ksAppPassword.set(passcode.value)
enteredPassword = ""
passcode.value = ""
close()
@@ -48,7 +44,7 @@ fun SetAppPasscodeView(
}
}
} else {
SetPasswordView(title, generalGetString(R.string.save_verb)) {
SetPasswordView(generalGetString(R.string.new_passcode), generalGetString(R.string.save_verb)) {
enteredPassword = passcode.value
passcode.value = ""
confirming = true
@@ -1,23 +0,0 @@
package chat.simplex.app.views.newchat
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.views.helpers.AppBarTitle
import chat.simplex.app.views.onboarding.ReadableText
import chat.simplex.app.views.onboarding.ReadableTextWithLink
@Composable
fun AddContactLearnMore() {
Column(
Modifier.verticalScroll(rememberScrollState()),
) {
AppBarTitle(stringResource(R.string.one_time_link))
ReadableText(R.string.scan_qr_to_connect_to_contact)
ReadableText(R.string.if_you_cant_meet_in_person)
ReadableTextWithLink(R.string.read_more_in_user_guide_with_link, "https://simplex.chat/docs/guide/readme.html#connect-to-friends")
}
}
@@ -1,8 +1,6 @@
package chat.simplex.app.views.newchat
import SectionBottomSpacer
import SectionSpacer
import SectionView
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@@ -17,10 +15,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
@Composable
fun AddContactView(connReqInvitation: String, connIncognito: Boolean) {
@@ -28,92 +26,62 @@ fun AddContactView(connReqInvitation: String, connIncognito: Boolean) {
AddContactLayout(
connReq = connReqInvitation,
connIncognito = connIncognito,
share = { shareText(cxt, connReqInvitation) },
learnMore = {
ModalManager.shared.showModal {
Column(
Modifier
.fillMaxHeight()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.SpaceBetween
) {
AddContactLearnMore()
}
}
}
share = { shareText(cxt, connReqInvitation) }
)
}
@Composable
fun AddContactLayout(connReq: String, connIncognito: Boolean, share: () -> Unit, learnMore: () -> Unit) {
Column(
Modifier
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween,
) {
AppBarTitle(stringResource(R.string.add_contact))
OneTimeLinkProfileText(connIncognito)
SectionSpacer()
SectionView(stringResource(R.string.one_time_link_short).uppercase()) {
OneTimeLinkSection(connReq, share, learnMore)
}
SectionBottomSpacer()
}
}
@Composable
fun OneTimeLinkProfileText(connIncognito: Boolean) {
Row(Modifier.padding(horizontal = DEFAULT_PADDING)) {
InfoAboutIncognito(
connIncognito,
true,
generalGetString(R.string.incognito_random_profile_description),
generalGetString(R.string.your_profile_will_be_sent)
)
}
}
@Composable
fun ColumnScope.OneTimeLinkSection(connReq: String, share: () -> Unit, learnMore: () -> Unit) {
if (connReq.isNotEmpty()) {
QRCode(
connReq, Modifier
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
} else {
CircularProgressIndicator(
fun AddContactLayout(connReq: String, connIncognito: Boolean, share: () -> Unit) {
BoxWithConstraints {
val screenHeight = maxHeight
Column(
Modifier
.size(36.dp)
.padding(4.dp)
.align(Alignment.CenterHorizontally),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween,
) {
AppBarTitle(stringResource(R.string.add_contact), false)
Text(
stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
)
Row {
InfoAboutIncognito(
connIncognito,
true,
generalGetString(R.string.incognito_random_profile_description),
generalGetString(R.string.your_profile_will_be_sent)
)
}
if (connReq.isNotEmpty()) {
QRCode(
connReq, Modifier
.aspectRatio(1f)
.padding(vertical = 3.dp)
)
} else {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp)
.align(Alignment.CenterHorizontally),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
Text(
annotatedStringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
lineHeight = 22.sp,
modifier = Modifier
.padding(top = DEFAULT_PADDING, bottom = if (screenHeight > 600.dp) DEFAULT_PADDING else 0.dp)
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
SimpleButton(stringResource(R.string.share_invitation_link), icon = painterResource(R.drawable.ic_share), click = share)
}
SectionBottomSpacer()
}
}
ShareLinkButton(share)
OneTimeLinkLearnMoreButton(learnMore)
}
@Composable
fun ShareLinkButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(R.drawable.ic_share),
stringResource(R.string.share_invitation_link),
onClick,
iconColor = MaterialTheme.colors.primary,
textColor = MaterialTheme.colors.primary,
)
}
@Composable
fun OneTimeLinkLearnMoreButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(R.drawable.ic_info),
stringResource(R.string.learn_more),
onClick,
)
}
@Composable
@@ -165,8 +133,7 @@ fun PreviewAddContactView() {
AddContactLayout(
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
connIncognito = false,
share = {},
learnMore = {},
share = {}
)
}
}
@@ -159,7 +159,7 @@ fun CreateGroupButton(color: Color, modifier: Modifier) {
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) {
Surface(shape = RoundedCornerShape(20.dp)) {
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = color, fontWeight = FontWeight.Bold)
Icon(painterResource(R.drawable.ic_arrow_forward_ios), stringResource(R.string.create_profile_button), tint = color)
@@ -1,7 +1,6 @@
package chat.simplex.app.views.newchat
import SectionBottomSpacer
import SectionDividerSpaced
import SectionView
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
@@ -11,7 +10,6 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@@ -45,16 +43,13 @@ fun ContactConnectionInfoView(
}
}
}
val context = LocalContext.current
ContactConnectionInfoLayout(
connReq = connReqInvitation,
contactConnection,
connIncognito = contactConnection.incognito,
focusAlias,
deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) },
onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) },
share = { if (connReqInvitation != null) shareText(context, connReqInvitation) },
learnMore = {
showQr = {
ModalManager.shared.showModal {
Column(
Modifier
@@ -62,7 +57,7 @@ fun ContactConnectionInfoView(
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.SpaceBetween
) {
AddContactLearnMore()
AddContactView(connReqInvitation ?: return@showModal, contactConnection.incognito)
}
}
}
@@ -73,12 +68,10 @@ fun ContactConnectionInfoView(
private fun ContactConnectionInfoLayout(
connReq: String?,
contactConnection: PendingContactConnection,
connIncognito: Boolean,
focusAlias: Boolean,
deleteConnection: () -> Unit,
onLocalAliasChanged: (String) -> Unit,
share: () -> Unit,
learnMore: () -> Unit,
showQr: () -> Unit,
) {
Column(
Modifier
@@ -90,6 +83,11 @@ private fun ContactConnectionInfoLayout(
else R.string.you_accepted_connection
)
)
if (contactConnection.groupLinkId == null) {
Row(Modifier.padding(bottom = DEFAULT_PADDING)) {
LocalAliasEditor(contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged)
}
}
Text(
stringResource(
if (contactConnection.viaContactUri)
@@ -99,28 +97,27 @@ private fun ContactConnectionInfoLayout(
),
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
)
OneTimeLinkProfileText(connIncognito)
if (contactConnection.groupLinkId == null) {
LocalAliasEditor(contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged)
}
SectionView {
if (!connReq.isNullOrEmpty() && contactConnection.initiated) {
OneTimeLinkSection(connReq, share, learnMore)
} else {
OneTimeLinkLearnMoreButton(learnMore)
ShowQrButton(contactConnection.incognito, showQr)
}
DeleteButton(deleteConnection)
}
SectionDividerSpaced(maxBottomPadding = false)
DeleteButton(deleteConnection)
SectionBottomSpacer()
}
}
@Composable
fun ShowQrButton(incognito: Boolean, onClick: () -> Unit) {
SettingsActionItem(
painterResource(R.drawable.ic_qr_code),
stringResource(R.string.show_QR_code),
click = onClick,
textColor = if (incognito) Indigo else MaterialTheme.colors.primary,
iconColor = if (incognito) Indigo else MaterialTheme.colors.primary,
)
}
@Composable
fun DeleteButton(onClick: () -> Unit) {
SettingsActionItem(
@@ -150,12 +147,10 @@ private fun PreviewContactConnectionInfoView() {
ContactConnectionInfoLayout(
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
PendingContactConnection.getSampleData(),
connIncognito = false,
focusAlias = false,
deleteConnection = {},
onLocalAliasChanged = {},
share = {},
learnMore = {}
showQr = {},
)
}
}
@@ -45,13 +45,14 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
when {
it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() -> stringResource(R.string.create_one_time_link)
it == CreateLinkTab.ONE_TIME -> stringResource(R.string.one_time_link)
it == CreateLinkTab.LONG_TERM -> stringResource(R.string.your_simplex_contact_address)
it == CreateLinkTab.LONG_TERM -> stringResource(R.string.your_contact_address)
else -> ""
}
}
Column(
Modifier
.fillMaxHeight(),
.fillMaxHeight()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.SpaceBetween
) {
Column(Modifier.weight(1f)) {
@@ -60,7 +61,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
AddContactView(connReqInvitation.value ?: "", m.incognito.value)
}
CreateLinkTab.LONG_TERM -> {
UserAddressView(m, viaCreateLinkView = true, close = {})
UserAddressView(m)
}
}
}
@@ -173,7 +173,7 @@ fun ActionButton(
disabled: Boolean = false,
click: () -> Unit = {}
) {
Surface(shape = RoundedCornerShape(18.dp), color = Color.Transparent) {
Surface(shape = RoundedCornerShape(18.dp)) {
Column(
Modifier
.clickable(onClick = click)
@@ -85,7 +85,7 @@ fun PasteToConnectLayout(
)
Box(Modifier.padding(top = DEFAULT_PADDING, bottom = 6.dp)) {
TextEditor(connectionLink, Modifier.height(180.dp), contentPadding = PaddingValues())
TextEditor(Modifier.height(180.dp), text = connectionLink)
}
Row(
@@ -1,169 +0,0 @@
package chat.simplex.app.views.onboarding
import SectionBottomSpacer
import android.util.Log
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.UserContactLinkRec
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun CreateSimpleXAddress(m: ChatModel) {
val context = LocalContext.current
var progressIndicator by remember { mutableStateOf(false) }
val userAddress = remember { m.userAddress }
CreateSimpleXAddressLayout(
userAddress.value,
share = { address: String -> shareText(context, address) },
sendEmail = { address ->
sendEmail(
context,
generalGetString(R.string.email_invite_subject),
generalGetString(R.string.email_invite_body).format(address.connReqContact)
)
},
createAddress = {
withApi {
progressIndicator = true
val connReqContact = m.controller.apiCreateUserAddress()
if (connReqContact != null) {
m.userAddress.value = UserContactLinkRec(connReqContact)
try {
val u = m.controller.apiSetProfileAddress(true)
if (u != null) {
m.updateUser(u)
}
} catch (e: Exception) {
Log.e(TAG, "CreateSimpleXAddress apiSetProfileAddress: ${e.stackTraceToString()}")
}
progressIndicator = false
}
}
},
nextStep = {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
m.onboardingStage.value = OnboardingStage.Step4_SetNotificationsMode
},
)
if (progressIndicator) {
ProgressIndicator()
}
}
@Composable
private fun CreateSimpleXAddressLayout(
userAddress: UserContactLinkRec?,
share: (String) -> Unit,
sendEmail: (UserContactLinkRec) -> Unit,
createAddress: () -> Unit,
nextStep: () -> Unit,
) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarTitle(stringResource(R.string.simplex_address))
Spacer(Modifier.weight(1f))
if (userAddress != null) {
QRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f))
ShareAddressButton { share(userAddress.connReqContact) }
Spacer(Modifier.weight(1f))
ShareViaEmailButton { sendEmail(userAddress) }
Spacer(Modifier.weight(1f))
ContinueButton(nextStep)
} else {
CreateAddressButton(createAddress)
TextBelowButton(stringResource(R.string.your_contacts_will_see_it))
Spacer(Modifier.weight(1f))
SkipButton(nextStep)
}
SectionBottomSpacer()
}
}
@Composable
private fun CreateAddressButton(onClick: () -> Unit) {
TextButton(onClick) {
Text(stringResource(R.string.create_simplex_address), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary)
}
}
@Composable
fun ShareAddressButton(onClick: () -> Unit) {
SimpleButtonFrame(onClick) {
Icon(
painterResource(R.drawable.ic_share_filled), generalGetString(R.string.share_verb), tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(end = 8.dp).size(18.dp)
)
Text(stringResource(R.string.share_verb), style = MaterialTheme.typography.caption, color = MaterialTheme.colors.primary)
}
}
@Composable
fun ShareViaEmailButton(onClick: () -> Unit) {
SimpleButtonFrame(onClick) {
Icon(
painterResource(R.drawable.ic_mail), generalGetString(R.string.share_verb), tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(end = 8.dp).size(30.dp)
)
Text(stringResource(R.string.invite_friends), style = MaterialTheme.typography.h6, color = MaterialTheme.colors.primary)
}
}
@Composable
private fun ContinueButton(onClick: () -> Unit) {
SimpleButtonIconEnded(stringResource(R.string.continue_to_next_step), painterResource(R.drawable.ic_chevron_right), click = onClick)
}
@Composable
private fun SkipButton(onClick: () -> Unit) {
SimpleButtonIconEnded(stringResource(R.string.dont_create_address), painterResource(R.drawable.ic_chevron_right), click = onClick)
TextBelowButton(stringResource(R.string.you_can_create_it_later))
}
@Composable
private fun TextBelowButton(text: String) {
Text(
text,
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING * 3),
style = MaterialTheme.typography.subtitle1,
textAlign = TextAlign.Center,
)
}
@Composable
private fun ProgressIndicator() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
}
@@ -4,22 +4,21 @@ import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
@Composable
@@ -34,7 +33,12 @@ fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = n
ReadableText(R.string.you_control_servers_to_receive_your_contacts_to_send)
ReadableText(R.string.only_client_devices_store_contacts_groups_e2e_encrypted_messages)
if (onboardingStage == null) {
ReadableTextWithLink(R.string.read_more_in_github_with_link, "https://github.com/simplex-chat/simplex-chat#readme")
val uriHandler = LocalUriHandler.current
Text(
annotatedStringResource(R.string.read_more_in_github_with_link),
modifier = Modifier.padding(bottom = 12.dp).clickable { uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat#readme") },
lineHeight = 22.sp
)
} else {
ReadableText(R.string.read_more_in_github)
}
@@ -51,28 +55,8 @@ fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = n
}
@Composable
fun ReadableText(@StringRes stringResId: Int, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current) {
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style)
}
@Composable
fun ReadableTextWithLink(@StringRes stringResId: Int, link: String, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp)) {
val annotated = annotatedStringResource(stringResId)
val primary = MaterialTheme.colors.primary
// This replaces links in text highlighted with specific color, e.g. SimplexBlue
val newStyles = remember(stringResId) {
val newStyles = ArrayList<AnnotatedString.Range<SpanStyle>>()
annotated.spanStyles.forEach {
if (it.item.color == SimplexBlue) {
newStyles.add(it.copy(item = it.item.copy(primary)))
} else {
newStyles.add(it)
}
}
newStyles
}
val uriHandler = LocalUriHandler.current
Text(AnnotatedString(annotated.text, newStyles), modifier = Modifier.padding(padding).clickable { uriHandler.openUriCatching(link) }, textAlign = textAlign, lineHeight = 22.sp)
fun ReadableText(@StringRes stringResId: Int, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp)) {
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp)
}
@Composable
@@ -80,17 +64,6 @@ fun ReadableText(text: String, textAlign: TextAlign = TextAlign.Start, padding:
Text(text, modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp)
}
@Composable
fun ReadableMarkdownText(text: String, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp)) {
MarkdownText(
text,
formattedText = remember(text) { parseToMarkdown(text) },
modifier = Modifier.padding(padding),
style = TextStyle(textAlign = textAlign, lineHeight = 22.sp, fontSize = 16.sp),
linkMode = SimplexApp.context.chatModel.controller.appPrefs.simplexLinkMode.get(),
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -1,7 +1,9 @@
package chat.simplex.app.views.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -14,8 +16,7 @@ import kotlinx.coroutines.launch
enum class OnboardingStage {
Step1_SimpleXInfo,
Step2_CreateProfile,
Step3_CreateSimpleXAddress,
Step4_SetNotificationsMode,
Step3_SetNotificationsMode,
OnboardingComplete
}
@@ -30,7 +31,8 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 20.dp)
.background(color = MaterialTheme.colors.background)
.padding(20.dp)
) {
CreateProfilePanel(chatModel, close)
LaunchedEffect(Unit) {
@@ -40,16 +40,13 @@ fun SetNotificationsMode(m: ChatModel) {
NotificationButton(currentMode, NotificationsMode.SERVICE, R.string.onboarding_notifications_mode_service, R.string.onboarding_notifications_mode_service_desc)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), contentAlignment = Alignment.Center) {
OnboardingActionButton(R.string.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage, false) {
changeNotificationsMode(currentMode.value, m)
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
OnboardingActionButton(R.string.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage, false) {
changeNotificationsMode(currentMode.value, m)
}
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}
LaunchedEffect(Unit) {
m.controller.ntfManager.createNtfChannelsMaybeShowAlert()
}
}
@Composable
@@ -59,10 +56,10 @@ private fun NotificationButton(currentMode: MutableState<NotificationsMode>, mod
border = BorderStroke(1.dp, color = if (currentMode.value == mode) MaterialTheme.colors.primary else MaterialTheme.colors.secondary.copy(alpha = 0.5f)),
shape = RoundedCornerShape(35.dp),
) {
Column(Modifier.padding(horizontal = 10.dp).padding(top = 4.dp, bottom = 8.dp)) {
Column(Modifier.padding(horizontal = 14.dp).padding(top = 4.dp, bottom = 8.dp)) {
Text(
stringResource(title),
style = MaterialTheme.typography.h3,
style = MaterialTheme.typography.h2,
fontWeight = FontWeight.Medium,
color = if (currentMode.value == mode) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier.padding(bottom = 8.dp).align(Alignment.CenterHorizontally),
@@ -70,7 +67,6 @@ private fun NotificationButton(currentMode: MutableState<NotificationsMode>, mod
)
Text(annotatedStringResource(description),
Modifier.align(Alignment.CenterHorizontally),
fontSize = 15.sp,
color = MaterialTheme.colors.onBackground,
lineHeight = 24.sp,
textAlign = TextAlign.Center
@@ -18,7 +18,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.*
@@ -43,13 +42,13 @@ fun SimpleXInfoLayout(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(start = DEFAULT_PADDING , end = DEFAULT_PADDING, top = DEFAULT_PADDING),
.padding(start = DEFAULT_PADDING * 1.5f, end = DEFAULT_PADDING * 1.5f, top = DEFAULT_PADDING * 4,/* bottom = DEFAULT_PADDING * 4*/),
) {
Box(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 10.dp), contentAlignment = Alignment.Center) {
SimpleXLogo()
}
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 48.dp).padding(horizontal = 36.dp), textAlign = TextAlign.Center)
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 60.dp).padding(horizontal = 48.dp), textAlign = TextAlign.Center)
InfoRow(painterResource(R.drawable.privacy), R.string.privacy_redefined, R.string.first_platform_without_user_ids, 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)
@@ -67,11 +66,12 @@ fun SimpleXInfoLayout(
Box(
Modifier
.fillMaxWidth()
.padding(bottom = DEFAULT_PADDING.times(1.5f), top = DEFAULT_PADDING), contentAlignment = Alignment.Center
.padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING), contentAlignment = Alignment.Center
) {
SimpleButtonDecorated(text = stringResource(R.string.how_it_works), icon = painterResource(R.drawable.ic_info),
click = showModal { HowItWorks(user, onboardingStage) })
}
Spacer(Modifier.weight(1f))
}
}
@@ -120,7 +120,7 @@ fun OnboardingActionButton(
Modifier
.border(border = BorderStroke(1.dp, MaterialTheme.colors.primary), shape = RoundedCornerShape(50))
.padding(
horizontal = DEFAULT_PADDING * 2,
horizontal = DEFAULT_PADDING * 3,
vertical = 4.dp
)
} else {
@@ -130,14 +130,13 @@ fun OnboardingActionButton(
SimpleButtonFrame(click = {
onclick?.invoke()
onboardingStage.value = onboarding
if (onboarding != null) {
SimplexApp.context.chatModel.controller.appPrefs.onboardingStage.set(onboarding)
}
}, modifier) {
Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary, fontSize = 20.sp)
Icon(
painterResource(R.drawable.ic_arrow_forward_ios), "next stage", tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(start = DEFAULT_PADDING.div(4)).size(20.dp)
modifier = Modifier
.padding(start = DEFAULT_PADDING, top = 5.dp)
.size(15.dp)
)
}
}
@@ -20,7 +20,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.BuildConfig
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
@@ -43,7 +42,9 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
)
}
Column(modifier = Modifier.padding(bottom = 12.dp)) {
Column(
modifier = Modifier.padding(bottom = 12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -52,16 +53,16 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
Icon(icon, stringResource(titleId), tint = MaterialTheme.colors.secondary)
Text(
generalGetString(titleId),
maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h4,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Medium
)
if (link != null) {
linkButton(link)
}
}
Text(generalGetString(descrId), fontSize = 15.sp)
Text(generalGetString(descrId))
}
}
@@ -113,23 +114,14 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING.times(0.75f))
verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING)
) {
AppBarTitle(String.format(generalGetString(R.string.new_in_version), v.version), bottomPadding = DEFAULT_PADDING)
AppBarTitle(String.format(generalGetString(R.string.new_in_version), v.version))
v.features.forEach { feature ->
featureDescription(painterResource(feature.icon), feature.titleId, feature.descrId, feature.link)
}
val uriHandler = LocalUriHandler.current
if (v.post != null) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = DEFAULT_PADDING.div(4))) {
Text(stringResource(R.string.whats_new_read_more), color = MaterialTheme.colors.primary,
modifier = Modifier.clickable { uriHandler.openUriCatching(v.post) })
Icon(painterResource(R.drawable.ic_open_in_new), stringResource(R.string.whats_new_read_more), tint = MaterialTheme.colors.primary)
}
}
if (!viaSettings) {
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(
@@ -161,14 +153,12 @@ private data class FeatureDescription(
private data class VersionDescription(
val version: String,
val features: List<FeatureDescription>,
val post: String? = null,
val features: List<FeatureDescription>
)
private val versionDescriptions: List<VersionDescription> = listOf(
VersionDescription(
version = "v4.2",
post = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html",
features = listOf(
FeatureDescription(
icon = R.drawable.ic_verified_user,
@@ -190,7 +180,6 @@ private val versionDescriptions: List<VersionDescription> = listOf(
),
VersionDescription(
version = "v4.3",
post = "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html",
features = listOf(
FeatureDescription(
icon = R.drawable.ic_mic,
@@ -216,7 +205,6 @@ private val versionDescriptions: List<VersionDescription> = listOf(
),
VersionDescription(
version = "v4.4",
post = "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html",
features = listOf(
FeatureDescription(
icon = R.drawable.ic_timer,
@@ -242,7 +230,6 @@ private val versionDescriptions: List<VersionDescription> = listOf(
),
VersionDescription(
version = "v4.5",
post = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html",
features = listOf(
FeatureDescription(
icon = R.drawable.ic_manage_accounts,
@@ -274,13 +261,12 @@ private val versionDescriptions: List<VersionDescription> = listOf(
icon = R.drawable.ic_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#help-translating-simplex-chat"
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#translate-the-apps"
)
)
),
VersionDescription(
version = "v4.6",
post = "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html",
features = listOf(
FeatureDescription(
icon = R.drawable.ic_lock,
@@ -311,13 +297,12 @@ private val versionDescriptions: List<VersionDescription> = listOf(
icon = R.drawable.ic_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#help-translating-simplex-chat"
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#translate-the-apps"
)
)
),
VersionDescription(
version = "v5.0",
post = "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html",
features = listOf(
FeatureDescription(
icon = R.drawable.ic_upload_file,
@@ -333,45 +318,7 @@ private val versionDescriptions: List<VersionDescription> = listOf(
icon = R.drawable.ic_translate,
titleId = R.string.v5_0_polish_interface,
descrId = R.string.v5_0_polish_interface_descr,
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat"
)
)
),
// Also in v5.1
// preference to disable calls per contact
// configurable SOCKS proxy port
// access welcome message via a group profile
// improve calls on lock screen
// better formatting of times and dates
VersionDescription(
version = "v5.1",
post = "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html",
features = listOf(
FeatureDescription(
icon = R.drawable.ic_add_reaction,
titleId = R.string.v5_1_message_reactions,
descrId = R.string.v5_1_message_reactions_descr
),
FeatureDescription(
icon = R.drawable.ic_chat,
titleId = R.string.v5_1_better_messages,
descrId = R.string.v5_1_better_messages_descr
),
FeatureDescription(
icon = R.drawable.ic_light_mode,
titleId = R.string.v5_1_custom_themes,
descrId = R.string.v5_1_custom_themes_descr
),
FeatureDescription(
icon = R.drawable.ic_lock,
titleId = R.string.v5_1_self_destruct_passcode,
descrId = R.string.v5_1_self_destruct_passcode_descr
),
FeatureDescription(
icon = R.drawable.ic_translate,
titleId = R.string.v5_1_japanese_portuguese_interface,
descrId = R.string.whats_new_thanks_to_users_contribute_weblate,
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat"
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#translate-the-apps"
)
)
)
@@ -0,0 +1,160 @@
package chat.simplex.app.views.usersettings
import SectionBottomSpacer
import SectionCustomFooter
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.ui.res.painterResource
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun AcceptRequestsView(m: ChatModel, contactLink: UserContactLinkRec) {
var contactLink by remember { mutableStateOf(contactLink) }
AcceptRequestsLayout(
contactLink,
saveState = { new: MutableState<AutoAcceptState>, old: MutableState<AutoAcceptState> ->
withApi {
val link = m.controller.userAddressAutoAccept(new.value.autoAccept)
if (link != null) {
contactLink = link
m.userAddress.value = link
old.value = new.value
}
}
}
)
}
@Composable
private fun AcceptRequestsLayout(
contactLink: UserContactLinkRec,
saveState: (new: MutableState<AutoAcceptState>, old: MutableState<AutoAcceptState>) -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
) {
AppBarTitle(stringResource(R.string.contact_requests))
val autoAcceptState = remember { mutableStateOf(AutoAcceptState(contactLink)) }
val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) }
SectionView(stringResource(R.string.accept_requests).uppercase()) {
PreferenceToggleWithIcon(stringResource(R.string.accept_automatically), painterResource(R.drawable.ic_check), checked = autoAcceptState.value.enable) {
autoAcceptState.value = if (!it)
AutoAcceptState()
else
AutoAcceptState(it, autoAcceptState.value.incognito, autoAcceptState.value.welcomeText)
}
if (autoAcceptState.value.enable) {
PreferenceToggleWithIcon(
stringResource(R.string.incognito),
if (autoAcceptState.value.incognito) painterResource(R.drawable.ic_theater_comedy_filled) else painterResource(R.drawable.ic_theater_comedy),
if (autoAcceptState.value.incognito) Indigo else MaterialTheme.colors.secondary,
autoAcceptState.value.incognito,
) {
autoAcceptState.value = AutoAcceptState(autoAcceptState.value.enable, it, autoAcceptState.value.welcomeText)
}
}
}
val welcomeText = remember { mutableStateOf(autoAcceptState.value.welcomeText) }
SectionCustomFooter(PaddingValues(horizontal = DEFAULT_PADDING)) {
ButtonsFooter(
cancel = {
autoAcceptState.value = autoAcceptStateSaved.value
welcomeText.value = autoAcceptStateSaved.value.welcomeText
},
save = { saveState(autoAcceptState, autoAcceptStateSaved) },
disabled = autoAcceptState.value == autoAcceptStateSaved.value
)
}
Spacer(Modifier.height(DEFAULT_PADDING))
if (autoAcceptState.value.enable) {
Text(
stringResource(R.string.section_title_welcome_message), color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), fontSize = 12.sp
)
TextEditor(Modifier.padding(horizontal = DEFAULT_PADDING).height(160.dp), text = welcomeText)
LaunchedEffect(welcomeText.value) {
if (welcomeText.value != autoAcceptState.value.welcomeText) {
autoAcceptState.value = AutoAcceptState(autoAcceptState.value.enable, autoAcceptState.value.incognito, welcomeText.value)
}
}
}
SectionBottomSpacer()
}
}
@Composable
private fun ButtonsFooter(cancel: () -> Unit, save: () -> Unit, disabled: Boolean) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
FooterButton(painterResource(R.drawable.ic_replay), stringResource(R.string.cancel_verb), cancel, disabled)
FooterButton(painterResource(R.drawable.ic_check), stringResource(R.string.save_verb), save, disabled)
}
}
private class AutoAcceptState {
var enable: Boolean = false
private set
var incognito: Boolean = false
private set
var welcomeText: String = ""
private set
constructor(enable: Boolean = false, incognito: Boolean = false, welcomeText: String = "") {
this.enable = enable
this.incognito = incognito
this.welcomeText = welcomeText
}
constructor(contactLink: UserContactLinkRec) {
contactLink.autoAccept?.let { aa ->
enable = true
incognito = aa.acceptIncognito
aa.autoReply?.let { msg ->
welcomeText = msg.text
} ?: run {
welcomeText = ""
}
}
}
val autoAccept: AutoAccept?
get() {
if (enable) {
var autoReply: MsgContent? = null
val s = welcomeText.trim()
if (s != "") {
autoReply = MsgContent.MCText(s)
}
return AutoAccept(incognito, autoReply)
}
return null
}
override fun equals(other: Any?): Boolean {
if (other !is AutoAcceptState) return false
return this.enable == other.enable && this.incognito == other.incognito && this.welcomeText == other.welcomeText
}
override fun hashCode(): Int {
var result = enable.hashCode()
result = 31 * result + incognito.hashCode()
result = 31 * result + welcomeText.hashCode()
return result
}
}
@@ -240,8 +240,8 @@ fun CustomizeThemeView(editColor: (ThemeColor, Color) -> Unit) {
val theme = remember { mutableStateOf(null as String?) }
val exportThemeLauncher = rememberSaveThemeLauncher(context, theme)
SectionItemView({
val overrides = ThemeManager.currentThemeOverridesForExport(isInDarkTheme)
theme.value = yaml.encodeToString<ThemeOverrides>(overrides)
val themeData = ThemeManager.currentThemeData(isInDarkTheme)
theme.value = yaml.encodeToString<ThemeData>(themeData)
exportThemeLauncher.launch("simplex.theme")
}) {
Text(generalGetString(R.string.export_theme), color = colors.primary)
@@ -251,7 +251,7 @@ fun CustomizeThemeView(editColor: (ThemeColor, Color) -> Unit) {
if (uri != null) {
val theme = getThemeFromUri(uri)
if (theme != null) {
ThemeManager.saveAndApplyThemeOverrides(theme, isInDarkTheme)
ThemeManager.saveAndApplyThemeData(currentTheme.name, theme, isInDarkTheme)
}
}
}
@@ -320,10 +320,8 @@ private fun LangSelector(state: State<String>, onSelected: (String) -> Unit) {
"es" to "Español",
"fr" to "Français",
"it" to "Italiano",
"ja" to "日本語",
"nl" to "Nederlands",
"pl" to "Polski",
"pt-BR" to "Português (Brasil)",
"ru" to "Русский",
"zh-CN" to "简体中文"
)
@@ -126,30 +126,6 @@ fun SharedPreferenceToggleWithIcon(
}
}
@Composable
fun SharedPreferenceToggleWithIcon(
text: String,
icon: Painter,
onClickInfo: () -> Unit,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(text, Modifier.padding(end = 4.dp))
Icon(
icon,
null,
Modifier.clickable(onClick = onClickInfo),
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = checked,
onCheckedChange = onCheckedChange,
)
}
}
@Composable
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: SharedPreference<T>, value: T) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -48,13 +48,12 @@ fun NetworkAndServersView(
chatModel.userSMPServersUnsaved.value = null
}
val proxyPort = remember { derivedStateOf { chatModel.controller.appPrefs.networkProxyHostPort.state.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } }
NetworkAndServersLayout(
developerTools = developerTools,
networkUseSocksProxy = networkUseSocksProxy,
onionHosts = onionHosts,
sessionMode = sessionMode,
proxyPort = proxyPort,
proxyPort = remember { derivedStateOf { chatModel.controller.appPrefs.networkProxyHostPort.state.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } },
showModal = showModal,
showSettingsModal = showSettingsModal,
showCustomModal = showCustomModal,
@@ -62,15 +61,14 @@ fun NetworkAndServersView(
if (enable) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.network_enable_socks),
text = generalGetString(R.string.network_enable_socks_info).format(proxyPort.value),
text = generalGetString(R.string.network_enable_socks_info),
confirmText = generalGetString(R.string.confirm_verb),
onConfirm = {
withApi {
val conf = NetCfg.proxyDefaults.withHostPort(chatModel.controller.appPrefs.networkProxyHostPort.get())
chatModel.controller.apiSetNetworkConfig(conf)
chatModel.controller.setNetCfg(conf)
chatModel.controller.apiSetNetworkConfig(NetCfg.proxyDefaults)
chatModel.controller.setNetCfg(NetCfg.proxyDefaults)
networkUseSocksProxy.value = true
onionHosts.value = conf.onionHosts
onionHosts.value = NetCfg.proxyDefaults.onionHosts
}
}
)
@@ -81,11 +79,10 @@ fun NetworkAndServersView(
confirmText = generalGetString(R.string.confirm_verb),
onConfirm = {
withApi {
val conf = NetCfg.defaults
chatModel.controller.apiSetNetworkConfig(conf)
chatModel.controller.setNetCfg(conf)
chatModel.controller.apiSetNetworkConfig(NetCfg.defaults)
chatModel.controller.setNetCfg(NetCfg.defaults)
networkUseSocksProxy.value = false
onionHosts.value = conf.onionHosts
onionHosts.value = NetCfg.defaults.onionHosts
}
}
)
@@ -209,31 +206,35 @@ fun UseSocksProxySwitch(
) {
Icon(
painterResource(R.drawable.ic_settings_ethernet),
stringResource(R.string.network_socks_toggle_use_socks_proxy),
stringResource(R.string.network_socks_toggle),
tint = MaterialTheme.colors.secondary
)
TextIconSpaced(false)
val text = buildAnnotatedString {
append(generalGetString(R.string.network_socks_toggle_use_socks_proxy) + " (")
val style = SpanStyle(color = MaterialTheme.colors.primary)
withAnnotation(tag = "PORT", annotation = generalGetString(R.string.network_proxy_port).format(proxyPort.value)) {
withStyle(style) { append(generalGetString(R.string.network_proxy_port).format(proxyPort.value)) }
}
append(")")
}
ClickableText(
text,
style = TextStyle(color = MaterialTheme.colors.onBackground, fontSize = 16.sp, fontFamily = FontFamily(Font(R.font.inter_regular))),
onClick = { offset ->
text.getStringAnnotations(tag = "PORT", start = offset, end = offset)
.firstOrNull()?.let { _ ->
showSettingsModal { SockProxySettings(it) }()
if (networkUseSocksProxy.value) {
val text = buildAnnotatedString {
append(generalGetString(R.string.network_socks_toggle_use_socks_proxy) + " (")
val style = SpanStyle(color = MaterialTheme.colors.primary)
withAnnotation(tag = "PORT", annotation = generalGetString(R.string.network_proxy_port).format(proxyPort.value)) {
withStyle(style) { append(generalGetString(R.string.network_proxy_port).format(proxyPort.value)) }
}
},
shouldConsumeEvent = { offset ->
text.getStringAnnotations(tag = "PORT", start = offset, end = offset).any()
}
)
append(")")
}
ClickableText(
text,
style = TextStyle(color = MaterialTheme.colors.onBackground, fontSize = 16.sp, fontFamily = FontFamily(Font(R.font.inter_regular))),
onClick = { offset ->
text.getStringAnnotations(tag = "PORT", start = offset, end = offset)
.firstOrNull()?.let { _ ->
showSettingsModal { SockProxySettings(it) }()
}
},
shouldConsumeEvent = { offset ->
text.getStringAnnotations(tag = "PORT", start = offset, end = offset).any()
}
)
} else {
Text(stringResource(R.string.network_socks_toggle))
}
}
DefaultSwitch(
checked = networkUseSocksProxy.value,
@@ -261,15 +262,13 @@ fun SockProxySettings(m: ChatModel) {
val save = {
withBGApi {
m.controller.appPrefs.networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text)
if (m.controller.appPrefs.networkUseSocksProxy.get()) {
m.controller.apiSetNetworkConfig(m.controller.getNetCfg())
}
m.controller.apiSetNetworkConfig(m.controller.getNetCfg())
}
}
SectionView {
SectionItemView {
ResetToDefaultsButton({
val reset = {
showUpdateNetworkSettingsDialog {
m.controller.appPrefs.networkProxyHostPort.set(defaultHostPort)
val newHost = defaultHostPort.split(":").first()
val newPort = defaultHostPort.split(":").last()
@@ -277,13 +276,6 @@ fun SockProxySettings(m: ChatModel) {
portUnsaved.value = portUnsaved.value.copy(newPort, TextRange(newPort.length))
save()
}
if (m.controller.appPrefs.networkUseSocksProxy.get()) {
showUpdateNetworkSettingsDialog {
reset()
}
} else {
reset()
}
}, disabled = hostPort == defaultHostPort)
}
SectionItemView {
@@ -315,7 +307,7 @@ fun SockProxySettings(m: ChatModel) {
hostUnsaved.value = hostUnsaved.value.copy(prevHost, TextRange(prevHost.length))
portUnsaved.value = portUnsaved.value.copy(prevPort, TextRange(prevPort.length))
},
save = { if (m.controller.appPrefs.networkUseSocksProxy.get()) showUpdateNetworkSettingsDialog { save() } else save() },
save = { showUpdateNetworkSettingsDialog { save() } },
revertDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text),
saveDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text) ||
remember { derivedStateOf { !validHost(hostUnsaved.value.text) } }.value ||
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
@@ -71,11 +72,6 @@ private fun PreferencesLayout(
applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.allow) }
FeatureSection(ChatFeature.Reactions, allowReactions) {
applyPrefs(preferences.copy(reactions = SimpleChatPreference(allow = it)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
FeatureSection(ChatFeature.Voice, allowVoice) {
applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it)))
@@ -103,10 +99,11 @@ private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllo
FeatureAllowed.values().map { it to it.text },
allowFeature,
icon = feature.icon,
enabled = remember { mutableStateOf(feature != ChatFeature.Calls) },
onSelected = onSelected,
)
}
SectionTextFooter(feature.allowDescription(allowFeature.value))
SectionTextFooter(feature.allowDescription(allowFeature.value) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else ""))
}
@Composable
@@ -6,8 +6,9 @@ import SectionItemView
import SectionTextFooter
import SectionView
import android.view.WindowManager
import androidx.compose.foundation.*
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.ui.Alignment
@@ -16,18 +17,12 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.ProfileNameField
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword
import chat.simplex.app.views.isValidDisplayName
import chat.simplex.app.views.localauth.SetAppPasscodeView
import chat.simplex.app.views.onboarding.ReadableText
enum class LAMode {
SYSTEM,
@@ -116,9 +111,6 @@ fun SimplexLockView(
val laLockDelay = remember { chatModel.controller.appPrefs.laLockDelay }
val showChangePasscode = remember { derivedStateOf { performLA.value && currentLAMode.state.value == LAMode.PASSCODE } }
val activity = LocalContext.current as FragmentActivity
val selfDestructPref = remember { chatModel.controller.appPrefs.selfDestruct }
val selfDestructDisplayName = remember { mutableStateOf(chatModel.controller.appPrefs.selfDestructDisplayName.get() ?: "") }
val selfDestructDisplayNamePref = remember { chatModel.controller.appPrefs.selfDestructDisplayName }
fun resetLAEnabled(onOff: Boolean) {
chatModel.controller.appPrefs.performLA.set(onOff)
@@ -131,11 +123,6 @@ fun SimplexLockView(
laUnavailableInstructionAlert()
}
fun resetSelfDestruct() {
selfDestructPref.set(false)
ksSelfDestructPassword.remove()
}
fun toggleLAMode(toLAMode: LAMode) {
authenticate(
if (toLAMode == LAMode.SYSTEM) {
@@ -143,7 +130,7 @@ fun SimplexLockView(
} else {
generalGetString(R.string.chat_lock)
},
generalGetString(R.string.change_lock_mode), activity = activity
generalGetString(R.string.change_lock_mode), activity
) { laResult ->
when (laResult) {
is LAResult.Error -> {
@@ -153,15 +140,16 @@ fun SimplexLockView(
LAResult.Success -> {
when (toLAMode) {
LAMode.SYSTEM -> {
authenticate(generalGetString(R.string.auth_enable_simplex_lock), promptSubtitle = "", activity = activity, usingLAMode = toLAMode) { laResult ->
authenticate(generalGetString(R.string.auth_enable_simplex_lock), promptSubtitle = "", activity, toLAMode) { laResult ->
when (laResult) {
LAResult.Success -> {
currentLAMode.set(toLAMode)
ksAppPassword.remove()
resetSelfDestruct()
laTurnedOnAlert()
}
is LAResult.Unavailable, is LAResult.Error -> laFailedAlert()
is LAResult.Unavailable, is LAResult.Error -> {
laFailedAlert()
}
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
}
}
@@ -176,7 +164,7 @@ fun SimplexLockView(
passcodeAlert(generalGetString(R.string.passcode_set))
},
cancel = {},
close = close
close
)
}
}
@@ -188,27 +176,8 @@ fun SimplexLockView(
}
}
fun toggleSelfDestruct(selfDestruct: SharedPreference<Boolean>) {
authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.change_self_destruct_mode), activity = activity) { laResult ->
when (laResult) {
is LAResult.Error -> laFailedAlert()
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
LAResult.Success -> {
if (!selfDestruct.get()) {
ModalManager.shared.showCustomModal { close ->
EnableSelfDestruct(selfDestruct, close)
}
} else {
resetSelfDestruct()
}
}
is LAResult.Unavailable -> disableUnavailableLA()
}
}
}
fun changeLAPassword() {
authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.la_change_app_passcode), activity = activity) { laResult ->
authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.la_change_app_passcode), activity) { laResult ->
when (laResult) {
LAResult.Success -> {
ModalManager.shared.showCustomModal { close ->
@@ -218,32 +187,7 @@ fun SimplexLockView(
passcodeAlert(generalGetString(R.string.passcode_changed))
}, cancel = {
passcodeAlert(generalGetString(R.string.passcode_not_changed))
}, close = close
)
}
}
}
is LAResult.Error -> laFailedAlert()
is LAResult.Failed -> {}
is LAResult.Unavailable -> disableUnavailableLA()
}
}
}
fun changeSelfDestructPassword() {
authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.change_self_destruct_passcode), activity = activity) { laResult ->
when (laResult) {
LAResult.Success -> {
ModalManager.shared.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
SetAppPasscodeView(
passcodeKeychain = ksSelfDestructPassword,
submit = {
selfDestructPasscodeAlert(generalGetString(R.string.self_destruct_passcode_changed))
}, cancel = {
passcodeAlert(generalGetString(R.string.passcode_not_changed))
},
close = close
}, close
)
}
}
@@ -279,8 +223,7 @@ fun SimplexLockView(
},
cancel = {
resetLAEnabled(false)
},
close = close
}, close
)
}
}
@@ -303,53 +246,7 @@ fun SimplexLockView(
LockDelaySelector(remember { laLockDelay.state }) { laLockDelay.set(it) }
if (showChangePasscode.value && laMode.value == LAMode.PASSCODE) {
SectionItemView({ changeLAPassword() }) {
Text(
generalGetString(R.string.la_change_app_passcode),
color = MaterialTheme.colors.primary
)
}
}
}
if (performLA.value && laMode.value == LAMode.PASSCODE) {
SectionDividerSpaced()
SectionView(stringResource(R.string.self_destruct_passcode).uppercase()) {
val openInfo = {
ModalManager.shared.showModal {
SelfDestructInfoView()
}
}
SettingsActionItemWithContent(null, null, click = openInfo) {
SharedPreferenceToggleWithIcon(
stringResource(R.string.enable_self_destruct),
painterResource(R.drawable.ic_info),
openInfo,
remember { selfDestructPref.state }.value
) {
toggleSelfDestruct(selfDestructPref)
}
}
if (remember { selfDestructPref.state }.value) {
Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
Text(
stringResource(R.string.self_destruct_new_display_name),
fontSize = 16.sp,
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
)
ProfileNameField(selfDestructDisplayName, "", ::isValidDisplayName)
LaunchedEffect(selfDestructDisplayName.value) {
val new = selfDestructDisplayName.value
if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) {
selfDestructDisplayNamePref.set(new)
}
}
}
SectionItemView({ changeSelfDestructPassword() }) {
Text(
stringResource(R.string.change_self_destruct_passcode),
color = MaterialTheme.colors.primary
)
}
Text(generalGetString(R.string.la_change_app_passcode))
}
}
}
@@ -358,40 +255,6 @@ fun SimplexLockView(
}
}
@Composable
private fun SelfDestructInfoView() {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING),
) {
AppBarTitle(stringResource(R.string.self_destruct), withPadding = false)
ReadableText(stringResource(R.string.if_you_enter_self_destruct_code))
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
TextListItem("1.", stringResource(R.string.all_app_data_will_be_cleared))
TextListItem("2.", stringResource(R.string.app_passcode_replaced_with_self_destruct))
TextListItem("3.", stringResource(R.string.empty_chat_profile_is_created))
}
SectionBottomSpacer()
}
}
@Composable
private fun EnableSelfDestruct(
selfDestruct: SharedPreference<Boolean>,
close: () -> Unit
) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
SetAppPasscodeView(
passcodeKeychain = ksSelfDestructPassword, title = generalGetString(R.string.set_passcode), reason = generalGetString(R.string.enabled_self_destruct_passcode),
submit = {
selfDestruct.set(true)
selfDestructPasscodeAlert(generalGetString(R.string.self_destruct_passcode_enabled))
},
cancel = {},
close = close
)
}
}
@Composable
private fun EnableLock(performLA: MutableState<Boolean>, onCheckedChange: (Boolean) -> Unit) {
SectionItemView {
@@ -437,14 +300,6 @@ private fun LockDelaySelector(state: State<Int>, onSelected: (Int) -> Unit) {
)
}
@Composable
private fun TextListItem(n: String, text: String) {
Box {
Text(n)
Text(text, Modifier.padding(start = 20.dp))
}
}
private fun laDelayText(t: Int): String {
val m = t / 60
val s = t % 60
@@ -464,7 +319,3 @@ private fun passcodeAlert(title: String) {
text = generalGetString(R.string.la_please_remember_to_store_password)
)
}
private fun selfDestructPasscodeAlert(title: String) {
AlertManager.shared.showAlertMsg(title, generalGetString(R.string.if_you_enter_passcode_data_removed))
}
@@ -27,8 +27,6 @@ import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -142,16 +140,14 @@ private fun CustomServer(
) {
val testedPreviously = remember { mutableMapOf<String, Boolean?>() }
TextEditor(
serverAddress,
Modifier.height(144.dp)
)
LaunchedEffect(Unit) {
snapshotFlow { serverAddress.value }
.distinctUntilChanged()
.collect {
testedPreviously[server.server] = server.tested
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
}
Modifier.height(144.dp),
text = serverAddress,
border = false,
fontSize = 16.sp,
background = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background
) {
testedPreviously[server.server] = server.tested
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
}
}
SectionDividerSpaced()
@@ -266,7 +266,7 @@ private fun HowToButton() {
SettingsActionItem(
painterResource(R.drawable.ic_open_in_new),
stringResource(R.string.how_to_use_your_servers),
{ uriHandler.openUriCatching("https://simplex.chat/docs/server.html") },
{ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SERVER.md") },
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary
)
@@ -120,7 +120,7 @@ fun RTCServersLayout(
} else {
Text(stringResource(R.string.enter_one_ICE_server_per_line))
if (editRTCServers) {
TextEditor(userRTCServersStr, Modifier.height(160.dp), contentPadding = PaddingValues())
TextEditor(Modifier.height(160.dp), text = userRTCServersStr)
Row(
Modifier.fillMaxWidth(),
@@ -197,7 +197,7 @@ private fun howToButton() {
val uriHandler = LocalUriHandler.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { uriHandler.openUriCatching("https://simplex.chat/docs/webrtc.html#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(
@@ -6,8 +6,9 @@ import SectionItemView
import SectionItemViewWithIcon
import SectionView
import TextIconSpaced
import android.content.*
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.*
@@ -25,16 +26,16 @@ 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 androidx.work.WorkManager
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.database.DatabaseView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.CreateLinkTab
import chat.simplex.app.views.newchat.CreateLinkView
import chat.simplex.app.views.onboarding.SimpleXInfo
import chat.simplex.app.views.onboarding.WhatsNewView
import com.jakewharton.processphoenix.ProcessPhoenix
@Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean, FragmentActivity) -> Unit) {
@@ -62,7 +63,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean, FragmentActivity)
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = true) { search.value = it }
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), alwaysVisible = true) { search.value = it }
},
content = { modalView(chatModel, search) })
}
@@ -150,7 +151,7 @@ fun SettingsLayout(
val profileHidden = rememberSaveable { mutableStateOf(false) }
SettingsActionItem(painterResource(R.drawable.ic_manage_accounts), stringResource(R.string.your_chat_profiles), { withAuth(generalGetString(R.string.auth_open_chat_profiles), generalGetString(R.string.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true)
SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() }
SettingsActionItem(painterResource(R.drawable.ic_qr_code), stringResource(R.string.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(R.drawable.ic_qr_code), stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped, extraPadding = true)
ChatPreferencesItem(showCustomModal, stopped = stopped)
}
SectionDividerSpaced()
@@ -181,9 +182,7 @@ fun SettingsLayout(
}
SectionDividerSpaced()
SectionView(stringResource(R.string.settings_section_title_app)) {
SettingsActionItem(painterResource(R.drawable.ic_restart_alt), stringResource(R.string.settings_restart_app), ::restartApp, extraPadding = true)
SettingsActionItem(painterResource(R.drawable.ic_power_settings_new), stringResource(R.string.settings_shutdown), { shutdownAppAlert(::shutdownApp) }, extraPadding = true)
SectionView(stringResource(R.string.settings_section_title_develop)) {
SettingsActionItem(painterResource(R.drawable.ic_code), stringResource(R.string.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) }, extraPadding = true)
AppVersionItem(showVersion)
}
@@ -363,15 +362,15 @@ fun ChatLockItem(
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) {
ProfileImage(size = size, image = profileOf.image, color = iconColor)
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondaryVariant, stopped: Boolean = false) {
ProfileImage(size = size, image = profileOf.image, color = color)
Spacer(Modifier.padding(horizontal = 8.dp))
Column(Modifier.height(size), verticalArrangement = Arrangement.Center) {
Text(
profileOf.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = if (stopped) MaterialTheme.colors.secondary else textColor,
color = if (stopped) MaterialTheme.colors.secondary else Color.Unspecified,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -379,7 +378,7 @@ fun ChatLockItem(
Text(
profileOf.fullName,
Modifier.padding(vertical = 5.dp),
color = if (stopped) MaterialTheme.colors.secondary else textColor,
color = if (stopped) MaterialTheme.colors.secondary else Color.Unspecified,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -485,33 +484,13 @@ private fun runAuth(title: String, desc: String, context: Context, onFinish: (su
authenticate(
title,
desc,
activity = context as FragmentActivity,
context as FragmentActivity,
completed = { laResult ->
onFinish(laResult == LAResult.Success || laResult is LAResult.Unavailable)
}
)
}
private fun restartApp() {
ProcessPhoenix.triggerRebirth(SimplexApp.context)
shutdownApp()
}
private fun shutdownApp() {
WorkManager.getInstance(SimplexApp.context).cancelAllWork()
SimplexService.safeStopService(SimplexApp.context)
Runtime.getRuntime().exit(0)
}
private fun shutdownAppAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.shutdown_alert_question),
text = generalGetString(R.string.shutdown_alert_desc),
destructive = true,
onConfirm = onConfirm
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -1,24 +0,0 @@
package chat.simplex.app.views.usersettings
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.ReadableText
import chat.simplex.app.views.onboarding.ReadableTextWithLink
@Composable
fun UserAddressLearnMore() {
Column(
Modifier.verticalScroll(rememberScrollState()),
) {
AppBarTitle(stringResource(R.string.simplex_address))
ReadableText(R.string.you_can_share_your_address)
ReadableText(R.string.you_wont_lose_your_contacts_if_delete_address)
ReadableText(R.string.you_can_accept_or_reject_connection)
ReadableTextWithLink(R.string.read_more_in_user_guide_with_link, "https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address")
}
}
@@ -1,423 +1,118 @@
package chat.simplex.app.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.UserContactLinkRec
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.ShareAddressButton
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun UserAddressView(
chatModel: ChatModel,
viaCreateLinkView: Boolean = false,
shareViaProfile: Boolean = false,
close: () -> Unit
) {
val context = LocalContext.current
val shareViaProfile = remember { mutableStateOf(shareViaProfile) }
var progressIndicator by remember { mutableStateOf(false) }
val onCloseHandler: MutableState<(close: () -> Unit) -> Unit> = remember { mutableStateOf({ _ -> }) }
fun setProfileAddress(on: Boolean) {
progressIndicator = true
withBGApi {
try {
val u = chatModel.controller.apiSetProfileAddress(on)
if (u != null) {
chatModel.updateUser(u)
fun UserAddressView(chatModel: ChatModel) {
val cxt = LocalContext.current
UserAddressLayout(
userAddress = remember { chatModel.userAddress }.value,
createAddress = {
withApi {
val connReqContact = chatModel.controller.apiCreateUserAddress()
if (connReqContact != null) {
chatModel.userAddress.value = UserContactLinkRec(connReqContact)
}
} catch (e: Exception) {
Log.e(TAG, "UserAddressView apiSetProfileAddress: ${e.stackTraceToString()}")
} finally {
progressIndicator = false
}
}
}
val userAddress = remember { chatModel.userAddress }
val showLayout = @Composable {
UserAddressLayout(
userAddress = userAddress.value,
shareViaProfile,
onCloseHandler,
createAddress = {
withApi {
progressIndicator = true
val connReqContact = chatModel.controller.apiCreateUserAddress()
if (connReqContact != null) {
chatModel.userAddress.value = UserContactLinkRec(connReqContact)
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.share_address_with_contacts_question),
text = generalGetString(R.string.add_address_to_your_profile),
confirmText = generalGetString(R.string.share_verb),
onConfirm = {
setProfileAddress(true)
shareViaProfile.value = true
}
)
}
progressIndicator = false
}
},
learnMore = {
ModalManager.shared.showModal {
Column(
Modifier
.fillMaxHeight()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.SpaceBetween
) {
UserAddressLearnMore()
}
}
},
share = { userAddress: String -> shareText(context, userAddress) },
sendEmail = { userAddress ->
sendEmail(
context,
generalGetString(R.string.email_invite_subject),
generalGetString(R.string.email_invite_body).format(userAddress.connReqContact)
)
},
setProfileAddress = ::setProfileAddress,
deleteAddress = {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.delete_address__question),
text = if (shareViaProfile.value) generalGetString(R.string.all_your_contacts_will_remain_connected_update_sent) else generalGetString(R.string.all_your_contacts_will_remain_connected),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
progressIndicator = true
withApi {
val u = chatModel.controller.apiDeleteUserAddress()
if (u != null) {
chatModel.userAddress.value = null
chatModel.updateUser(u)
shareViaProfile.value = false
progressIndicator = false
}
}
},
destructive = true,
)
},
saveAas = { aas: AutoAcceptState, savedAAS: MutableState<AutoAcceptState> ->
withBGApi {
val address = chatModel.controller.userAddressAutoAccept(aas.autoAccept)
if (address != null) {
chatModel.userAddress.value = address
savedAAS.value = aas
}
}
},
)
}
if (viaCreateLinkView) {
showLayout()
} else {
ModalView(close = { onCloseHandler.value(close) }) {
showLayout()
}
}
if (progressIndicator) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (userAddress.value != null) {
Surface(Modifier.size(50.dp), color = MaterialTheme.colors.background.copy(0.9f), shape = RoundedCornerShape(50)){}
},
share = { userAddress: String -> shareText(cxt, userAddress) },
acceptRequests = {
chatModel.userAddress.value?.let { address ->
ModalManager.shared.showModal(settings = true) { AcceptRequestsView(chatModel, address) }
}
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
},
deleteAddress = {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.delete_address__question),
text = generalGetString(R.string.all_your_contacts_will_remain_connected),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
chatModel.controller.apiDeleteUserAddress()
chatModel.userAddress.value = null
}
},
destructive = true,
)
}
}
)
}
@Composable
private fun UserAddressLayout(
fun UserAddressLayout(
userAddress: UserContactLinkRec?,
shareViaProfile: MutableState<Boolean>,
onCloseHandler: MutableState<(close: () -> Unit) -> Unit>,
createAddress: () -> Unit,
learnMore: () -> Unit,
share: (String) -> Unit,
sendEmail: (UserContactLinkRec) -> Unit,
setProfileAddress: (Boolean) -> Unit,
deleteAddress: () -> Unit,
saveAas: (AutoAcceptState, MutableState<AutoAcceptState>) -> Unit,
acceptRequests: () -> Unit,
deleteAddress: () -> Unit
) {
Column(
Modifier.verticalScroll(rememberScrollState()),
) {
AppBarTitle(stringResource(R.string.simplex_address), false)
AppBarTitle(stringResource(R.string.your_contact_address), false)
Text(
stringResource(R.string.you_can_share_your_address_anybody_will_be_able_to_connect),
Modifier.padding(bottom = 12.dp),
lineHeight = 22.sp
)
Column(
Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
if (userAddress == null) {
SectionView {
CreateAddressButton(createAddress)
SectionTextFooter(stringResource(R.string.create_address_and_let_people_connect))
}
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
LearnMoreButton(learnMore)
}
LaunchedEffect(Unit) {
onCloseHandler.value = { close -> close() }
}
SimpleButton(stringResource(R.string.create_address), icon = painterResource(R.drawable.ic_qr_code), click = createAddress)
} else {
val autoAcceptState = remember { mutableStateOf(AutoAcceptState(userAddress)) }
val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) }
SectionView(stringResource(R.string.address_section_title).uppercase()) {
QRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f))
ShareAddressButton { share(userAddress.connReqContact) }
ShareViaEmailButton { sendEmail(userAddress) }
ShareWithContactsButton(shareViaProfile, setProfileAddress)
AutoAcceptToggle(autoAcceptState) { saveAas(autoAcceptState.value, autoAcceptStateSaved) }
LearnMoreButton(learnMore)
}
if (autoAcceptState.value.enable) {
SectionDividerSpaced()
AutoAcceptSection(autoAcceptState, autoAcceptStateSaved, saveAas)
}
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
DeleteAddressButton(deleteAddress)
SectionTextFooter(stringResource(R.string.your_contacts_will_remain_connected))
}
LaunchedEffect(Unit) {
onCloseHandler.value = { close ->
if (autoAcceptState.value == autoAcceptStateSaved.value) close()
else showUnsavedChangesAlert({ saveAas(autoAcceptState.value, autoAcceptStateSaved); close() }, close)
}
QRCode(userAddress.connReqContact, Modifier.aspectRatio(1f))
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = DEFAULT_PADDING)
) {
SimpleButton(
stringResource(R.string.share_link),
icon = painterResource(R.drawable.ic_share),
click = { share(userAddress.connReqContact) })
SimpleButtonIconEnded(
stringResource(R.string.contact_requests),
icon = painterResource(R.drawable.ic_chevron_right),
click = acceptRequests
)
}
SimpleButton(
stringResource(R.string.delete_address),
icon = painterResource(R.drawable.ic_delete),
color = Color.Red,
click = deleteAddress
)
}
}
SectionBottomSpacer()
}
}
@Composable
private fun CreateAddressButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(R.drawable.ic_qr_code),
stringResource(R.string.create_simplex_address),
onClick,
iconColor = MaterialTheme.colors.primary,
textColor = MaterialTheme.colors.primary,
)
}
@Composable
private fun LearnMoreButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(R.drawable.ic_info),
stringResource(R.string.learn_more_about_address),
onClick,
)
}
@Composable
fun ShareViaEmailButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(R.drawable.ic_mail),
stringResource(R.string.invite_friends),
onClick,
iconColor = MaterialTheme.colors.primary,
textColor = MaterialTheme.colors.primary,
)
}
@Composable
fun ShareWithContactsButton(shareViaProfile: MutableState<Boolean>, setProfileAddress: (Boolean) -> Unit) {
PreferenceToggleWithIcon(
stringResource(R.string.share_with_contacts),
painterResource(R.drawable.ic_person),
checked = shareViaProfile.value,
) { on ->
shareViaProfile.value = on
if (on) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.share_address_with_contacts_question),
text = generalGetString(R.string.profile_update_will_be_sent_to_contacts),
confirmText = generalGetString(R.string.share_verb),
onConfirm = {
setProfileAddress(on)
},
onDismiss = {
shareViaProfile.value = !on
},
onDismissRequest = {
shareViaProfile.value = !on
})
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.stop_sharing_address),
text = generalGetString(R.string.profile_update_will_be_sent_to_contacts),
confirmText = generalGetString(R.string.stop_sharing),
onConfirm = {
setProfileAddress(on)
},
onDismiss = {
shareViaProfile.value = !on
},
onDismissRequest = {
shareViaProfile.value = !on
})
}
}
}
@Composable
private fun AutoAcceptToggle(autoAcceptState: MutableState<AutoAcceptState>, saveAas: (AutoAcceptState) -> Unit) {
PreferenceToggleWithIcon(stringResource(R.string.auto_accept_contact), painterResource(R.drawable.ic_check), checked = autoAcceptState.value.enable) {
autoAcceptState.value = if (!it)
AutoAcceptState()
else
AutoAcceptState(it, autoAcceptState.value.incognito, autoAcceptState.value.welcomeText)
saveAas(autoAcceptState.value)
}
}
@Composable
private fun DeleteAddressButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(R.drawable.ic_delete),
stringResource(R.string.delete_address),
onClick,
iconColor = MaterialTheme.colors.error,
textColor = MaterialTheme.colors.error,
)
}
private class AutoAcceptState {
var enable: Boolean = false
private set
var incognito: Boolean = false
private set
var welcomeText: String = ""
private set
constructor(enable: Boolean = false, incognito: Boolean = false, welcomeText: String = "") {
this.enable = enable
this.incognito = incognito
this.welcomeText = welcomeText
}
constructor(contactLink: UserContactLinkRec) {
contactLink.autoAccept?.let { aa ->
enable = true
incognito = aa.acceptIncognito
aa.autoReply?.let { msg ->
welcomeText = msg.text
} ?: run {
welcomeText = ""
}
}
}
val autoAccept: AutoAccept?
get() {
if (enable) {
var autoReply: MsgContent? = null
val s = welcomeText.trim()
if (s != "") {
autoReply = MsgContent.MCText(s)
}
return AutoAccept(incognito, autoReply)
}
return null
}
override fun equals(other: Any?): Boolean {
if (other !is AutoAcceptState) return false
return this.enable == other.enable && this.incognito == other.incognito && this.welcomeText == other.welcomeText
}
override fun hashCode(): Int {
var result = enable.hashCode()
result = 31 * result + incognito.hashCode()
result = 31 * result + welcomeText.hashCode()
return result
}
}
@Composable
private fun AutoAcceptSection(
autoAcceptState: MutableState<AutoAcceptState>,
savedAutoAcceptState: MutableState<AutoAcceptState>,
saveAas: (AutoAcceptState, MutableState<AutoAcceptState>) -> Unit
) {
SectionView(stringResource(R.string.auto_accept_contact).uppercase()) {
AcceptIncognitoToggle(autoAcceptState)
WelcomeMessageEditor(autoAcceptState)
SaveAASButton(autoAcceptState.value == savedAutoAcceptState.value) { saveAas(autoAcceptState.value, savedAutoAcceptState) }
}
}
@Composable
private fun AcceptIncognitoToggle(autoAcceptState: MutableState<AutoAcceptState>) {
PreferenceToggleWithIcon(
stringResource(R.string.accept_contact_incognito_button),
if (autoAcceptState.value.incognito) painterResource(R.drawable.ic_theater_comedy_filled) else painterResource(R.drawable.ic_theater_comedy),
if (autoAcceptState.value.incognito) Indigo else MaterialTheme.colors.secondary,
autoAcceptState.value.incognito,
) {
autoAcceptState.value = AutoAcceptState(autoAcceptState.value.enable, it, autoAcceptState.value.welcomeText)
}
}
@Composable
private fun WelcomeMessageEditor(autoAcceptState: MutableState<AutoAcceptState>) {
val welcomeText = rememberSaveable { mutableStateOf(autoAcceptState.value.welcomeText) }
TextEditor(welcomeText, Modifier.height(100.dp), placeholder = stringResource(R.string.enter_welcome_message_optional))
LaunchedEffect(welcomeText.value) {
if (welcomeText.value != autoAcceptState.value.welcomeText) {
autoAcceptState.value = AutoAcceptState(autoAcceptState.value.enable, autoAcceptState.value.incognito, welcomeText.value)
}
}
}
@Composable
private fun SaveAASButton(disabled: Boolean, onClick: () -> Unit) {
SectionItemView(onClick, disabled = disabled) {
Text(stringResource(R.string.save_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -431,27 +126,12 @@ fun PreviewUserAddressLayoutNoAddress() {
userAddress = null,
createAddress = {},
share = { _ -> },
acceptRequests = {},
deleteAddress = {},
saveAas = { _, _ -> },
setProfileAddress = { _ -> },
learnMore = {},
shareViaProfile = remember { mutableStateOf(false) },
onCloseHandler = remember { mutableStateOf({}) },
sendEmail = {},
)
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(R.string.save_settings_question),
confirmText = generalGetString(R.string.save_auto_accept_settings),
dismissText = generalGetString(R.string.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -465,13 +145,8 @@ fun PreviewUserAddressLayoutAddressCreated() {
userAddress = UserContactLinkRec("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"),
createAddress = {},
share = { _ -> },
acceptRequests = {},
deleteAddress = {},
saveAas = { _, _ -> },
setProfileAddress = { _ -> },
learnMore = {},
shareViaProfile = remember { mutableStateOf(false) },
onCloseHandler = remember { mutableStateOf({}) },
sendEmail = {},
)
}
}
@@ -38,13 +38,14 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
val searchTextOrPassword = rememberSaveable { search }
val users by remember { derivedStateOf { m.users.map { it.user } } }
val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } }
UserProfilesLayout(
UserProfilesView(
users = users,
filteredUsers = filteredUsers,
profileHidden = profileHidden,
searchTextOrPassword = searchTextOrPassword,
showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice,
visibleUsersCount = visibleUsersCount(m),
prefPerformLA = m.controller.appPrefs.performLA.get(),
addUser = {
ModalManager.shared.showModalCloseable { close ->
CreateProfile(m, close)
@@ -136,12 +137,13 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
}
@Composable
private fun UserProfilesLayout(
private fun UserProfilesView(
users: List<User>,
filteredUsers: List<User>,
searchTextOrPassword: MutableState<String>,
profileHidden: MutableState<Boolean>,
visibleUsersCount: Int,
prefPerformLA: Boolean,
showHiddenProfilesNotice: SharedPreference<Boolean>,
addUser: () -> Unit,
activateUser: (User) -> Unit,
@@ -169,7 +171,7 @@ private fun UserProfilesLayout(
SectionView {
for (user in filteredUsers) {
UserView(user, users, visibleUsersCount, activateUser, removeUser, unhideUser, muteUser, unmuteUser, showHiddenProfile)
UserView(user, users, visibleUsersCount, prefPerformLA, activateUser, removeUser, unhideUser, muteUser, unmuteUser, showHiddenProfile)
SectionDivider()
}
if (searchTextOrPassword.value.trim().isEmpty()) {
@@ -203,6 +205,7 @@ private fun UserView(
user: User,
users: List<User>,
visibleUsersCount: Int,
prefPerformLA: Boolean,
activateUser: (User) -> Unit,
removeUser: (User) -> Unit,
unhideUser: (User) -> Unit,
@@ -222,7 +225,7 @@ private fun UserView(
unhideUser(user)
})
} else {
if (visibleUsersCount > 1) {
if (visibleUsersCount > 1 && prefPerformLA) {
ItemAction(stringResource(R.string.user_hide), painterResource(R.drawable.ic_lock), onClick = {
showMenu.value = false
showHiddenProfile(user)
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.dp"
android:height="24.dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M480.33,875Q398.63,875 326.7,843.74Q254.78,812.47 201.14,758.74Q147.5,705 116.25,633.21Q85,561.42 85,479.8Q85,398.09 116.36,325.93Q147.73,253.78 201.13,200.39Q254.54,147 326.79,116Q399.04,85 479.92,85Q526.76,85 570.69,95.51Q614.62,106.03 654,125Q650.5,133.5 649.25,142.08Q648,150.67 648,160Q648,167.9 648.75,174.95Q649.5,182 652,189Q614,166.5 570.93,154.5Q527.86,142.5 479.9,142.5Q339.69,142.5 241.09,240.75Q142.5,339 142.5,479.51Q142.5,620.03 241.24,718.76Q339.97,817.5 480.49,817.5Q621,817.5 719.25,718.91Q817.5,620.31 817.5,480Q817.5,441.47 809.25,404.99Q801,368.5 786,336Q796.96,343.76 810.93,347.88Q824.9,352 840,352Q843.36,352 846.75,352Q850.14,352 853.5,351.5Q864,382 869.5,413.93Q875,445.87 875,479.82Q875,560.91 843.99,633.3Q812.97,705.68 759.49,758.99Q706,812.3 633.98,843.65Q561.95,875 480.33,875ZM624.45,426.5Q647.4,426.5 662.45,411.5Q677.5,396.49 677.5,373.55Q677.5,350.6 662.5,335.55Q647.49,320.5 624.55,320.5Q601.6,320.5 586.55,335.5Q571.5,350.51 571.5,373.45Q571.5,396.4 586.5,411.45Q601.51,426.5 624.45,426.5ZM335.45,426.5Q358.4,426.5 373.45,411.5Q388.5,396.49 388.5,373.55Q388.5,350.6 373.5,335.55Q358.49,320.5 335.55,320.5Q312.6,320.5 297.55,335.5Q282.5,350.51 282.5,373.45Q282.5,396.4 297.5,411.45Q312.51,426.5 335.45,426.5ZM480,696Q544.5,696 599.25,661.5Q654,627 679,566.5L281,566.5Q307,627 361.25,661.5Q415.5,696 480,696ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM811.5,188.5L760,188.5Q747.75,188.5 739.63,180.33Q731.5,172.15 731.5,159.82Q731.5,147.5 739.63,139.25Q747.75,131 760,131L811.5,131L811.5,80Q811.5,67.75 819.67,59.38Q827.85,51 840.17,51Q852.5,51 860.75,59.38Q869,67.75 869,80L869,131L920,131Q932.25,131 940.63,139.43Q949,147.85 949,160.17Q949,172.5 940.63,180.5Q932.25,188.5 920,188.5L869,188.5L869,240Q869,252.25 860.58,260.38Q852.15,268.5 839.83,268.5Q827.5,268.5 819.5,260.38Q811.5,252.25 811.5,240L811.5,188.5Z"/>
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.dp"
android:height="24.dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M811.5,188.5L760,188.5Q748.5,188.5 740,180.33Q731.5,172.15 731.5,159.82Q731.5,147.5 739.63,139.25Q747.75,131 760,131L811.5,131L811.5,80Q811.5,67.75 819.67,59.38Q827.85,51 840.17,51Q852.5,51 860.75,59.38Q869,67.75 869,80L869,131L920,131Q932.25,131 940.63,139.43Q949,147.85 949,160.17Q949,172.5 940.63,180.5Q932.25,188.5 920,188.5L869,188.5L869,240Q869,251.5 860.58,260Q852.15,268.5 839.83,268.5Q827.5,268.5 819.5,260.38Q811.5,252.25 811.5,240L811.5,188.5ZM480.33,875Q398.63,875 326.7,843.74Q254.78,812.47 201.14,758.74Q147.5,705 116.25,633.21Q85,561.42 85,479.8Q85,398.09 116.36,325.93Q147.73,253.78 201.13,200.39Q254.54,147 326.79,116Q399.04,85 479.92,85Q526.76,85 570.69,95.51Q614.62,106.03 654,125Q650.5,133.5 649.25,142.08Q648,150.67 648,159.57Q648,198 671.44,228.46Q694.88,258.93 732.5,267.5Q741.57,305.17 772.03,328.58Q802.48,352 840.28,352Q843.36,352 846.75,352Q850.14,352 853.5,351.5Q864,382 869.5,413.93Q875,445.87 875,479.82Q875,560.91 843.99,633.3Q812.97,705.68 759.49,758.99Q706,812.3 633.98,843.65Q561.95,875 480.33,875ZM624.45,426.5Q647.4,426.5 662.45,411.5Q677.5,396.49 677.5,373.55Q677.5,350.6 662.5,335.55Q647.49,320.5 624.55,320.5Q601.6,320.5 586.55,335.5Q571.5,350.51 571.5,373.45Q571.5,396.4 586.5,411.45Q601.51,426.5 624.45,426.5ZM335.45,426.5Q358.4,426.5 373.45,411.5Q388.5,396.49 388.5,373.55Q388.5,350.6 373.5,335.55Q358.49,320.5 335.55,320.5Q312.6,320.5 297.55,335.5Q282.5,350.51 282.5,373.45Q282.5,396.4 297.5,411.45Q312.51,426.5 335.45,426.5ZM480,696Q544.5,696 599.25,661.5Q654,627 679,566.5L281,566.5Q307,627 361.25,661.5Q415.5,696 480,696Z"/>
</vector>
@@ -5,5 +5,5 @@
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M480,520.5L271,729.5Q262,738.5 250.75,738.5Q239.5,738.5 230.5,729.5Q221.5,720.5 221.5,709.25Q221.5,698 230.5,689L440,479.5L230.5,270Q221.5,261.5 221.5,250.25Q221.5,239 230.5,230Q239.5,221 250.75,221Q262,221 271,230L480,439.5L689,230.5Q698,221.5 709.25,221.5Q720.5,221.5 729.5,230.5Q738.5,239.5 738.5,250.75Q738.5,262 729.5,271L520.5,480L730,689.5Q738.5,698.5 738.5,709.75Q738.5,721 730,729.5Q721,738.5 709.75,738.5Q698.5,738.5 689.5,729.5L480,520.5Z"/>
android:pathData="m480,520.5 l-209,209q-9,9 -20.25,9t-20.25,-9q-9,-9 -9,-20.25t9,-20.25L440,479.5 230.5,270q-9,-8.5 -9,-19.75t9,-20.25q9,-9 20.25,-9t20.25,9l209,209.5 209,-209q9,-9 20.25,-9t20.25,9q9,9 9,20.25t-9,20.25l-209,209L730,689.5q8.5,9 8.5,20.25T730,729.5q-9,9 -20.25,9t-20.25,-9L480,520.5Z"/>
</vector>
@@ -1,4 +0,0 @@
<vector android:height="24dp" android:viewportHeight="960"
android:viewportWidth="960" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M430.5,707.5q-12.25,0 -20.63,-8.18 -8.38,-8.18 -8.38,-20.5 0,-12.32 8.38,-20.58T430.5,650h99q12.25,0 20.63,8.43 8.38,8.43 8.38,20.75 0,12.32 -8.38,20.33t-20.63,8h-99ZM152.5,300q-12.25,0 -20.63,-8.18 -8.38,-8.18 -8.38,-20.5 0,-12.32 8.38,-20.58t20.63,-8.25h655q12.25,0 20.63,8.43 8.38,8.43 8.38,20.75 0,12.32 -8.38,20.33t-20.63,8h-655ZM271.5,504q-12.25,0 -20.63,-8.43 -8.38,-8.43 -8.38,-20.75 0,-11.82 8.38,-20.08t20.63,-8.25h417q11.75,0 20.13,8.43 8.38,8.43 8.38,20.25 0,12.32 -8.38,20.58T688.5,504h-417Z"/>
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M480.02,479.5q-65.52,0 -106.52,-40.98t-41,-106.5q0,-65.52 40.98,-106.52t106.5,-41q65.52,0 106.52,40.98t41,106.5q0,65.52 -40.98,106.52t-106.5,41ZM738,793.5L222,793.5q-23.72,0 -40.61,-16.89Q164.5,759.72 164.5,736v-33.51q0,-37.49 18.75,-63.99t48.43,-40.17Q298,569 358.5,554T480,539q61,0 121,15.25t126.4,44.43q30.82,13.64 49.46,39.85Q795.5,664.75 795.5,702.47v33.77q0,23.45 -16.89,40.36Q761.72,793.5 738,793.5ZM222,736h516v-33.37q0,-16.32 -9.75,-30.97Q718.5,657 705,650q-64,-30.5 -116.29,-42t-108.57,-11.5Q423.5,596.5 370.5,608t-116,42q-14,7 -23.25,21.73T222,702.74L222,736ZM480,422q39,0 64.5,-25.5T570,332q0,-39 -25.5,-64.5T480,242q-39,0 -64.5,25.5T390,332q0,39 25.5,64.5T480,422ZM480,332ZM480,736Z"/>
</vector>
@@ -1,4 +0,0 @@
<vector android:height="24dp" android:viewportHeight="960"
android:viewportWidth="960" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M479.83,521.5q-12.32,0 -20.33,-8.38t-8,-20.63v-344q0,-11.68 8.18,-20.09 8.18,-8.41 20.5,-8.41 12.32,0 20.58,8.41Q509,136.83 509,148.5v344q0,12.25 -8.43,20.63 -8.43,8.38 -20.75,8.38ZM480.07,837.5q-74.07,0 -138.82,-28 -64.75,-28 -112.75,-76t-76,-112.7q-28,-64.7 -28,-138.8 0,-70 27.73,-133.75 27.73,-63.75 77.17,-114.06 9.1,-9.19 22.16,-9.69 13.06,-0.5 21.78,8.32 9.16,9.3 7.41,21.24Q279,266 270.5,274.5 228,316 205,369.36q-23,53.36 -23,112.76 0,124.23 86.94,211.05Q355.88,780 480.12,780t211.06,-86.93Q778,606.13 778,481.9q0,-59.4 -22.39,-112.74Q733.21,315.82 691.5,274q-9,-9 -10.25,-21.25T689,232.5q9.5,-9.5 22.75,-7.75T735,236.19q48,50.31 74.25,113.35t26.25,132.49q0,73.97 -28,138.72 -28,64.75 -76,112.75t-112.68,76q-64.68,28 -138.75,28Z"/>
</vector>
@@ -1,4 +0,0 @@
<vector android:height="24dp" android:viewportHeight="960"
android:viewportWidth="960" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M421,830q-110,-20 -183.25,-107.25T164.5,520.5q0,-71 30.5,-134.25t86,-106.75q7.5,-5.5 18.25,-4.75T317.5,283q9.5,9.5 8.5,22t-13,22q-43.5,35 -67.25,86T222,520.5q0,93.5 58,163T427,772q10.5,2 18,10.5t7.5,19.5q0,13.5 -9.75,21.75T421,830ZM541,830q-12,2 -21.5,-6.25T510,802q0,-10.5 7.25,-19.25T535.5,772q90,-19 147,-88.5t57,-163q0,-108 -75,-183.25T481,262h-21l41,41q8,8 8,20.25T501,343q-9,9 -21.25,9T460,343l-91,-89.5q-5,-4.5 -7,-9.25t-2,-10.75q0,-6 2,-10.75t7,-9.25l91,-91q7.5,-8 19.75,-8t21.25,8q8,9 8,21.25t-8,19.75l-41,41h21q132,0 224,92.5t92,223.5q0,115 -73,202.25T541,830Z"/>
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M480,729.5 L293.5,842q-8,5 -16.75,4.5T261.5,841q-6.5,-5 -10,-12.5t-1,-17.5l49,-212.5L135,455q-7.5,-6.5 -9.25,-14.5t0.25,-16q2,-8 9,-13.25t16.5,-6.25L369,386l84.5,-200.5q3.5,-8.5 11,-13T480,168q8,0 15.5,4.5t11.5,13L591.5,386 809,405q9,1 16,6.25t9,13.25q2,8 0.25,16T825,455L660.5,598.5 710,811q2,10 -1.5,17.5t-10,12.5q-6.5,5 -15.25,5.5T667,842L480,729.5Z"/>
</vector>

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