Compare commits
1 Commits
ep/survey-
...
av/ios-mai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23ca901f14 |
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Feature
|
||||
description: Suggest your feature
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
labels: ["type:enhancement", "type:triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/question.yml
vendored
2
.github/ISSUE_TEMPLATE/question.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Question
|
||||
description: Ask your question
|
||||
title: "[Q]: "
|
||||
labels: ["question", "triage"]
|
||||
labels: ["type:question", "type:triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
30
.github/workflows/build.yml
vendored
30
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/web.yml
vendored
2
.github/workflows/web.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x]
|
||||
node-version: [12.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
50
README.md
50
README.md
@@ -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)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||
|🇯🇵 ja|Japanese ||[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|||
|
||||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||
|🇧🇷 pt-BR|Português||[](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|
||||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|
||||
@@ -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,10 +197,6 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent updates:
|
||||
|
||||
[July 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
||||
|
||||
[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).
|
||||
@@ -259,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.
|
||||
@@ -292,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.
|
||||
@@ -334,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.
|
||||
- ✅ Message delivery confirmation (with sender opt-out per contact).
|
||||
- 🏗 Desktop client.
|
||||
- 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.
|
||||
|
||||
|
||||
20
apps/android/.gitignore
vendored
Normal file
20
apps/android/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/misc.xml
|
||||
/.idea/uiDesigner.xml
|
||||
/.idea/kotlinc.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
app/src/main/cpp/libs/
|
||||
1
apps/android/.idea/.name
generated
Normal file
1
apps/android/.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
SimpleX
|
||||
6
apps/android/.idea/compiler.xml
generated
Normal file
6
apps/android/.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
</component>
|
||||
</project>
|
||||
19
apps/android/.idea/gradle.xml
generated
Normal file
19
apps/android/.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
20
apps/android/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
20
apps/android/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
apps/android/.idea/vcs.xml
generated
Normal file
6
apps/android/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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).
|
||||
235
apps/android/app/build.gradle
Normal file
235
apps/android/app/build.gradle
Normal file
@@ -0,0 +1,235 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 32
|
||||
|
||||
defaultConfig {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 26
|
||||
targetSdk 32
|
||||
versionCode 117
|
||||
versionName "5.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags ''
|
||||
}
|
||||
}
|
||||
manifestPlaceholders.app_name = "@string/app_name"
|
||||
manifestPlaceholders.provider_authorities = "chat.simplex.app.provider"
|
||||
manifestPlaceholders.extract_native_libs = compression_level != "0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix "$application_id_suffix"
|
||||
debuggable new Boolean("$enable_debuggable")
|
||||
manifestPlaceholders.app_name = "$app_name"
|
||||
// Provider can't be the same for different apps on the same device
|
||||
manifestPlaceholders.provider_authorities = "chat.simplex.app${application_id_suffix}.provider"
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.material.ExperimentalMaterialApi"
|
||||
freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets"
|
||||
freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi"
|
||||
freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi"
|
||||
freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file('src/main/cpp/CMakeLists.txt')
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion compose_version
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
jniLibs.useLegacyPackaging = compression_level != "0"
|
||||
}
|
||||
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||
def isBundle = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("bundle") }) != null
|
||||
// if (isRelease) {
|
||||
// Comma separated list of languages that will be included in the apk
|
||||
android.defaultConfig.resConfigs(
|
||||
"en",
|
||||
"cs",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"nl",
|
||||
"pl",
|
||||
"ru",
|
||||
"zh-rCN"
|
||||
)
|
||||
// }
|
||||
if (isBundle) {
|
||||
defaultConfig.ndk.abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||
} else {
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
if (isRelease) {
|
||||
include 'arm64-v8a', 'armeabi-v7a'
|
||||
} else {
|
||||
include 'arm64-v8a', 'armeabi-v7a'
|
||||
universalApk false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation "androidx.compose.ui:ui:$compose_version"
|
||||
implementation "androidx.compose.material:material:$compose_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
|
||||
implementation 'androidx.activity:activity-compose:1.4.0'
|
||||
implementation 'androidx.fragment:fragment:1.4.1'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||
implementation 'com.charleskorn.kaml:kaml:0.43.0'
|
||||
//implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||
implementation "androidx.compose.ui:ui-util:$compose_version"
|
||||
implementation "androidx.navigation:navigation-compose:2.4.1"
|
||||
implementation "com.google.accompanist:accompanist-insets:0.23.0"
|
||||
implementation 'androidx.webkit:webkit:1.4.0'
|
||||
implementation "com.godaddy.android.colorpicker:compose-color-picker:0.4.2"
|
||||
|
||||
def work_version = "2.7.1"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.work:work-multiprocess:$work_version"
|
||||
|
||||
def camerax_version = "1.1.0-beta01"
|
||||
implementation "androidx.camera:camera-core:${camerax_version}"
|
||||
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
||||
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
||||
implementation "androidx.camera:camera-view:${camerax_version}"
|
||||
|
||||
//Barcode
|
||||
implementation 'org.boofcv:boofcv-android:0.40.1'
|
||||
implementation 'org.boofcv:boofcv-core:0.40.1'
|
||||
|
||||
//Camera Permission
|
||||
implementation "com.google.accompanist:accompanist-permissions:0.23.0"
|
||||
implementation "com.google.accompanist:accompanist-pager:0.25.1"
|
||||
|
||||
// Link Previews
|
||||
implementation 'org.jsoup:jsoup:1.13.1'
|
||||
|
||||
// Biometric authentication
|
||||
implementation 'androidx.biometric:biometric:1.2.0-alpha04'
|
||||
|
||||
// GIFs support
|
||||
implementation "io.coil-kt:coil-compose:2.1.0"
|
||||
implementation "io.coil-kt:coil-gif:2.1.0"
|
||||
|
||||
// Video support
|
||||
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||
}
|
||||
|
||||
// Don't do anything if no compression is needed
|
||||
if (compression_level != "0") {
|
||||
tasks.whenTaskAdded { task ->
|
||||
if (task.name == 'packageDebug') {
|
||||
task.finalizedBy compressApk
|
||||
} else if (task.name == 'packageRelease') {
|
||||
task.finalizedBy compressApk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("compressApk") {
|
||||
doLast {
|
||||
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||
def buildType
|
||||
if (isRelease) {
|
||||
buildType = "release"
|
||||
} else {
|
||||
buildType = "debug"
|
||||
}
|
||||
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
||||
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
||||
def keyAlias = ""
|
||||
def keyPassword = ""
|
||||
def storeFile = ""
|
||||
def storePassword = ""
|
||||
if (project.properties['android.injected.signing.key.alias'] != null) {
|
||||
keyAlias = project.properties['android.injected.signing.key.alias']
|
||||
keyPassword = project.properties['android.injected.signing.key.password']
|
||||
storeFile = project.properties['android.injected.signing.store.file']
|
||||
storePassword = project.properties['android.injected.signing.store.password']
|
||||
} else if (android.signingConfigs.hasProperty(buildType)) {
|
||||
def gradleConfig = android.signingConfigs[buildType]
|
||||
keyAlias = gradleConfig.keyAlias
|
||||
keyPassword = gradleConfig.keyPassword
|
||||
storeFile = gradleConfig.storeFile
|
||||
storePassword = gradleConfig.storePassword
|
||||
} else {
|
||||
// There is no signing config for current build type, can't sign the apk
|
||||
println("No signing configs for this build type: $buildType")
|
||||
return
|
||||
}
|
||||
|
||||
def outputDir = tasks["package${buildType.capitalize()}"].outputs.files.last()
|
||||
|
||||
exec {
|
||||
workingDir '../../../scripts/android'
|
||||
setEnvironment(['JAVA_HOME': "$javaHome"])
|
||||
commandLine './compress-and-sign-apk.sh', \
|
||||
"$compression_level", \
|
||||
"$outputDir", \
|
||||
"$sdkDir", \
|
||||
"$storeFile", \
|
||||
"$storePassword", \
|
||||
"$keyAlias", \
|
||||
"$keyPassword"
|
||||
}
|
||||
|
||||
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
||||
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||
new File(outputDir, "app-armeabi-v7a-release.apk").renameTo(new File(outputDir, "simplex-armv7a.apk"))
|
||||
new File(outputDir, "app-arm64-v8a-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||
}
|
||||
|
||||
// View all gradle properties set
|
||||
// project.properties.each { k, v -> println "$k -> $v" }
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
<uses-feature android:name="android.hardware.camera" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.VIDEO_CAPTURE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
@@ -64,4 +64,4 @@ target_link_libraries( # Specifies the target library.
|
||||
|
||||
# Links the target library to the log library
|
||||
# included in the NDK.
|
||||
${log-lib})
|
||||
${log-lib})
|
||||
@@ -19,7 +19,7 @@ extern void __rel_iplt_start(void){};
|
||||
extern void reallocarray(void){};
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
||||
int ret = pipe_std_to_socket(name);
|
||||
(*env)->ReleaseStringUTFChars(env, socket_name, name);
|
||||
@@ -27,7 +27,7 @@ Java_chat_simplex_common_platform_CoreKt_pipeStdOutToSocket(JNIEnv *env, __unuse
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
|
||||
Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
|
||||
hs_init(NULL, NULL);
|
||||
setLineBuffering();
|
||||
}
|
||||
@@ -44,7 +44,7 @@ extern char *chat_parse_server(const char *str);
|
||||
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
||||
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
||||
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
|
||||
@@ -67,7 +67,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
|
||||
Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
|
||||
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
|
||||
(*env)->ReleaseStringUTFChars(env, msg, _msg);
|
||||
@@ -75,17 +75,17 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, __unused jclas
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
|
||||
Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
|
||||
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) {
|
||||
Java_chat_simplex_app_SimplexAppKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) {
|
||||
return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait));
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||
Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str));
|
||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||
@@ -93,7 +93,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, __unused
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||
Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
|
||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||
@@ -101,7 +101,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused j
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
|
||||
Java_chat_simplex_app_SimplexAppKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
|
||||
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
|
||||
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -3,8 +3,8 @@ package chat.simplex.app
|
||||
import android.app.backup.BackupAgentHelper
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import android.content.Context
|
||||
import chat.simplex.common.model.AppPreferences
|
||||
import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
|
||||
|
||||
class BackupAgent: BackupAgentHelper() {
|
||||
override fun onFullBackup(data: FullBackupDataOutput?) {
|
||||
668
apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
Normal file
668
apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
Normal file
@@ -0,0 +1,668 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.os.SystemClock.elapsedRealtime
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.animation.core.*
|
||||
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
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.*
|
||||
import chat.simplex.app.MainActivity.Companion.enteredBackground
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.model.NtfManager.Companion.getUserIdFromIntent
|
||||
import chat.simplex.app.ui.theme.*
|
||||
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.chatlist.*
|
||||
import chat.simplex.app.views.database.DatabaseErrorView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.localauth.SetAppPasscodeView
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import chat.simplex.app.views.usersettings.LAMode
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
companion object {
|
||||
/**
|
||||
* We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user
|
||||
* clicks on new message in notification. In this case savedInstanceState will be null (this prevents restoring the values)
|
||||
* See [SimplexService.onTaskRemoved] for another part of the logic which nullifies the values when app closed by the user
|
||||
* */
|
||||
val userAuthorized = mutableStateOf<Boolean?>(null)
|
||||
val enteredBackground = mutableStateOf<Long?>(null)
|
||||
// Remember result and show it after orientation change
|
||||
private val laFailed = mutableStateOf(false)
|
||||
|
||||
fun clearAuthState() {
|
||||
userAuthorized.value = null
|
||||
enteredBackground.value = null
|
||||
}
|
||||
}
|
||||
private val vm by viewModels<SimplexViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// testJson()
|
||||
val m = vm.chatModel
|
||||
applyAppLocale(m.controller.appPrefs.appLanguage)
|
||||
// When call ended and orientation changes, it re-process old intent, it's unneeded.
|
||||
// Only needed to be processed on first creation of activity
|
||||
if (savedInstanceState == null) {
|
||||
processNotificationIntent(intent, m)
|
||||
processIntent(intent, m)
|
||||
processExternalIntent(intent, m)
|
||||
}
|
||||
if (m.controller.appPrefs.privacyProtectScreen.get()) {
|
||||
Log.d(TAG, "onCreate: set FLAG_SECURE")
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE
|
||||
)
|
||||
}
|
||||
setContent {
|
||||
SimpleXTheme {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
MainPage(
|
||||
m,
|
||||
userAuthorized,
|
||||
laFailed,
|
||||
::runAuthenticate,
|
||||
::setPerformLA,
|
||||
showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown, this) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||
SimplexApp.context.schedulePeriodicWakeUp()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
processIntent(intent, vm.chatModel)
|
||||
processExternalIntent(intent, vm.chatModel)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val enteredBackgroundVal = enteredBackground.value
|
||||
val delay = vm.chatModel.controller.appPrefs.laLockDelay.get()
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
/**
|
||||
* When new activity is created after a click on notification, the old one receives onPause before
|
||||
* recreation but receives onStop after recreation. So using both (onPause and onStop) to prevent
|
||||
* unwanted multiple auth dialogs from [runAuthenticate]
|
||||
* */
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
VideoPlayer.stopAll()
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (
|
||||
onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
|
||||
|| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
|
||||
|| isTaskRoot // there are still other tasks after we reach the main (home) activity
|
||||
) {
|
||||
// https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
||||
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
|
||||
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
|
||||
clearAuthState()
|
||||
laFailed.value = true
|
||||
}
|
||||
if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
|
||||
// Drop shared content
|
||||
SimplexApp.context.chatModel.sharedContent.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun runAuthenticate() {
|
||||
val m = vm.chatModel
|
||||
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)
|
||||
withContext(Dispatchers.Main) {
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_unlock)
|
||||
else
|
||||
generalGetString(R.string.la_enter_app_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_log_in_using_credential)
|
||||
else
|
||||
generalGetString(R.string.auth_unlock),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success ->
|
||||
userAuthorized.value = true
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
laFailed.value = true
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.PASSCODE) {
|
||||
laFailedAlert()
|
||||
}
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLANotice(laNoticeShown: SharedPreference<Boolean>, activity: FragmentActivity) {
|
||||
Log.d(TAG, "showLANotice")
|
||||
if (!laNoticeShown.get()) {
|
||||
laNoticeShown.set(true)
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.la_notice_title_simplex_lock),
|
||||
text = generalGetString(R.string.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
|
||||
confirmText = generalGetString(R.string.la_notice_turn_on),
|
||||
onConfirm = {
|
||||
withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager
|
||||
showChooseLAMode(laNoticeShown, activity)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showChooseLAMode(laNoticeShown: SharedPreference<Boolean>, activity: FragmentActivity) {
|
||||
Log.d(TAG, "showLANotice")
|
||||
laNoticeShown.set(true)
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.la_lock_mode),
|
||||
text = null,
|
||||
confirmText = generalGetString(R.string.la_lock_mode_passcode),
|
||||
dismissText = generalGetString(R.string.la_lock_mode_system),
|
||||
onConfirm = {
|
||||
AlertManager.shared.hideAlert()
|
||||
setPasscode()
|
||||
},
|
||||
onDismiss = {
|
||||
AlertManager.shared.hideAlert()
|
||||
initialEnableLA(activity)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialEnableLA(activity: FragmentActivity) {
|
||||
val m = vm.chatModel
|
||||
val appPrefs = m.controller.appPrefs
|
||||
m.controller.appPrefs.laMode.set(LAMode.SYSTEM)
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_enable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
activity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
m.showAdvertiseLAUnavailableAlert.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setPasscode() {
|
||||
val chatModel = vm.chatModel
|
||||
val appPrefs = chatModel.controller.appPrefs
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
SetAppPasscodeView(
|
||||
submit = {
|
||||
chatModel.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
appPrefs.laMode.set(LAMode.PASSCODE)
|
||||
laTurnedOnAlert()
|
||||
},
|
||||
cancel = {
|
||||
chatModel.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laPasscodeNotSetAlert()
|
||||
},
|
||||
close)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPerformLA(on: Boolean, activity: FragmentActivity) {
|
||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (on) {
|
||||
enableLA(activity)
|
||||
} else {
|
||||
disableLA(activity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableLA(activity: FragmentActivity) {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_enable_simplex_lock)
|
||||
else
|
||||
generalGetString(R.string.new_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_confirm_credential)
|
||||
else
|
||||
"",
|
||||
activity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableLA(activity: FragmentActivity) {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_disable_simplex_lock)
|
||||
else
|
||||
generalGetString(R.string.la_enter_app_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(R.string.auth_confirm_credential)
|
||||
else
|
||||
generalGetString(R.string.auth_disable_simplex_lock),
|
||||
activity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
ksAppPassword.remove()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class SimplexViewModel(application: Application): AndroidViewModel(application) {
|
||||
val app = getApplication<SimplexApp>()
|
||||
val chatModel = app.chatModel
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainPage(
|
||||
chatModel: ChatModel,
|
||||
userAuthorized: MutableState<Boolean?>,
|
||||
laFailed: MutableState<Boolean>,
|
||||
runAuthenticate: () -> Unit,
|
||||
setPerformLA: (Boolean, FragmentActivity) -> Unit,
|
||||
showLANotice: () -> Unit
|
||||
) {
|
||||
var showChatDatabaseError by rememberSaveable {
|
||||
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
||||
}
|
||||
LaunchedEffect(chatModel.chatDbStatus.value) {
|
||||
showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null
|
||||
}
|
||||
|
||||
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(showAdvertiseLAAlert) {
|
||||
if (
|
||||
!chatModel.controller.appPrefs.laNoticeShown.get()
|
||||
&& showAdvertiseLAAlert
|
||||
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
|
||||
&& chatModel.chats.isNotEmpty()
|
||||
&& chatModel.activeCallInvitation.value == null
|
||||
) {
|
||||
showLANotice()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||
if (chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value) {
|
||||
ModalManager.shared.closeModals()
|
||||
chatModel.clearOverlays.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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
|
||||
when {
|
||||
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) }
|
||||
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
translationX = -offset.value.dp.toPx()
|
||||
}
|
||||
) {
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
if (chatModel.sharedContent.value == null)
|
||||
ChatListView(chatModel, setPerformLA, stopped)
|
||||
else
|
||||
ShareListView(chatModel, stopped)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val onComposed: () -> Unit = {
|
||||
scope.launch {
|
||||
offset.animateTo(
|
||||
if (chatModel.chatId.value == null) 0f else maxWidth.value,
|
||||
chatListAnimationSpec()
|
||||
)
|
||||
if (offset.value == 0f) {
|
||||
currentChatId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (it != null) currentChatId = it
|
||||
else onComposed()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
|
||||
currentChatId?.let {
|
||||
ChatView(it, chatModel, onComposed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
|
||||
onboarding == OnboardingStage.Step3_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||
}
|
||||
ModalManager.shared.showInView()
|
||||
val invitation = chatModel.activeCallInvitation.value
|
||||
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||
AlertManager.shared.showInView()
|
||||
}
|
||||
|
||||
DisposableEffectOnRotate {
|
||||
// When using lock delay = 0 and screen rotates, the app will be locked which is not useful.
|
||||
// Let's prolong the unlocked period to 3 sec for screen rotation to take place
|
||||
if (chatModel.controller.appPrefs.laLockDelay.get() == 0) {
|
||||
enteredBackground.value = elapsedRealtime() + 3000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
val userId = getUserIdFromIntent(intent)
|
||||
when (intent?.action) {
|
||||
NtfManager.OpenChatAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||
if (chatId != null) {
|
||||
withBGApi {
|
||||
awaitChatStartedIfNeeded(chatModel)
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||
chatModel.controller.changeActiveUser(userId, null)
|
||||
}
|
||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||
chatModel.clearOverlays.value = true
|
||||
if (cInfo != null) openChat(cInfo, chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
NtfManager.ShowChatsAction -> {
|
||||
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||
withBGApi {
|
||||
awaitChatStartedIfNeeded(chatModel)
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||
chatModel.controller.changeActiveUser(userId, null)
|
||||
}
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
}
|
||||
}
|
||||
NtfManager.AcceptCallAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
if (chatId == null || chatId == "") return
|
||||
Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
|
||||
chatModel.clearOverlays.value = true
|
||||
val invitation = chatModel.callInvitations[chatId]
|
||||
if (invitation == null) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended))
|
||||
} else {
|
||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
when (intent?.action) {
|
||||
"android.intent.action.VIEW" -> {
|
||||
val uri = intent.data
|
||||
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
// Close active chat and show a list of chats
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
when {
|
||||
intent.type == "text/plain" -> {
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (text != null) {
|
||||
chatModel.sharedContent.value = SharedContent.Text(text)
|
||||
}
|
||||
}
|
||||
isMediaIntent(intent) -> {
|
||||
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
if (uri != null) {
|
||||
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri))
|
||||
} // All other mime types
|
||||
}
|
||||
else -> {
|
||||
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
if (uri != null) {
|
||||
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
// Close active chat and show a list of chats
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
Log.e(TAG, "ACTION_SEND_MULTIPLE ${intent.type}")
|
||||
when {
|
||||
isMediaIntent(intent) -> {
|
||||
val uris = intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>
|
||||
if (uris != null) {
|
||||
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris)
|
||||
} // All other mime types
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isMediaIntent(intent: Intent): Boolean =
|
||||
intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
|
||||
|
||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
chatModel.appOpenUrl.value = uri
|
||||
} else {
|
||||
withUriAction(uri) { linkType ->
|
||||
val title = when (linkType) {
|
||||
ConnectionLinkType.CONTACT -> generalGetString(R.string.connect_via_contact_link)
|
||||
ConnectionLinkType.INVITATION -> generalGetString(R.string.connect_via_invitation_link)
|
||||
ConnectionLinkType.GROUP -> generalGetString(R.string.connect_via_group_link)
|
||||
}
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = title,
|
||||
text = if (linkType == ConnectionLinkType.GROUP)
|
||||
generalGetString(R.string.you_will_join_group)
|
||||
else
|
||||
generalGetString(R.string.profile_will_be_sent_to_contact_sending_link),
|
||||
confirmText = generalGetString(R.string.connect_via_link_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: connecting")
|
||||
connectViaUri(chatModel, linkType, uri)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
|
||||
// Still decrypting database
|
||||
if (chatModel.chatRunning.value == null) {
|
||||
val step = 50L
|
||||
for (i in 0..(timeout / step)) {
|
||||
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
|
||||
break
|
||||
}
|
||||
delay(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//fun testJson() {
|
||||
// val str: String = """
|
||||
// """.trimIndent()
|
||||
//
|
||||
// println(json.decodeFromString<APIResponse>(str))
|
||||
//}
|
||||
260
apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
Normal file
260
apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
Normal file
@@ -0,0 +1,260 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.Application
|
||||
import android.net.LocalServerSocket
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.*
|
||||
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 kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
const val TAG = "SIMPLEX"
|
||||
|
||||
// ghc's rts
|
||||
external fun initHS()
|
||||
// android-support
|
||||
external fun pipeStdOutToSocket(socketName: String) : Int
|
||||
|
||||
// SimpleX API
|
||||
typealias ChatCtrl = Long
|
||||
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>
|
||||
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
||||
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
||||
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||
external fun chatParseMarkdown(str: String): String
|
||||
external fun chatParseServer(str: String): String
|
||||
external fun chatPasswordHash(pwd: String, salt: String): String
|
||||
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
lateinit var chatController: ChatController
|
||||
|
||||
var isAppOnForeground: Boolean = false
|
||||
|
||||
val defaultLocale: Locale = Locale.getDefault()
|
||||
|
||||
fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
|
||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
|
||||
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
||||
val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey, confirm.value)
|
||||
val res: DBMigrationResult = kotlin.runCatching {
|
||||
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
||||
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
||||
val ctrl = if (res is DBMigrationResult.OK) {
|
||||
migrated[1] as Long
|
||||
} else null
|
||||
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) {
|
||||
Log.d(TAG, "Unable to migrate successfully: $res")
|
||||
} 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)
|
||||
withApi {
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user == null) {
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
} else {
|
||||
chatController.startChat(user)
|
||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||
if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
|
||||
SimplexService.start(applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val chatModel: ChatModel
|
||||
get() = chatController.chatModel
|
||||
|
||||
private val ntfManager: NtfManager by lazy {
|
||||
NtfManager(applicationContext, appPreferences)
|
||||
}
|
||||
|
||||
private val appPreferences: AppPreferences by lazy {
|
||||
AppPreferences(applicationContext)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
context = this
|
||||
initChatController()
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
|
||||
runMigrations()
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
Log.d(TAG, "onStateChanged: $event")
|
||||
withApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> {
|
||||
isAppOnForeground = true
|
||||
if (chatModel.chatRunning.value == true) {
|
||||
kotlin.runCatching {
|
||||
val currentUserId = chatModel.currentUser.value?.userId
|
||||
val chats = ArrayList(chatController.apiGetChats())
|
||||
/** Active user can be changed in background while [ChatController.apiGetChats] is executing */
|
||||
if (chatModel.currentUser.value?.userId == currentUserId) {
|
||||
val currentChatId = chatModel.chatId.value
|
||||
val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null
|
||||
if (oldStats != null) {
|
||||
val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId }
|
||||
/** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
|
||||
if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
|
||||
}
|
||||
chatModel.updateChats(chats)
|
||||
}
|
||||
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
isAppOnForeground = true
|
||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
/**
|
||||
* We're starting service here instead of in [Lifecycle.Event.ON_START] because
|
||||
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
|
||||
* It can happen when app was started and a user enables battery optimization while app in background
|
||||
* */
|
||||
if (chatModel.chatRunning.value != false &&
|
||||
chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
|
||||
appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
|
||||
) {
|
||||
SimplexService.start(applicationContext)
|
||||
}
|
||||
}
|
||||
else -> isAppOnForeground = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
|
||||
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name &&
|
||||
(!NotificationsMode.SERVICE.requiresIgnoringBattery || isIgnoringBatteryOptimizations(chatModel.controller.appContext))
|
||||
}
|
||||
|
||||
private fun allowToStartPeriodically() = with(chatModel.controller) {
|
||||
appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC.name &&
|
||||
(!NotificationsMode.PERIODIC.requiresIgnoringBattery || isIgnoringBatteryOptimizations(chatModel.controller.appContext))
|
||||
}
|
||||
|
||||
/*
|
||||
* It takes 1-10 milliseconds to process this function. Better to do it in a background thread
|
||||
* */
|
||||
fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch {
|
||||
if (!allowToStartServiceAfterAppExit()) {
|
||||
return@launch
|
||||
}
|
||||
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
|
||||
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
||||
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
.addTag(SimplexService.TAG)
|
||||
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
.build()
|
||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||
WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
|
||||
fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch {
|
||||
if (!allowToStartPeriodically()) {
|
||||
return@launch
|
||||
}
|
||||
MessagesFetcherWorker.scheduleWork()
|
||||
}
|
||||
|
||||
private fun runMigrations() {
|
||||
val lastMigration = chatModel.controller.appPrefs.lastMigratedVersionCode
|
||||
if (lastMigration.get() < BuildConfig.VERSION_CODE) {
|
||||
while (true) {
|
||||
if (lastMigration.get() < 117) {
|
||||
if (chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.DARK.name) {
|
||||
chatModel.controller.appPrefs.currentTheme.set(DefaultTheme.SIMPLEX.name)
|
||||
}
|
||||
lastMigration.set(117)
|
||||
} else {
|
||||
lastMigration.set(BuildConfig.VERSION_CODE)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var context: SimplexApp private set
|
||||
|
||||
init {
|
||||
val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2"
|
||||
val s = Semaphore(0)
|
||||
thread(name="stdout/stderr pipe") {
|
||||
Log.d(TAG, "starting server")
|
||||
var server: LocalServerSocket? = null
|
||||
for (i in 0..100) {
|
||||
try {
|
||||
server = LocalServerSocket(socketName + i)
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
if (server == null) {
|
||||
throw Error("Unable to setup local server socket. Contact developers")
|
||||
}
|
||||
Log.d(TAG, "started server")
|
||||
s.release()
|
||||
val receiver = server.accept()
|
||||
Log.d(TAG, "started receiver")
|
||||
val logbuffer = FifoQueue<String>(500)
|
||||
if (receiver != null) {
|
||||
val inStream = receiver.inputStream
|
||||
val inStreamReader = InputStreamReader(inStream)
|
||||
val input = BufferedReader(inStreamReader)
|
||||
Log.d(TAG, "starting receiver loop")
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
Log.w("$TAG (stdout/stderr)", line)
|
||||
logbuffer.add(line)
|
||||
}
|
||||
Log.w(TAG, "exited receiver loop")
|
||||
}
|
||||
}
|
||||
|
||||
System.loadLibrary("app-lib")
|
||||
|
||||
s.acquire()
|
||||
pipeStdOutToSocket(socketName)
|
||||
|
||||
initHS()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
|
||||
override fun add(element: E): Boolean {
|
||||
if(size > capacity) removeFirst()
|
||||
return super.add(element)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,17 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.platform.Log
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.*
|
||||
import chat.simplex.common.AppLock
|
||||
import chat.simplex.common.AppLock.clearAuthState
|
||||
import chat.simplex.common.helpers.requiresIgnoringBattery
|
||||
import chat.simplex.common.model.ChatController
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import kotlinx.coroutines.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// based on:
|
||||
// https://robertohuertas.com/2019/06/29/android_foreground_services/
|
||||
@@ -56,8 +41,8 @@ class SimplexService: Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Simplex service created")
|
||||
val title = generalGetString(MR.strings.simplex_service_notification_title)
|
||||
val text = generalGetString(MR.strings.simplex_service_notification_text)
|
||||
val title = getString(R.string.simplex_service_notification_title)
|
||||
val text = getString(R.string.simplex_service_notification_text)
|
||||
notificationManager = createNotificationChannel()
|
||||
serviceNotification = createNotification(title, text)
|
||||
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
||||
@@ -100,15 +85,14 @@ class SimplexService: Service() {
|
||||
val self = this
|
||||
isStartingService = true
|
||||
withApi {
|
||||
val chatController = ChatController
|
||||
waitDbMigrationEnds(chatController)
|
||||
val chatController = (application as SimplexApp).chatController
|
||||
try {
|
||||
Log.w(TAG, "Starting foreground service")
|
||||
val chatDbStatus = chatController.chatModel.chatDbStatus.value
|
||||
if (chatDbStatus != DBMigrationResult.OK) {
|
||||
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
||||
showPassphraseNotification(chatDbStatus)
|
||||
safeStopService()
|
||||
safeStopService(self)
|
||||
return@withApi
|
||||
}
|
||||
saveServiceState(self, ServiceState.STARTED)
|
||||
@@ -157,7 +141,7 @@ class SimplexService: Service() {
|
||||
setupIntent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
setupIntent.putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL_ID)
|
||||
val setup = PendingIntent.getActivity(this, 0, setupIntent, flags)
|
||||
builder.addAction(0, generalGetString(MR.strings.hide_notification), setup)
|
||||
builder.addAction(0, getString(R.string.hide_notification), setup)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
@@ -170,7 +154,7 @@ class SimplexService: Service() {
|
||||
// re-schedules the task when "Clear recent apps" is pressed
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
// Just to make sure that after restart of the app the user will need to re-authenticate
|
||||
AppLock.clearAuthState()
|
||||
MainActivity.clearAuthState()
|
||||
|
||||
// If notification service isn't enabled or battery optimization isn't disabled, we shouldn't restart the service
|
||||
if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
|
||||
@@ -181,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);
|
||||
}
|
||||
@@ -221,7 +206,7 @@ class SimplexService: Service() {
|
||||
}
|
||||
if (getServiceState(context) == ServiceState.STARTED) {
|
||||
Log.d(TAG, "ServiceStartWorker: Starting foreground service (work ID: $id)")
|
||||
start()
|
||||
start(context)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
@@ -262,26 +247,26 @@ class SimplexService: Service() {
|
||||
workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
|
||||
}
|
||||
|
||||
suspend fun start() = serviceAction(Action.START)
|
||||
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
||||
|
||||
/**
|
||||
* If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an
|
||||
* exception related to foreground services lifecycle
|
||||
* */
|
||||
fun safeStopService() {
|
||||
fun safeStopService(context: Context) {
|
||||
if (isServiceStarted) {
|
||||
androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java))
|
||||
context.stopService(Intent(context, SimplexService::class.java))
|
||||
} else {
|
||||
stopAfterStart = true
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun serviceAction(action: Action) {
|
||||
private suspend fun serviceAction(context: Context, action: Action) {
|
||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||
withContext(Dispatchers.IO) {
|
||||
Intent(androidAppContext, SimplexService::class.java).also {
|
||||
Intent(context, SimplexService::class.java).also {
|
||||
it.action = action.name
|
||||
ContextCompat.startForegroundService(androidAppContext, it)
|
||||
ContextCompat.startForegroundService(context, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,15 +295,15 @@ class SimplexService: Service() {
|
||||
}
|
||||
|
||||
val title = when(chatDbStatus) {
|
||||
is DBMigrationResult.ErrorNotADatabase -> generalGetString(MR.strings.enter_passphrase_notification_title)
|
||||
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_title)
|
||||
is DBMigrationResult.OK -> return
|
||||
else -> generalGetString(MR.strings.database_initialization_error_title)
|
||||
else -> generalGetString(R.string.database_initialization_error_title)
|
||||
}
|
||||
|
||||
val description = when(chatDbStatus) {
|
||||
is DBMigrationResult.ErrorNotADatabase -> generalGetString(MR.strings.enter_passphrase_notification_desc)
|
||||
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_desc)
|
||||
is DBMigrationResult.OK -> return
|
||||
else -> generalGetString(MR.strings.database_initialization_error_desc)
|
||||
else -> generalGetString(R.string.database_initialization_error_desc)
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(SimplexApp.context, NOTIFICATION_CHANNEL_ID)
|
||||
@@ -339,171 +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)
|
||||
|
||||
fun showBackgroundServiceNoticeIfNeeded() {
|
||||
val appPrefs = ChatController.appPrefs
|
||||
val mode = appPrefs.notificationsMode.get()
|
||||
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
|
||||
// Nothing to do if mode is OFF. Can be selected on on-boarding stage
|
||||
if (mode == NotificationsMode.OFF) return
|
||||
|
||||
if (!appPrefs.backgroundServiceNoticeShown.get()) {
|
||||
// the branch for the new users who have never seen service notice
|
||||
if (!mode.requiresIgnoringBattery || isIgnoringBatteryOptimizations()) {
|
||||
showBGServiceNotice(mode)
|
||||
} else {
|
||||
showBGServiceNoticeIgnoreOptimization(mode)
|
||||
}
|
||||
// set both flags, so that if the user doesn't allow ignoring optimizations, the service will be disabled without additional notice
|
||||
appPrefs.backgroundServiceNoticeShown.set(true)
|
||||
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
|
||||
} else if (mode.requiresIgnoringBattery && !isIgnoringBatteryOptimizations()) {
|
||||
// the branch for users who have app installed, and have seen the service notice,
|
||||
// but the battery optimization for the app is on (Android 12) AND the service is running
|
||||
if (appPrefs.backgroundServiceBatteryNoticeShown.get()) {
|
||||
// users have been presented with battery notice before - they did not allow ignoring optimizations -> disable service
|
||||
showDisablingServiceNotice(mode)
|
||||
appPrefs.notificationsMode.set(NotificationsMode.OFF)
|
||||
StartReceiver.toggleReceiver(false)
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
safeStopService()
|
||||
} else {
|
||||
// show battery optimization notice
|
||||
showBGServiceNoticeIgnoreOptimization(mode)
|
||||
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
|
||||
}
|
||||
} else {
|
||||
// service or periodic mode was chosen and battery optimization is disabled
|
||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||
SimplexApp.context.schedulePeriodicWakeUp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBGServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = AlertManager.shared::hideAlert,
|
||||
title = {
|
||||
Row {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_bolt),
|
||||
contentDescription =
|
||||
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications),
|
||||
)
|
||||
Text(
|
||||
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc),
|
||||
Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(
|
||||
annotatedStringResource(MR.strings.it_can_disabled_via_settings_notifications_still_shown)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(MR.strings.ok)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode) = AlertManager.shared.showAlert {
|
||||
val ignoreOptimization = {
|
||||
AlertManager.shared.hideAlert()
|
||||
askAboutIgnoringBatteryOptimization()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = ignoreOptimization,
|
||||
title = {
|
||||
Row {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_bolt),
|
||||
contentDescription =
|
||||
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications),
|
||||
)
|
||||
Text(
|
||||
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc),
|
||||
Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(annotatedStringResource(MR.strings.turn_off_battery_optimization))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.ok)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun showDisablingServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = AlertManager.shared::hideAlert,
|
||||
title = {
|
||||
Row {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_bolt),
|
||||
contentDescription =
|
||||
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications),
|
||||
)
|
||||
Text(
|
||||
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications_disabled) else stringResource(MR.strings.periodic_notifications_disabled),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
annotatedStringResource(MR.strings.turning_off_service_and_periodic),
|
||||
Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(MR.strings.ok)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun isIgnoringBatteryOptimizations(): Boolean {
|
||||
val powerManager = androidAppContext.getSystemService(Application.POWER_SERVICE) as PowerManager
|
||||
return powerManager.isIgnoringBatteryOptimizations(androidAppContext.packageName)
|
||||
}
|
||||
|
||||
private fun askAboutIgnoringBatteryOptimization() {
|
||||
Intent().apply {
|
||||
@SuppressLint("BatteryLife")
|
||||
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
|
||||
data = Uri.parse("package:${androidAppContext.packageName}")
|
||||
// This flag is needed when you start a new activity from non-Activity context
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
androidAppContext.startActivity(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,42 @@
|
||||
package chat.simplex.app.model
|
||||
|
||||
import android.app.*
|
||||
import android.app.TaskStackBuilder
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.AudioAttributes
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.core.app.*
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.views.call.IncomingCallActivity
|
||||
import chat.simplex.app.views.call.getKeyguardManager
|
||||
import chat.simplex.common.views.chatlist.acceptContactRequest
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.call.CallMediaType
|
||||
import chat.simplex.common.views.call.RcvCallInvitation
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.chatlist.acceptContactRequest
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.NotificationPreviewMode
|
||||
import kotlinx.datetime.Clock
|
||||
import chat.simplex.res.MR
|
||||
|
||||
object NtfManager {
|
||||
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
|
||||
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
||||
class NtfManager(val context: Context, private val appPreferences: AppPreferences) {
|
||||
companion object {
|
||||
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
|
||||
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
||||
|
||||
// DO NOT change notification channel settings / names
|
||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1"
|
||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
||||
const val CallNotificationId: Int = -1
|
||||
private const val UserIdKey: String = "userId"
|
||||
private const val ChatIdKey: String = "chatId"
|
||||
private val appPreferences: AppPreferences = ChatController.appPrefs
|
||||
private val context: Context
|
||||
get() = SimplexApp.context
|
||||
// DO NOT change notification channel settings / names
|
||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1"
|
||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
||||
const val CallNotificationId: Int = -1
|
||||
|
||||
fun getUserIdFromIntent(intent: Intent?): Long? {
|
||||
val userId = intent?.getLongExtra(UserIdKey, -1L)
|
||||
return if (userId == -1L || userId == null) null else userId
|
||||
private const val UserIdKey: String = "userId"
|
||||
private const val ChatIdKey: String = "chatId"
|
||||
|
||||
fun getUserIdFromIntent(intent: Intent?): Long? {
|
||||
val userId = intent?.getLongExtra(UserIdKey, -1L)
|
||||
return if (userId == -1L || userId == null) null else userId
|
||||
}
|
||||
}
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -54,6 +47,10 @@ object NtfManager {
|
||||
if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert()
|
||||
}
|
||||
|
||||
enum class NotificationAction {
|
||||
ACCEPT_CONTACT_REQUEST
|
||||
}
|
||||
|
||||
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
|
||||
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
|
||||
val attrs = AudioAttributes.Builder()
|
||||
@@ -61,7 +58,7 @@ object NtfManager {
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.build()
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
Log.d(TAG, "callNotificationChannel sound: $soundUri")
|
||||
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
||||
callChannel.setSound(soundUri, attrs)
|
||||
callChannel.enableVibration(true)
|
||||
// the numbers below are explained here: https://developer.android.com/reference/android/os/Vibrator
|
||||
@@ -73,8 +70,8 @@ object NtfManager {
|
||||
fun cancelNotificationsForChat(chatId: String) {
|
||||
prevNtfTime.remove(chatId)
|
||||
manager.cancel(chatId.hashCode())
|
||||
val msgNtfs = manager.activeNotifications.filter { ntf ->
|
||||
ntf.notification.channelId == MessageChannel
|
||||
val msgNtfs = manager.activeNotifications.filter {
|
||||
ntf -> ntf.notification.channelId == MessageChannel
|
||||
}
|
||||
if (msgNtfs.count() == 1) {
|
||||
// Have a group notification with no children so cancel it
|
||||
@@ -87,7 +84,7 @@ object NtfManager {
|
||||
user = user,
|
||||
chatId = cInfo.id,
|
||||
displayName = cInfo.displayName,
|
||||
msgText = generalGetString(MR.strings.notification_new_contact_request),
|
||||
msgText = generalGetString(R.string.notification_new_contact_request),
|
||||
image = cInfo.image,
|
||||
listOf(NotificationAction.ACCEPT_CONTACT_REQUEST)
|
||||
)
|
||||
@@ -98,7 +95,7 @@ object NtfManager {
|
||||
user = user,
|
||||
chatId = contact.id,
|
||||
displayName = contact.displayName,
|
||||
msgText = generalGetString(MR.strings.notification_contact_connected)
|
||||
msgText = generalGetString(R.string.notification_contact_connected)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,13 +110,14 @@ object NtfManager {
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||
prevNtfTime[chatId] = now
|
||||
|
||||
val previewMode = appPreferences.notificationPreviewMode.get()
|
||||
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(MR.strings.notification_preview_somebody) else displayName
|
||||
val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(MR.strings.notification_preview_new_message) else msgText
|
||||
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(R.string.notification_preview_somebody) else displayName
|
||||
val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(R.string.notification_preview_new_message) else msgText
|
||||
val largeIcon = when {
|
||||
actions.isEmpty() -> null
|
||||
image == null || previewMode == NotificationPreviewMode.HIDDEN.name -> BitmapFactory.decodeResource(context.resources, R.drawable.icon)
|
||||
else -> base64ToBitmap(image).asAndroidBitmap()
|
||||
else -> base64ToBitmap(image)
|
||||
}
|
||||
val builder = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setContentTitle(title)
|
||||
@@ -143,10 +141,11 @@ object NtfManager {
|
||||
actionIntent.putExtra(ChatIdKey, chatId)
|
||||
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
|
||||
val actionButton = when (action) {
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(MR.strings.accept)
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(R.string.accept)
|
||||
}
|
||||
builder.addAction(0, actionButton, actionPendingIntent)
|
||||
}
|
||||
|
||||
val summary = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setColor(0x88FFFF)
|
||||
@@ -158,23 +157,20 @@ object NtfManager {
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
// using cInfo.id only shows one notification per chat and updates it when the message arrives
|
||||
if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
notify(chatId.hashCode(), builder.build())
|
||||
notify(0, summary)
|
||||
}
|
||||
notify(chatId.hashCode(), builder.build())
|
||||
notify(0, summary)
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyCallInvitation(invitation: RcvCallInvitation) {
|
||||
val keyguardManager = getKeyguardManager(context)
|
||||
Log.d(
|
||||
TAG,
|
||||
Log.d(TAG,
|
||||
"notifyCallInvitation pre-requests: " +
|
||||
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
|
||||
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
|
||||
"onForeground ${isAppOnForeground}"
|
||||
"onForeground ${SimplexApp.context.isAppOnForeground}"
|
||||
)
|
||||
if (isAppOnForeground) return
|
||||
if (SimplexApp.context.isAppOnForeground) return
|
||||
val contactId = invitation.contact.id
|
||||
Log.d(TAG, "notifyCallInvitation $contactId")
|
||||
val image = invitation.contact.image
|
||||
@@ -192,27 +188,27 @@ object NtfManager {
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.user.userId, invitation.contact.id))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(MR.strings.accept), chatPendingIntent(AcceptCallAction, invitation.user.userId, contactId))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(MR.strings.reject), chatPendingIntent(RejectCallAction, invitation.user.userId, contactId, true))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, invitation.user.userId, contactId))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, invitation.user.userId, contactId, true))
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setSound(soundUri)
|
||||
}
|
||||
val text = generalGetString(
|
||||
if (invitation.callType.media == CallMediaType.Video) {
|
||||
if (invitation.sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call
|
||||
if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||
} else {
|
||||
if (invitation.sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call
|
||||
if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||
}
|
||||
)
|
||||
val previewMode = appPreferences.notificationPreviewMode.get()
|
||||
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
|
||||
generalGetString(MR.strings.notification_preview_somebody)
|
||||
generalGetString(R.string.notification_preview_somebody)
|
||||
else
|
||||
invitation.contact.displayName
|
||||
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
|
||||
BitmapFactory.decodeResource(context.resources, R.drawable.icon)
|
||||
else
|
||||
base64ToBitmap(image).asAndroidBitmap()
|
||||
base64ToBitmap(image)
|
||||
|
||||
ntfBuilder = ntfBuilder
|
||||
.setContentTitle(title)
|
||||
@@ -227,9 +223,7 @@ object NtfManager {
|
||||
// This makes notification sound and vibration repeat endlessly
|
||||
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
notify(CallNotificationId, notification)
|
||||
}
|
||||
notify(CallNotificationId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,13 +231,9 @@ object NtfManager {
|
||||
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 {
|
||||
private fun hideSecrets(cItem: ChatItem) : String {
|
||||
val md = cItem.formattedText
|
||||
return if (md != null) {
|
||||
var res = ""
|
||||
@@ -282,8 +272,8 @@ object NtfManager {
|
||||
* old ones if needed
|
||||
* */
|
||||
fun createNtfChannelsMaybeShowAlert() {
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(MR.strings.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(MR.strings.ntf_channel_calls)))
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
|
||||
// Remove old channels since they can't be edited
|
||||
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
||||
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
||||
@@ -308,16 +298,14 @@ object NtfManager {
|
||||
}
|
||||
val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
|
||||
acceptContactRequest(apiId, cInfo, isCurrentUser, m)
|
||||
cancelNotificationsForChat(chatId)
|
||||
m.controller.ntfManager.cancelNotificationsForChat(chatId)
|
||||
}
|
||||
|
||||
RejectCallAction -> {
|
||||
val invitation = m.callInvitations[chatId]
|
||||
if (invitation != null) {
|
||||
m.callManager.endCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.ui.theme
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.ui.theme
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Shapes
|
||||
@@ -1,5 +1,7 @@
|
||||
package chat.simplex.common.ui.theme
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.*
|
||||
@@ -8,13 +10,13 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ChatController
|
||||
import chat.simplex.common.platform.isInNightMode
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import chat.simplex.res.MR
|
||||
import okhttp3.internal.toHexString
|
||||
|
||||
enum class DefaultTheme {
|
||||
SYSTEM, LIGHT, DARK, SIMPLEX;
|
||||
@@ -68,15 +70,15 @@ enum class ThemeColor {
|
||||
|
||||
val text: String
|
||||
get() = when (this) {
|
||||
PRIMARY -> generalGetString(MR.strings.color_primary)
|
||||
PRIMARY_VARIANT -> generalGetString(MR.strings.color_primary_variant)
|
||||
SECONDARY -> generalGetString(MR.strings.color_secondary)
|
||||
SECONDARY_VARIANT -> generalGetString(MR.strings.color_secondary_variant)
|
||||
BACKGROUND -> generalGetString(MR.strings.color_background)
|
||||
SURFACE -> generalGetString(MR.strings.color_surface)
|
||||
TITLE -> generalGetString(MR.strings.color_title)
|
||||
SENT_MESSAGE -> generalGetString(MR.strings.color_sent_message)
|
||||
RECEIVED_MESSAGE -> generalGetString(MR.strings.color_received_message)
|
||||
PRIMARY -> generalGetString(R.string.color_primary)
|
||||
PRIMARY_VARIANT -> generalGetString(R.string.color_primary_variant)
|
||||
SECONDARY -> generalGetString(R.string.color_secondary)
|
||||
SECONDARY_VARIANT -> generalGetString(R.string.color_secondary_variant)
|
||||
BACKGROUND -> generalGetString(R.string.color_background)
|
||||
SURFACE -> generalGetString(R.string.color_surface)
|
||||
TITLE -> generalGetString(R.string.color_title)
|
||||
SENT_MESSAGE -> generalGetString(R.string.color_sent_message)
|
||||
RECEIVED_MESSAGE -> generalGetString(R.string.color_received_message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +150,7 @@ data class ThemeColors(
|
||||
private fun String.colorFromReadableHex(): Color =
|
||||
Color(this.replace("#", "").toLongOrNull(16) ?: Color.White.toArgb().toLong())
|
||||
|
||||
private fun Color.toReadableHex(): String = "#" + Integer.toHexString(toArgb())
|
||||
private fun Color.toReadableHex(): String = "#" + toArgb().toHexString()
|
||||
|
||||
@Serializable
|
||||
data class ThemeOverrides (
|
||||
@@ -252,6 +254,10 @@ val SimplexColorPaletteApp = AppColors(
|
||||
|
||||
val CurrentColors: MutableStateFlow<ThemeManager.ActiveTheme> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
|
||||
|
||||
// Non-@Composable implementation
|
||||
private fun isInNightMode() =
|
||||
(SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
|
||||
|
||||
@Composable
|
||||
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.colors.isLight
|
||||
|
||||
@@ -264,7 +270,7 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
||||
}
|
||||
val systemDark = isSystemInDarkTheme()
|
||||
LaunchedEffect(systemDark) {
|
||||
if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == systemDark) {
|
||||
if (SimplexApp.context.chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == systemDark) {
|
||||
// Change active colors from light to dark and back based on system theme
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
package chat.simplex.common.ui.theme
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.material.Colors
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.common.model.AppPreferences
|
||||
import chat.simplex.common.model.ChatController
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
|
||||
// https://github.com/rsms/inter
|
||||
// I place it here because IDEA shows an error (but still works anyway) when this declaration inside Type.kt
|
||||
expect val Inter: FontFamily
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import okhttp3.internal.toHexString
|
||||
|
||||
object ThemeManager {
|
||||
private val appPrefs: AppPreferences = ChatController.appPrefs
|
||||
private val appPrefs: AppPreferences by lazy {
|
||||
SimplexApp.context.chatModel.controller.appPrefs
|
||||
}
|
||||
|
||||
data class ActiveTheme(val name: String, val base: DefaultTheme, val colors: Colors, val appColors: AppColors)
|
||||
|
||||
@@ -65,28 +63,28 @@ object ThemeManager {
|
||||
Triple(
|
||||
if (darkForSystemTheme) systemDarkThemeColors().first else LightColorPalette,
|
||||
DefaultTheme.SYSTEM,
|
||||
generalGetString(MR.strings.theme_system)
|
||||
generalGetString(R.string.theme_system)
|
||||
)
|
||||
)
|
||||
allThemes.add(
|
||||
Triple(
|
||||
LightColorPalette,
|
||||
DefaultTheme.LIGHT,
|
||||
generalGetString(MR.strings.theme_light)
|
||||
generalGetString(R.string.theme_light)
|
||||
)
|
||||
)
|
||||
allThemes.add(
|
||||
Triple(
|
||||
DarkColorPalette,
|
||||
DefaultTheme.DARK,
|
||||
generalGetString(MR.strings.theme_dark)
|
||||
generalGetString(R.string.theme_dark)
|
||||
)
|
||||
)
|
||||
allThemes.add(
|
||||
Triple(
|
||||
SimplexColorPalette,
|
||||
DefaultTheme.SIMPLEX,
|
||||
generalGetString(MR.strings.theme_simplex)
|
||||
generalGetString(R.string.theme_simplex)
|
||||
)
|
||||
)
|
||||
return allThemes
|
||||
@@ -151,4 +149,4 @@ object ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
private fun Color.toReadableHex(): String = "#" + Integer.toHexString(toArgb())
|
||||
private fun Color.toReadableHex(): String = "#" + toArgb().toHexString()
|
||||
@@ -1,9 +1,20 @@
|
||||
package chat.simplex.common.ui.theme
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.material.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
|
||||
// https://github.com/rsms/inter
|
||||
val Inter = FontFamily(
|
||||
Font(R.font.inter_regular),
|
||||
Font(R.font.inter_italic, style = FontStyle.Italic),
|
||||
Font(R.font.inter_bold, weight = FontWeight.Bold),
|
||||
Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold),
|
||||
Font(R.font.inter_medium, weight = FontWeight.Medium),
|
||||
Font(R.font.inter_light, weight = FontWeight.Light),
|
||||
)
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
@@ -22,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,
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@@ -14,7 +14,7 @@ fun SplashView() {
|
||||
color = MaterialTheme.colors.background
|
||||
) {
|
||||
// Image(
|
||||
// painter = painterResource(MR.images.logo),
|
||||
// painter = painterResource(R.drawable.logo),
|
||||
// contentDescription = "Simplex Icon",
|
||||
// modifier = Modifier
|
||||
// .height(230.dp)
|
||||
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.common.views
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
@@ -8,18 +9,19 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
|
||||
@Composable
|
||||
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||
@@ -27,12 +29,12 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||
BackHandler(onBack = {
|
||||
close()
|
||||
})
|
||||
TerminalLayout(
|
||||
remember { chatModel.terminalItems },
|
||||
composeState,
|
||||
sendCommand = { sendCommand(chatModel, composeState) },
|
||||
close
|
||||
)
|
||||
TerminalLayout(
|
||||
remember { chatModel.terminalItems },
|
||||
composeState,
|
||||
sendCommand = { sendCommand(chatModel, composeState) },
|
||||
close
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendCommand(chatModel: ChatModel, composeState: MutableState<ComposeState>) {
|
||||
@@ -84,7 +86,7 @@ fun TerminalLayout(
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = { sendCommand() },
|
||||
sendMessage = sendCommand,
|
||||
sendLiveMessage = null,
|
||||
updateLiveMessage = null,
|
||||
onMessageChange = ::onMessageChange,
|
||||
@@ -115,7 +117,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
}
|
||||
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
LazyColumn(state = listState, reverseLayout = true) {
|
||||
items(reversedTerminalItems) { item ->
|
||||
Text(
|
||||
@@ -126,7 +128,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
ModalManager.shared.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) {
|
||||
ModalManager.shared.showModal(endButtons = { ShareButton { shareText(context, item.details) } }) {
|
||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
|
||||
}
|
||||
@@ -137,11 +139,12 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewTerminalLayout() {
|
||||
SimpleXTheme {
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -14,22 +14,23 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.Profile
|
||||
import chat.simplex.common.platform.navigationBarsWithImePadding
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.onboarding.ReadableText
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Profile
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@@ -53,19 +54,19 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||
close()
|
||||
}
|
||||
})*/
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING)
|
||||
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1)
|
||||
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
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(MR.strings.display_name),
|
||||
stringResource(R.string.display_name),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
if (!isValidDisplayName(displayName.value)) {
|
||||
Text(
|
||||
stringResource(MR.strings.no_spaces),
|
||||
stringResource(R.string.no_spaces),
|
||||
fontSize = 16.sp,
|
||||
color = Color.Red
|
||||
)
|
||||
@@ -74,18 +75,18 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Text(
|
||||
stringResource(MR.strings.full_name_optional__prompt),
|
||||
stringResource(R.string.full_name_optional__prompt),
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
ProfileNameField(fullName, "")
|
||||
ProfileNameField(fullName, "", ::isValidDisplayName)
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
if (chatModel.users.isEmpty()) {
|
||||
SimpleButtonDecorated(
|
||||
text = stringResource(MR.strings.about_simplex),
|
||||
icon = painterResource(MR.images.ic_arrow_back_ios_new),
|
||||
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 }
|
||||
@@ -103,8 +104,8 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
||||
Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
|
||||
Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = createColor)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,17 +125,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()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package chat.simplex.common.views.call
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.withApi
|
||||
import android.util.Log
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@@ -14,16 +16,17 @@ class CallManager(val chatModel: ChatModel) {
|
||||
if (invitation.user.showNotifications) {
|
||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||
activeCallInvitation.value = invitation
|
||||
ntfManager.notifyCallInvitation(invitation)
|
||||
controller.ntfManager.notifyCallInvitation(invitation)
|
||||
} else {
|
||||
val contact = invitation.contact
|
||||
ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||
controller.ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptIncomingCall(invitation: RcvCallInvitation) {
|
||||
ModalManager.shared.closeModals()
|
||||
val call = chatModel.activeCall.value
|
||||
if (call == null) {
|
||||
justAcceptIncomingCall(invitation = invitation)
|
||||
@@ -61,7 +64,7 @@ class CallManager(val chatModel: ChatModel) {
|
||||
callInvitations.remove(invitation.contact.id)
|
||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||
activeCallInvitation.value = null
|
||||
ntfManager.cancelCallNotification()
|
||||
controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +90,7 @@ class CallManager(val chatModel: ChatModel) {
|
||||
callInvitations.remove(invitation.contact.id)
|
||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||
activeCallInvitation.value = null
|
||||
ntfManager.cancelCallNotification()
|
||||
controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
withApi {
|
||||
if (!controller.apiRejectCall(invitation.contact)) {
|
||||
@@ -100,7 +103,7 @@ class CallManager(val chatModel: ChatModel) {
|
||||
fun reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
||||
if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
ntfManager.cancelCallNotification()
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.call
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
@@ -9,9 +9,11 @@ import android.media.*
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.*
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -22,46 +24,44 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import androidx.webkit.WebViewClientCompat
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.ProfileImage
|
||||
import chat.simplex.common.views.helpers.withApi
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.Contact
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
actual fun ActiveCallView() {
|
||||
val chatModel = ChatModel
|
||||
fun ActiveCallView(chatModel: ChatModel) {
|
||||
BackHandler(onBack = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||
})
|
||||
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE }
|
||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
|
||||
LaunchedEffect(Unit) {
|
||||
// Start service when call happening since it's not already started.
|
||||
// It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off
|
||||
if (!ntfModeService) platform.androidServiceStart()
|
||||
if (!ntfModeService) SimplexService.start(SimplexApp.context)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
var btDeviceCount = 0
|
||||
val audioCallback = object: AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||
@@ -88,16 +88,16 @@ actual fun ActiveCallView() {
|
||||
}
|
||||
}
|
||||
am.registerAudioDeviceCallback(audioCallback, null)
|
||||
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
val pm = (SimplexApp.context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
|
||||
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, SimplexApp.context.packageName + ":proximityLock")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
proximityLock?.acquire()
|
||||
onDispose {
|
||||
// Stop it when call ended
|
||||
if (!ntfModeService) platform.androidServiceSafeStop()
|
||||
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
||||
dropAudioManagerOverrides()
|
||||
am.unregisterAudioDeviceCallback(audioCallback)
|
||||
proximityLock?.release()
|
||||
@@ -185,12 +185,10 @@ actual fun ActiveCallView() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,7 +214,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
|
||||
}
|
||||
|
||||
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
|
||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -242,7 +240,7 @@ private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boole
|
||||
}
|
||||
|
||||
private fun dropAudioManagerOverrides() {
|
||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.mode = AudioManager.MODE_NORMAL
|
||||
// Clear selected communication device to default value after we changed it in call
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -272,14 +270,14 @@ private fun ActiveCallOverlayLayout(
|
||||
ToggleAudioButton(call, toggleAudio)
|
||||
Spacer(Modifier.size(40.dp))
|
||||
IconButton(onClick = dismiss) {
|
||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
Icon(painterResource(R.drawable.ic_call_end_filled), stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
if (call.videoEnabled) {
|
||||
ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, flipCamera)
|
||||
ControlButton(call, painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, toggleVideo)
|
||||
ControlButton(call, painterResource(R.drawable.ic_flip_camera_android_filled), R.string.icon_descr_flip_camera, flipCamera)
|
||||
ControlButton(call, painterResource(R.drawable.ic_videocam_filled), R.string.icon_descr_video_off, toggleVideo)
|
||||
} else {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
ControlButton(call, painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, toggleVideo)
|
||||
ControlButton(call, painterResource(R.drawable.ic_videocam_off), R.string.icon_descr_video_on, toggleVideo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,7 +295,7 @@ private fun ActiveCallOverlayLayout(
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
IconButton(onClick = dismiss) {
|
||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
Icon(painterResource(R.drawable.ic_call_end_filled), stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(start = 32.dp)) {
|
||||
@@ -315,7 +313,7 @@ private fun ActiveCallOverlayLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, action: () -> Unit, enabled: Boolean = true) {
|
||||
private fun ControlButton(call: Call, icon: Painter, @StringRes iconText: Int, action: () -> Unit, enabled: Boolean = true) {
|
||||
if (call.hasMedia) {
|
||||
IconButton(onClick = action, enabled = enabled) {
|
||||
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
|
||||
@@ -328,18 +326,18 @@ private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, a
|
||||
@Composable
|
||||
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
||||
if (call.audioEnabled) {
|
||||
ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, toggleAudio)
|
||||
ControlButton(call, painterResource(R.drawable.ic_mic), R.string.icon_descr_audio_off, toggleAudio)
|
||||
} else {
|
||||
ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, toggleAudio)
|
||||
ControlButton(call, painterResource(R.drawable.ic_mic_off), R.string.icon_descr_audio_on, toggleAudio)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
|
||||
if (call.soundSpeaker) {
|
||||
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, toggleSound, enabled)
|
||||
ControlButton(call, painterResource(R.drawable.ic_volume_up), R.string.icon_descr_speaker_off, toggleSound, enabled)
|
||||
} else {
|
||||
ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, toggleSound, enabled)
|
||||
ControlButton(call, painterResource(R.drawable.ic_volume_down), R.string.icon_descr_speaker_on, toggleSound, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +350,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
InfoText(call.callState.text)
|
||||
|
||||
val connInfo = call.connectionInfo
|
||||
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
||||
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
||||
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
|
||||
InfoText(call.encryptionStatus + connInfoText)
|
||||
}
|
||||
@@ -4,14 +4,16 @@ import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import chat.simplex.common.platform.Log
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@@ -23,26 +25,25 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.app.model.NtfManager.OpenChatAction
|
||||
import chat.simplex.common.platform.ntfManager
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class IncomingCallActivity: ComponentActivity() {
|
||||
private val vm by viewModels<SimplexViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent { IncomingCallActivityView(ChatModel) }
|
||||
setContent { IncomingCallActivityView(vm.chatModel) }
|
||||
unlockForIncomingCall()
|
||||
}
|
||||
|
||||
@@ -101,7 +102,7 @@ fun IncomingCallActivityView(m: ChatModel) {
|
||||
) {
|
||||
if (showCallView) {
|
||||
Box {
|
||||
ActiveCallView()
|
||||
ActiveCallView(m)
|
||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||
}
|
||||
} else if (invitation != null) {
|
||||
@@ -119,7 +120,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
|
||||
ntfManager.cancelCallNotification()
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
@@ -129,7 +130,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
ntfManager.cancelCallNotification()
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
},
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||
openApp = {
|
||||
@@ -169,18 +170,18 @@ fun IncomingCallLockScreenAlertLayout(
|
||||
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
LockScreenCallButton(stringResource(MR.strings.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||
LockScreenCallButton(stringResource(R.string.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
LockScreenCallButton(stringResource(MR.strings.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||
LockScreenCallButton(stringResource(R.string.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
LockScreenCallButton(stringResource(MR.strings.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
||||
LockScreenCallButton(stringResource(R.string.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
||||
}
|
||||
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
||||
SimpleXLogo()
|
||||
Text(stringResource(MR.strings.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
||||
Text(stringResource(MR.strings.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
||||
Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
||||
Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
SimpleButton(text = stringResource(MR.strings.open_verb), icon = painterResource(R.drawable.ic_check_filled), click = openApp)
|
||||
SimpleButton(text = stringResource(R.string.open_verb), icon = painterResource(R.drawable.ic_check_filled), click = openApp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,7 +190,7 @@ fun IncomingCallLockScreenAlertLayout(
|
||||
private fun SimpleXLogo() {
|
||||
Image(
|
||||
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
||||
contentDescription = stringResource(MR.strings.image_descr_simplex_logo),
|
||||
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||
modifier = Modifier
|
||||
.padding(vertical = DEFAULT_PADDING)
|
||||
.fillMaxWidth(0.80f)
|
||||
@@ -217,10 +218,10 @@ private fun LockScreenCallButton(text: String, icon: Painter, color: Color, acti
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewIncomingCallLockScreenAlert() {
|
||||
SimpleXTheme(true) {
|
||||
@@ -1,6 +1,5 @@
|
||||
package chat.simplex.common.views.call
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -11,31 +10,33 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
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 chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.ProfileImage
|
||||
import chat.simplex.common.views.usersettings.ProfilePreview
|
||||
import chat.simplex.common.platform.ntfManager
|
||||
import chat.simplex.common.platform.SoundPlayer
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.usersettings.ProfilePreview
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
val cm = chatModel.callManager
|
||||
val cxt = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(true) { SoundPlayer.start(scope, sound = !chatModel.showCallView.value) }
|
||||
DisposableEffect(true) { onDispose { SoundPlayer.stop() } }
|
||||
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = !chatModel.showCallView.value) }
|
||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||
IncomingCallAlertLayout(
|
||||
invitation,
|
||||
chatModel,
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
ntfManager.cancelCallNotification()
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
},
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
||||
)
|
||||
@@ -58,9 +59,9 @@ fun IncomingCallAlertLayout(
|
||||
ProfilePreview(profileOf = invitation.contact, size = 64.dp)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CallButton(stringResource(MR.strings.reject), painterResource(MR.images.ic_call_end_filled), Color.Red, rejectCall)
|
||||
CallButton(stringResource(MR.strings.ignore), painterResource(MR.images.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||
CallButton(stringResource(MR.strings.accept), painterResource(MR.images.ic_check_filled), SimplexGreen, acceptCall)
|
||||
CallButton(stringResource(R.string.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||
CallButton(stringResource(R.string.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||
CallButton(stringResource(R.string.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,8 +75,8 @@ fun IncomingCallInfo(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
if (invitation.callType.media == CallMediaType.Video) CallIcon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call))
|
||||
else CallIcon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call))
|
||||
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)
|
||||
}
|
||||
@@ -112,7 +113,7 @@ fun PreviewIncomingCallAlertLayout() {
|
||||
sharedKey = null,
|
||||
callTs = Clock.System.now()
|
||||
),
|
||||
chatModel = ChatModel,
|
||||
chatModel = SimplexApp.context.chatModel,
|
||||
rejectCall = {},
|
||||
ignoreCall = {},
|
||||
acceptCall = {}
|
||||
@@ -1,22 +1,22 @@
|
||||
package chat.simplex.common.helpers
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.content.Context
|
||||
import android.media.*
|
||||
import android.net.Uri
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.common.R
|
||||
import chat.simplex.common.platform.SoundPlayerInterface
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.views.helpers.withScope
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.views.helpers.withScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
object SoundPlayer: SoundPlayerInterface {
|
||||
class SoundPlayer {
|
||||
private var player: MediaPlayer? = null
|
||||
var playing = false
|
||||
|
||||
override fun start(scope: CoroutineScope, sound: Boolean) {
|
||||
fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) {
|
||||
player?.reset()
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
@@ -25,10 +25,10 @@ object SoundPlayer: SoundPlayerInterface {
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.build()
|
||||
)
|
||||
setDataSource(androidAppContext, Uri.parse("android.resource://" + androidAppContext.packageName + "/" + R.raw.ring_once))
|
||||
setDataSource(SimplexApp.context, Uri.parse("android.resource://" + SimplexApp.context.packageName + "/" + R.raw.ring_once))
|
||||
prepare()
|
||||
}
|
||||
val vibrator = ContextCompat.getSystemService(androidAppContext, Vibrator::class.java)
|
||||
val vibrator = ContextCompat.getSystemService(cxt, Vibrator::class.java)
|
||||
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
playing = true
|
||||
withScope(scope) {
|
||||
@@ -40,8 +40,12 @@ object SoundPlayer: SoundPlayerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
fun stop() {
|
||||
playing = false
|
||||
player?.stop()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared = SoundPlayer()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package chat.simplex.common.views.call
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.res.MR
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.toUpperCase
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.Contact
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -30,9 +33,9 @@ data class Call(
|
||||
|
||||
val encryptionStatus: String @Composable get() = when(callState) {
|
||||
CallState.WaitCapabilities -> ""
|
||||
CallState.InvitationSent -> stringResource(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
|
||||
CallState.InvitationAccepted -> stringResource(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
|
||||
else -> stringResource(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
|
||||
CallState.InvitationSent -> stringResource(if (localEncrypted) R.string.status_e2e_encrypted else R.string.status_no_e2e_encryption)
|
||||
CallState.InvitationAccepted -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption)
|
||||
else -> stringResource(if (!localEncrypted) R.string.status_no_e2e_encryption else if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_e2e_encrypted)
|
||||
}
|
||||
|
||||
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
|
||||
@@ -50,15 +53,15 @@ enum class CallState {
|
||||
Ended;
|
||||
|
||||
val text: String @Composable get() = when(this) {
|
||||
WaitCapabilities -> stringResource(MR.strings.callstate_starting)
|
||||
InvitationSent -> stringResource(MR.strings.callstate_waiting_for_answer)
|
||||
InvitationAccepted -> stringResource(MR.strings.callstate_starting)
|
||||
OfferSent -> stringResource(MR.strings.callstate_waiting_for_confirmation)
|
||||
OfferReceived -> stringResource(MR.strings.callstate_received_answer)
|
||||
AnswerReceived -> stringResource(MR.strings.callstate_received_confirmation)
|
||||
Negotiated -> stringResource(MR.strings.callstate_connecting)
|
||||
Connected -> stringResource(MR.strings.callstate_connected)
|
||||
Ended -> stringResource(MR.strings.callstate_ended)
|
||||
WaitCapabilities -> stringResource(R.string.callstate_starting)
|
||||
InvitationSent -> stringResource(R.string.callstate_waiting_for_answer)
|
||||
InvitationAccepted -> stringResource(R.string.callstate_starting)
|
||||
OfferSent -> stringResource(R.string.callstate_waiting_for_confirmation)
|
||||
OfferReceived -> stringResource(R.string.callstate_received_answer)
|
||||
AnswerReceived -> stringResource(R.string.callstate_received_confirmation)
|
||||
Negotiated -> stringResource(R.string.callstate_connecting)
|
||||
Connected -> stringResource(R.string.callstate_connected)
|
||||
Ended -> stringResource(R.string.callstate_ended)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,12 +99,12 @@ sealed class WCallResponse {
|
||||
@Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
||||
@Serializable data class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
|
||||
val callTypeText: String get() = generalGetString(when(callType.media) {
|
||||
CallMediaType.Video -> if (sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call
|
||||
CallMediaType.Audio -> if (sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call
|
||||
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||
})
|
||||
val callTitle: String get() = generalGetString(when(callType.media) {
|
||||
CallMediaType.Video -> MR.strings.incoming_video_call
|
||||
CallMediaType.Audio -> MR.strings.incoming_audio_call
|
||||
CallMediaType.Video -> R.string.incoming_video_call
|
||||
CallMediaType.Audio -> R.string.incoming_audio_call
|
||||
})
|
||||
}
|
||||
@Serializable data class CallCapabilities(val encryption: Boolean)
|
||||
@@ -111,9 +114,9 @@ sealed class WCallResponse {
|
||||
val remote = remoteCandidate?.candidateType
|
||||
return when {
|
||||
local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host ->
|
||||
stringResource(MR.strings.call_connection_peer_to_peer)
|
||||
stringResource(R.string.call_connection_peer_to_peer)
|
||||
local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay ->
|
||||
stringResource(MR.strings.call_connection_via_relay)
|
||||
stringResource(R.string.call_connection_via_relay)
|
||||
else ->
|
||||
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
|
||||
}
|
||||
@@ -212,7 +215,7 @@ fun parseRTCIceServers(servers: List<String>): List<RTCIceServer>? {
|
||||
}
|
||||
|
||||
fun getIceServers(): List<RTCIceServer>? {
|
||||
val value = ChatController.appPrefs.webrtcIceServers.get() ?: return null
|
||||
val value = SimplexApp.context.chatController.appPrefs.webrtcIceServers.get() ?: return null
|
||||
val servers: List<String> = value.split("\n")
|
||||
return parseRTCIceServers(servers)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import InfoRow
|
||||
import InfoRowEllipsis
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionItemViewWithIcon
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import TextIconSpaced
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
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.platform.*
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.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.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatInfoView(
|
||||
chatModel: ChatModel,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
connectionCode: String?,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
if (chat != null) {
|
||||
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
|
||||
mutableStateOf(chatModel.contactNetworkStatus(contact))
|
||||
}
|
||||
ChatInfoLayout(
|
||||
chat,
|
||||
contact,
|
||||
connStats,
|
||||
contactNetworkStatus.value,
|
||||
customUserProfile,
|
||||
localAlias,
|
||||
connectionCode,
|
||||
developerTools,
|
||||
onLocalAliasChanged = {
|
||||
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
||||
},
|
||||
openPreferences = {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
ContactPreferencesView(chatModel, user, contact.contactId, close)
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
switchContactAddress = {
|
||||
showSwitchContactAddressAlert(chatModel, contact.contactId)
|
||||
},
|
||||
verifyClicked = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
|
||||
VerifyCodeView(
|
||||
ct.displayName,
|
||||
connectionCode,
|
||||
ct.verified,
|
||||
verify = { code ->
|
||||
chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r ->
|
||||
val (verified, existingCode) = r
|
||||
chatModel.updateContact(
|
||||
ct.copy(
|
||||
activeConn = ct.activeConn.copy(
|
||||
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||
)
|
||||
)
|
||||
)
|
||||
r
|
||||
}
|
||||
},
|
||||
close,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.delete_contact_question),
|
||||
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (r) {
|
||||
chatModel.removeChat(chatInfo.id)
|
||||
chatModel.chatId.value = null
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
},
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.clear_chat_question),
|
||||
text = generalGetString(R.string.clear_chat_warning),
|
||||
confirmText = generalGetString(R.string.clear_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (updatedChatInfo != null) {
|
||||
chatModel.clearChat(updatedChatInfo)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
},
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoLayout(
|
||||
chat: Chat,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
contactNetworkStatus: NetworkStatus,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
connectionCode: String?,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
openPreferences: () -> Unit,
|
||||
deleteContact: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
switchContactAddress: () -> Unit,
|
||||
verifyClicked: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
ChatInfoHeader(chat.chatInfo, contact)
|
||||
}
|
||||
|
||||
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
|
||||
SectionSpacer()
|
||||
if (customUserProfile != null) {
|
||||
SectionView(generalGetString(R.string.incognito).uppercase()) {
|
||||
InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
SectionView {
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(contact.verified, verifyClicked)
|
||||
}
|
||||
ContactPreferencesButton(openPreferences)
|
||||
}
|
||||
|
||||
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)) {
|
||||
SwitchAddressButton(switchContactAddress)
|
||||
if (connStats != null) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.network_status),
|
||||
contactNetworkStatus.statusExplanation
|
||||
)}) {
|
||||
NetworkStatusRow(contactNetworkStatus)
|
||||
}
|
||||
val rcvServers = connStats.rcvServers
|
||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||
}
|
||||
val sndServers = connStats.sndServers
|
||||
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
SectionView {
|
||||
ClearChatButton(clearChat)
|
||||
DeleteContactButton(deleteContact)
|
||||
}
|
||||
|
||||
if (developerTools) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
|
||||
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (contact.verified) {
|
||||
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
Text(
|
||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LocalAliasEditor(
|
||||
initialValue: String,
|
||||
center: Boolean = true,
|
||||
leadingIcon: Boolean = false,
|
||||
focus: Boolean = false,
|
||||
updateValue: (String) -> Unit
|
||||
) {
|
||||
var value by rememberSaveable { mutableStateOf(initialValue) }
|
||||
val modifier = if (center)
|
||||
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp)
|
||||
else
|
||||
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).fillMaxWidth()
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (center) Arrangement.Center else Arrangement.Start) {
|
||||
DefaultBasicTextField(
|
||||
modifier,
|
||||
value,
|
||||
{
|
||||
Text(
|
||||
generalGetString(R.string.text_field_set_contact_placeholder),
|
||||
textAlign = if (center) TextAlign.Center else TextAlign.Start,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
},
|
||||
leadingIcon = if (leadingIcon) {
|
||||
{ Icon(painterResource(R.drawable.ic_edit_filled), null, Modifier.padding(start = 7.dp)) }
|
||||
} else null,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
focus = focus,
|
||||
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
|
||||
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
|
||||
) {
|
||||
value = it
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { value }
|
||||
.onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing
|
||||
.conflate() // get the latest value
|
||||
.filter { it == value } // don't process old ones
|
||||
.collect {
|
||||
updateValue(value)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.network_status))
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_info),
|
||||
stringResource(R.string.network_status),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
networkStatus.statusString,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
ServerImage(networkStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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 = 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 ->
|
||||
Icon(painterResource(R.drawable.ic_error_filled), stringResource(R.string.icon_descr_server_status_error), tint = MaterialTheme.colors.secondary)
|
||||
else -> Icon(painterResource(R.drawable.ic_circle), stringResource(R.string.icon_descr_server_status_pending), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimplexServers(text: String, servers: List<String>) {
|
||||
val info = servers.joinToString(separator = ", ") { it.substringAfter("@") }
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
InfoRowEllipsis(text, info) {
|
||||
clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ",")))
|
||||
Toast.makeText(SimplexApp.context, generalGetString(R.string.copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwitchAddressButton(onClick: () -> Unit) {
|
||||
SectionItemView(onClick) {
|
||||
Text(stringResource(R.string.switch_receiving_address), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
if (contactVerified) painterResource(R.drawable.ic_verified_user) else painterResource(R.drawable.ic_shield),
|
||||
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
|
||||
click = onClick,
|
||||
iconColor = MaterialTheme.colors.secondary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(R.drawable.ic_toggle_on),
|
||||
stringResource(R.string.contact_preferences),
|
||||
click = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearChatButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(R.drawable.ic_settings_backup_restore),
|
||||
stringResource(R.string.clear_chat_button),
|
||||
click = onClick,
|
||||
textColor = WarningOrange,
|
||||
iconColor = WarningOrange,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteContactButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(R.drawable.ic_delete),
|
||||
stringResource(R.string.button_delete_contact),
|
||||
click = onClick,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
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.switch_verb),
|
||||
onConfirm = {
|
||||
switchContactAddress(m, contactId)
|
||||
},
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun switchContactAddress(m: ChatModel, contactId: Long) = withApi {
|
||||
m.controller.apiSwitchContact(contactId)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatInfoLayout() {
|
||||
SimpleXTheme {
|
||||
ChatInfoLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = arrayListOf()
|
||||
),
|
||||
Contact.sampleData,
|
||||
localAlias = "",
|
||||
connectionCode = "123",
|
||||
developerTools = false,
|
||||
connStats = null,
|
||||
contactNetworkStatus = NetworkStatus.Connected(),
|
||||
onLocalAliasChanged = {},
|
||||
customUserProfile = null,
|
||||
openPreferences = {},
|
||||
deleteContact = {},
|
||||
clearChat = {},
|
||||
switchContactAddress = {},
|
||||
verifyClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package chat.simplex.common.views.chat
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -16,30 +20,31 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.group.*
|
||||
import chat.simplex.common.views.chat.item.*
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.GroupInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.platform.AudioPlayer
|
||||
import chat.simplex.res.MR
|
||||
import androidx.core.content.FileProvider
|
||||
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.call.*
|
||||
import chat.simplex.app.views.chat.group.*
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.chatlist.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.AppBarHeight
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.Clock
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import kotlin.math.sign
|
||||
|
||||
@Composable
|
||||
@@ -95,7 +100,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
.collect { activeChat.value = it }
|
||||
}
|
||||
}
|
||||
val view = LocalMultiplatformView()
|
||||
val view = LocalView.current
|
||||
if (activeChat.value == null || user == null) {
|
||||
chatModel.chatId.value = null
|
||||
} else {
|
||||
@@ -107,7 +112,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
|
||||
}
|
||||
}
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
ChatLayout(
|
||||
chat,
|
||||
@@ -161,8 +165,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
|
||||
hideKeyboard(view)
|
||||
withApi {
|
||||
val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
val stats = r?.second
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
val (_, code) = if (member.memberActive) {
|
||||
try {
|
||||
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
|
||||
@@ -255,73 +258,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
chatModel.controller.allowFeatureToContact(contact, feature, param)
|
||||
}
|
||||
},
|
||||
updateContactStats = { contact ->
|
||||
withApi {
|
||||
val r = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
|
||||
if (r != null) {
|
||||
chatModel.updateContactConnectionStats(contact, r.first)
|
||||
}
|
||||
}
|
||||
},
|
||||
updateMemberStats = { groupInfo, member ->
|
||||
withApi {
|
||||
val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
if (r != null) {
|
||||
val memStats = r.second
|
||||
if (memStats != null) {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, memStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
syncContactConnection = { contact ->
|
||||
withApi {
|
||||
val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false)
|
||||
if (cStats != null) {
|
||||
chatModel.updateContactConnectionStats(contact, cStats)
|
||||
}
|
||||
}
|
||||
},
|
||||
syncMemberConnection = { groupInfo, member ->
|
||||
withApi {
|
||||
val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false)
|
||||
if (r != null) {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second)
|
||||
}
|
||||
}
|
||||
},
|
||||
findModelChat = { chatId ->
|
||||
chatModel.getChat(chatId)
|
||||
},
|
||||
findModelMember = { memberId ->
|
||||
chatModel.groupMembers.find { it.id == memberId }
|
||||
},
|
||||
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 {
|
||||
clipboard.shareText(itemInfoShareText(cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get()))
|
||||
} }) {
|
||||
ChatItemInfoView(cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
addMembers = { groupInfo ->
|
||||
hideKeyboard(view)
|
||||
withApi {
|
||||
@@ -333,7 +269,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
},
|
||||
markRead = { range, unreadCountAfter ->
|
||||
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
|
||||
ntfManager.cancelNotificationsForChat(chat.id)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
withBGApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
@@ -342,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
|
||||
@@ -380,14 +316,6 @@ fun ChatLayout(
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||
updateContactStats: (Contact) -> Unit,
|
||||
updateMemberStats: (GroupInfo, GroupMember) -> Unit,
|
||||
syncContactConnection: (Contact) -> Unit,
|
||||
syncMemberConnection: (GroupInfo, GroupMember) -> Unit,
|
||||
findModelChat: (String) -> Chat?,
|
||||
findModelMember: (String) -> GroupMember?,
|
||||
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,
|
||||
@@ -423,16 +351,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,
|
||||
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
|
||||
setReaction, showItemDetails, markRead, setFloatingButton, onComposed,
|
||||
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -466,7 +389,7 @@ fun ChatInfoToolbar(
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
val menuItems = arrayListOf<@Composable () -> Unit>()
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
|
||||
ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), painterResource(R.drawable.ic_search), onClick = {
|
||||
showMenu.value = false
|
||||
showSearch = true
|
||||
})
|
||||
@@ -478,11 +401,11 @@ fun ChatInfoToolbar(
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Audio)
|
||||
}) {
|
||||
Icon(painterResource(MR.images.ic_call_500), stringResource(MR.strings.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
Icon(painterResource(R.drawable.ic_call_500), stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = {
|
||||
ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), painterResource(R.drawable.ic_videocam), onClick = {
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Video)
|
||||
})
|
||||
@@ -493,15 +416,15 @@ fun ChatInfoToolbar(
|
||||
showMenu.value = false
|
||||
addMembers(chat.chatInfo.groupInfo)
|
||||
}) {
|
||||
Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary)
|
||||
Icon(painterResource(R.drawable.ic_person_add_500), stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
|
||||
menuItems.add {
|
||||
ItemAction(
|
||||
if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat),
|
||||
if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications),
|
||||
if (ntfsEnabled.value) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
||||
if (ntfsEnabled.value) painterResource(R.drawable.ic_notifications_off) else painterResource(R.drawable.ic_notifications),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
// Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu
|
||||
@@ -515,7 +438,7 @@ fun ChatInfoToolbar(
|
||||
|
||||
barButtons.add {
|
||||
IconButton({ showMenu.value = true }) {
|
||||
Icon(MoreVertFilled, stringResource(MR.strings.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
Icon(MoreVertFilled, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,7 +495,7 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
||||
|
||||
@Composable
|
||||
private fun ContactVerifiedShield() {
|
||||
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
|
||||
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
|
||||
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
|
||||
@@ -605,14 +528,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||
updateContactStats: (Contact) -> Unit,
|
||||
updateMemberStats: (GroupInfo, GroupMember) -> Unit,
|
||||
syncContactConnection: (Contact) -> Unit,
|
||||
syncMemberConnection: (GroupInfo, GroupMember) -> Unit,
|
||||
findModelChat: (String) -> Chat?,
|
||||
findModelMember: (String) -> GroupMember?,
|
||||
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
|
||||
showItemDetails: (ChatInfo, ChatItem) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||
onComposed: () -> Unit,
|
||||
@@ -652,8 +567,8 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
.distinctUntilChanged()
|
||||
.filter { !stopListening }
|
||||
.collect {
|
||||
onComposed()
|
||||
stopListening = true
|
||||
onComposed()
|
||||
stopListening = true
|
||||
}
|
||||
}
|
||||
DisposableEffectOnGone(
|
||||
@@ -700,43 +615,47 @@ 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) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
showMemberInfo(chat.chatInfo.groupInfo, member)
|
||||
}
|
||||
) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId == null) {
|
||||
MemberImage(member)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
showMemberInfo(chat.chatInfo.groupInfo, member)
|
||||
}
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
} 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, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, 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, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, 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, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -874,8 +793,8 @@ fun BoxWithConstraintsScope.FloatingButtons(
|
||||
|
||||
DefaultDropdownMenu(showDropDown, offset = DpOffset(maxWidth - DEFAULT_PADDING, 24.dp + fabSize)) {
|
||||
ItemAction(
|
||||
generalGetString(MR.strings.mark_read),
|
||||
painterResource(MR.images.ic_check),
|
||||
generalGetString(R.string.mark_read),
|
||||
painterResource(R.drawable.ic_check),
|
||||
onClick = {
|
||||
markRead(
|
||||
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
|
||||
@@ -982,7 +901,7 @@ private fun bottomEndFloatingButton(
|
||||
backgroundColor = MaterialTheme.colors.secondaryVariant,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(MR.images.ic_keyboard_arrow_down),
|
||||
painter = painterResource(R.drawable.ic_keyboard_arrow_down),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
@@ -1011,8 +930,8 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
|
||||
}
|
||||
|
||||
sealed class ProviderMedia {
|
||||
data class Image(val uri: URI, val image: ImageBitmap): ProviderMedia()
|
||||
data class Video(val uri: URI, val preview: String): ProviderMedia()
|
||||
data class Image(val uri: Uri, val image: Bitmap): ProviderMedia()
|
||||
data class Video(val uri: Uri, val preview: String): ProviderMedia()
|
||||
}
|
||||
|
||||
private fun providerForGallery(
|
||||
@@ -1022,7 +941,7 @@ private fun providerForGallery(
|
||||
scrollTo: (Int) -> Unit
|
||||
): ImageGalleryProvider {
|
||||
fun canShowMedia(item: ChatItem): Boolean =
|
||||
(item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(item.file) != null)
|
||||
(item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null)
|
||||
|
||||
fun item(skipInternalIndex: Int, initialChatId: Long): Pair<Int, ChatItem>? {
|
||||
var processedInternalIndex = -skipInternalIndex.sign
|
||||
@@ -1049,17 +968,17 @@ private fun providerForGallery(
|
||||
val item = item(internalIndex, initialChatId)?.second ?: return null
|
||||
return when (item.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
val imageBitmap: ImageBitmap? = getLoadedImage(item.file)
|
||||
val filePath = getLoadedFilePath(item.file)
|
||||
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, item.file)
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, item.file)
|
||||
if (imageBitmap != null && filePath != null) {
|
||||
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
ProviderMedia.Image(uri, imageBitmap)
|
||||
} else null
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
val filePath = getLoadedFilePath(item.file)
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, item.file)
|
||||
if (filePath != null) {
|
||||
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image)
|
||||
} else null
|
||||
}
|
||||
@@ -1103,11 +1022,12 @@ private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConf
|
||||
override val touchSlop: Float get() = slop
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatLayout() {
|
||||
SimpleXTheme {
|
||||
@@ -1158,14 +1078,6 @@ fun PreviewChatLayout() {
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
acceptFeature = { _, _, _ -> },
|
||||
updateContactStats = { },
|
||||
updateMemberStats = { _, _ -> },
|
||||
syncContactConnection = { },
|
||||
syncMemberConnection = { _, _ -> },
|
||||
findModelChat = { null },
|
||||
findModelMember = { null },
|
||||
setReaction = { _, _, _, _ -> },
|
||||
showItemDetails = { _, _ -> },
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
changeNtfsState = { _, _ -> },
|
||||
@@ -1175,7 +1087,7 @@ fun PreviewChatLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewGroupChatLayout() {
|
||||
SimpleXTheme {
|
||||
@@ -1226,14 +1138,6 @@ fun PreviewGroupChatLayout() {
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
acceptFeature = { _, _, _ -> },
|
||||
updateContactStats = { },
|
||||
updateMemberStats = { _, _ -> },
|
||||
syncContactConnection = { },
|
||||
syncMemberConnection = { _, _ -> },
|
||||
findModelChat = { null },
|
||||
findModelMember = { null },
|
||||
setReaction = { _, _, _, _ -> },
|
||||
showItemDetails = { _, _ -> },
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
changeNtfsState = { _, _ -> },
|
||||
@@ -1,6 +1,3 @@
|
||||
package chat.simplex.common.views.chat
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@@ -8,11 +5,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
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 chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
|
||||
@@ -26,8 +24,8 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_draft_filled),
|
||||
stringResource(MR.strings.icon_descr_file),
|
||||
painterResource(R.drawable.ic_draft_filled),
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
@@ -38,8 +36,8 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
|
||||
if (cancelEnabled) {
|
||||
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_close),
|
||||
contentDescription = stringResource(MR.strings.icon_descr_cancel_file_preview),
|
||||
painterResource(R.drawable.ic_close),
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -10,13 +10,14 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.platform.base64ToBitmap
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.UploadContent
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.UploadContent
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
|
||||
@Composable
|
||||
fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
||||
@@ -36,14 +37,14 @@ fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Uni
|
||||
val content = media.content[index]
|
||||
if (content is UploadContent.Video) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val imageBitmap = base64ToBitmap(item)
|
||||
val imageBitmap = base64ToBitmap(item).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"preview video",
|
||||
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||
)
|
||||
Icon(
|
||||
painterResource(MR.images.ic_videocam_filled),
|
||||
painterResource(R.drawable.ic_videocam_filled),
|
||||
"preview video",
|
||||
Modifier
|
||||
.size(20.dp),
|
||||
@@ -51,7 +52,7 @@ fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Uni
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val imageBitmap = base64ToBitmap(item)
|
||||
val imageBitmap = base64ToBitmap(item).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"preview image",
|
||||
@@ -63,8 +64,8 @@ fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Uni
|
||||
if (cancelEnabled) {
|
||||
IconButton(onClick = cancelImages) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_close),
|
||||
contentDescription = stringResource(MR.strings.icon_descr_cancel_image_preview),
|
||||
painterResource(R.drawable.ic_close),
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
@file:UseSerializers(UriSerializer::class)
|
||||
package chat.simplex.common.views.chat
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import ComposeFileView
|
||||
import ComposeVoiceView
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.*
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
@@ -10,20 +25,20 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
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 chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.item.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.serialization.*
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
|
||||
@Serializable
|
||||
@@ -32,7 +47,7 @@ sealed class ComposePreview {
|
||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||
@Serializable class MediaPreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
|
||||
@Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||
@Serializable class FilePreview(val fileName: String, val uri: URI): ComposePreview()
|
||||
@Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -147,14 +162,6 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun AttachmentSelection(
|
||||
composeState: MutableState<ComposeState>,
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
processPickedFile: (URI?, String?) -> Unit,
|
||||
processPickedMedia: (List<URI>, String?) -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ComposeView(
|
||||
chatModel: ChatModel,
|
||||
@@ -163,6 +170,7 @@ fun ComposeView(
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
showChooseAttachment: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val linkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val prevLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
@@ -171,26 +179,46 @@ fun ComposeView(
|
||||
val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
val processPickedMedia = { uris: List<URI>, text: String? ->
|
||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val bitmap: Bitmap? = getBitmapFromUri(uri)
|
||||
if (bitmap != null) {
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
|
||||
}
|
||||
}
|
||||
}
|
||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
cameraLauncher.launchWithFallback()
|
||||
} else {
|
||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val processPickedMedia = { uris: List<Uri>, text: String? ->
|
||||
val content = ArrayList<UploadContent>()
|
||||
val imagesPreview = ArrayList<String>()
|
||||
uris.forEach { uri ->
|
||||
var bitmap: ImageBitmap?
|
||||
var bitmap: Bitmap? = null
|
||||
val isImage = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(SimplexApp.context, uri)?.split(".")?.last())?.contains("image/") == true
|
||||
when {
|
||||
isImage(uri) -> {
|
||||
isImage -> {
|
||||
// Image
|
||||
val drawable = getDrawableFromUri(uri)
|
||||
bitmap = getBitmapFromUri(uri)
|
||||
if (isAnimImage(uri, drawable)) {
|
||||
bitmap = if (drawable != null) getBitmapFromUri(uri) else null
|
||||
val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable
|
||||
val isAnimOldApi = Build.VERSION.SDK_INT < 28 &&
|
||||
(getFileName(SimplexApp.context, uri)?.endsWith(".gif") == true || getFileName(SimplexApp.context, uri)?.endsWith(".webp") == true)
|
||||
if (isAnimNewApi || isAnimOldApi) {
|
||||
// It's a gif or webp
|
||||
val fileSize = getFileSize(uri)
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= maxFileSize) {
|
||||
content.add(UploadContent.AnimatedImage(uri))
|
||||
} else {
|
||||
bitmap = null
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -213,25 +241,66 @@ fun ComposeView(
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content))
|
||||
}
|
||||
}
|
||||
val processPickedFile = { uri: URI?, text: String? ->
|
||||
val processPickedFile = { uri: Uri?, text: String? ->
|
||||
if (uri != null) {
|
||||
val fileSize = getFileSize(uri)
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= maxFileSize) {
|
||||
val fileName = getFileName(uri)
|
||||
val fileName = getFileName(SimplexApp.context, uri)
|
||||
if (fileName != null) {
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri))
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedMedia(it, null) }
|
||||
val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
|
||||
val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedMedia(it, null) }
|
||||
val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
|
||||
|
||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
||||
|
||||
AttachmentSelection(composeState, attachmentOption, processPickedFile, processPickedMedia)
|
||||
LaunchedEffect(attachmentOption.value) {
|
||||
when (attachmentOption.value) {
|
||||
AttachmentOption.CameraPhoto -> {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||
cameraLauncher.launchWithFallback()
|
||||
}
|
||||
else -> {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.GalleryImage -> {
|
||||
try {
|
||||
galleryImageLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryImageLauncherFallback.launch("image/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.GalleryVideo -> {
|
||||
try {
|
||||
galleryVideoLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryVideoLauncherFallback.launch("video/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.File -> {
|
||||
filesLauncher.launch("*/*")
|
||||
attachmentOption.value = null
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
fun isSimplexLink(link: String): Boolean =
|
||||
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
|
||||
@@ -297,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(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?
|
||||
@@ -390,9 +454,9 @@ fun ComposeView(
|
||||
is ComposePreview.MediaPreview -> {
|
||||
preview.content.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(it.uri)
|
||||
is UploadContent.Video -> saveFileFromUri(it.uri)
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
is UploadContent.Video -> saveFileFromUri(context, it.uri)
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
@@ -407,7 +471,7 @@ fun ComposeView(
|
||||
is ComposePreview.VoicePreview -> {
|
||||
val tmpFile = File(preview.voice)
|
||||
AudioPlayer.stop(tmpFile.absolutePath)
|
||||
val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, "")))
|
||||
val actualFile = File(getAppFilePath(SimplexApp.context, tmpFile.name.replaceAfter(RecorderNative.extension, "")))
|
||||
withContext(Dispatchers.IO) {
|
||||
Files.move(tmpFile.toPath(), actualFile.toPath())
|
||||
}
|
||||
@@ -416,7 +480,7 @@ fun ComposeView(
|
||||
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val file = saveFileFromUri(preview.uri)
|
||||
val file = saveFileFromUri(context, preview.uri)
|
||||
if (file != null) {
|
||||
files.add((file))
|
||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
|
||||
@@ -431,25 +495,24 @@ 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 &&
|
||||
(cs.preview is ComposePreview.MediaPreview ||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,7 +560,7 @@ fun ComposeView(
|
||||
recState.value = RecordingState.NotStarted
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
withBGApi {
|
||||
RecorderInterface.stopRecording?.invoke()
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
AudioPlayer.stop(filePath)
|
||||
filePath?.let { File(it).delete() }
|
||||
}
|
||||
@@ -524,8 +587,8 @@ fun ComposeView(
|
||||
suspend fun sendLiveMessage() {
|
||||
val cs = composeState.value
|
||||
val typedMsg = cs.message
|
||||
if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) {
|
||||
val ci = sendMessageAsync(typedMsg, live = true, ttl = null)
|
||||
if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) {
|
||||
val ci = sendMessageAsync(typedMsg, live = true)
|
||||
if (ci != null) {
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
|
||||
}
|
||||
@@ -546,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))
|
||||
}
|
||||
@@ -589,10 +652,10 @@ fun ComposeView(
|
||||
fun contextItemView() {
|
||||
when (val contextItem = composeState.value.contextItem) {
|
||||
ComposeContextItem.NoContextItem -> {}
|
||||
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_reply)) {
|
||||
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, painterResource(R.drawable.ic_reply)) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||
}
|
||||
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_edit_filled)) {
|
||||
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(R.drawable.ic_edit_filled)) {
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
@@ -623,43 +686,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(MR.strings.files_and_media_prohibited),
|
||||
text = generalGetString(MR.strings.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(MR.images.ic_attach_file_filled_500),
|
||||
contentDescription = stringResource(MR.strings.attach),
|
||||
tint = if (!composeState.value.attachmentDisabled && userCanSend.value && !isGroupAndProhibitedFiles) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
painterResource(R.drawable.ic_attach_file_filled_500),
|
||||
contentDescription = stringResource(R.string.attach),
|
||||
tint = if (!composeState.value.attachmentDisabled && userCanSend.value) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
@@ -704,29 +745,34 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffectOnGone {
|
||||
val cs = composeState.value
|
||||
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
|
||||
sendMessage(null)
|
||||
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))
|
||||
val activity = LocalContext.current as Activity
|
||||
DisposableEffect(Unit) {
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
if (orientation == activity.resources.configuration.orientation) {
|
||||
val cs = composeState.value
|
||||
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
clearCurrentDraft()
|
||||
deleteUnusedFiles()
|
||||
} else if (composeState.value.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))
|
||||
}
|
||||
chatModel.draft.value = composeState.value
|
||||
chatModel.draftChatId.value = chat.id
|
||||
} else {
|
||||
clearCurrentDraft()
|
||||
deleteUnusedFiles()
|
||||
}
|
||||
chatModel.removeLiveDummy()
|
||||
}
|
||||
chatModel.draft.value = composeState.value
|
||||
chatModel.draftChatId.value = chat.id
|
||||
} else {
|
||||
clearCurrentDraft()
|
||||
deleteUnusedFiles()
|
||||
}
|
||||
chatModel.removeLiveDummy()
|
||||
}
|
||||
|
||||
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
|
||||
SendMsgView(
|
||||
composeState,
|
||||
showVoiceRecordIcon = true,
|
||||
@@ -738,10 +784,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,
|
||||
@@ -756,3 +800,65 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PickFromGallery: ActivityResultContract<Int, Uri?>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||
type = "image/*"
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
|
||||
}
|
||||
|
||||
class PickMultipleImagesFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
type = "image/*"
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
|
||||
if (intent?.data != null)
|
||||
listOf(intent.data!!)
|
||||
else if (intent?.clipData != null)
|
||||
with(intent.clipData!!) {
|
||||
val uris = ArrayList<Uri>()
|
||||
for (i in 0 until kotlin.math.min(itemCount, 10)) {
|
||||
val uri = getItemAt(i).uri
|
||||
if (uri != null) uris.add(uri)
|
||||
}
|
||||
if (itemCount > 10) {
|
||||
AlertManager.shared.showAlertMsg(R.string.images_limit_title, R.string.images_limit_desc)
|
||||
}
|
||||
uris
|
||||
}
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
|
||||
|
||||
class PickMultipleVideosFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Video.Media.INTERNAL_CONTENT_URI).apply {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
type = "video/*"
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
|
||||
if (intent?.data != null)
|
||||
listOf(intent.data!!)
|
||||
else if (intent?.clipData != null)
|
||||
with(intent.clipData!!) {
|
||||
val uris = ArrayList<Uri>()
|
||||
for (i in 0 until kotlin.math.min(itemCount, 10)) {
|
||||
val uri = getItemAt(i).uri
|
||||
if (uri != null) uris.add(uri)
|
||||
}
|
||||
if (itemCount > 10) {
|
||||
AlertManager.shared.showAlertMsg(R.string.videos_limit_title, R.string.videos_limit_desc)
|
||||
}
|
||||
uris
|
||||
}
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import androidx.compose.animation.core.Animatable
|
||||
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.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.model.durationText
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun ComposeVoiceView(
|
||||
filePath: String,
|
||||
recordedDurationMs: Int,
|
||||
finishedRecording: Boolean,
|
||||
cancelEnabled: Boolean,
|
||||
cancelVoice: () -> Unit
|
||||
) {
|
||||
BoxWithConstraints(Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
val audioPlaying = rememberSaveable { mutableStateOf(false) }
|
||||
val progress = rememberSaveable { mutableStateOf(0) }
|
||||
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
|
||||
val 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
|
||||
.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
|
||||
) {
|
||||
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 = {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewComposeAudioView() {
|
||||
SimpleXTheme {
|
||||
ComposeFileView(
|
||||
"test.txt",
|
||||
cancelFile = {},
|
||||
cancelEnabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import InfoRow
|
||||
import SectionBottomSpacer
|
||||
@@ -14,12 +14,12 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.PreferenceToggle
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.res.MR
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.PreferenceToggle
|
||||
|
||||
@Composable
|
||||
fun ContactPreferencesView(
|
||||
@@ -81,7 +81,7 @@ private fun ContactPreferencesLayout(
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.contact_preferences))
|
||||
AppBarTitle(stringResource(R.string.contact_preferences))
|
||||
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
|
||||
val onTTLUpdated = { ttl: Int? ->
|
||||
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl))
|
||||
@@ -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))
|
||||
@@ -140,18 +135,19 @@ private fun FeatureSection(
|
||||
leadingIcon = true,
|
||||
) {
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(MR.strings.chat_preferences_you_allow),
|
||||
generalGetString(R.string.chat_preferences_you_allow),
|
||||
ContactFeatureAllowed.values(userDefault).map { it to it.text },
|
||||
allowFeature,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(feature != ChatFeature.Calls) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
InfoRow(
|
||||
generalGetString(MR.strings.chat_preferences_contact_allows),
|
||||
generalGetString(R.string.chat_preferences_contact_allows),
|
||||
pref.contactPreference.allow.text
|
||||
)
|
||||
}
|
||||
SectionTextFooter(feature.enabledDescription(enabled))
|
||||
SectionTextFooter(feature.enabledDescription(enabled) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else ""))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -175,28 +171,20 @@ private fun TimedMessagesFeatureSection(
|
||||
leadingIcon = true,
|
||||
) {
|
||||
PreferenceToggle(
|
||||
generalGetString(MR.strings.chat_preferences_you_allow),
|
||||
generalGetString(R.string.chat_preferences_you_allow),
|
||||
checked = allowFeature.value,
|
||||
) { allow ->
|
||||
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
|
||||
}
|
||||
InfoRow(
|
||||
generalGetString(MR.strings.chat_preferences_contact_allows),
|
||||
generalGetString(R.string.chat_preferences_contact_allows),
|
||||
pref.contactPreference.allow.text
|
||||
)
|
||||
if (featuresAllowed.timedMessagesAllowed) {
|
||||
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
|
||||
DropdownCustomTimePickerSettingRow(
|
||||
selection = ttl,
|
||||
propagateExternalSelectionUpdate = true, // for Reset
|
||||
label = generalGetString(MR.strings.delete_after),
|
||||
dropdownValues = TimedMessagesPreference.ttlValues,
|
||||
customPickerTitle = generalGetString(MR.strings.delete_after),
|
||||
customPickerConfirmButtonText = generalGetString(MR.strings.custom_time_picker_select),
|
||||
onSelected = onTTLUpdated
|
||||
)
|
||||
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
|
||||
InfoRow(generalGetString(MR.strings.delete_after), timeText(pref.contactPreference.ttl))
|
||||
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
|
||||
}
|
||||
}
|
||||
SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled))
|
||||
@@ -206,19 +194,31 @@ private fun TimedMessagesFeatureSection(
|
||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
SectionItemView(reset, disabled = disabled) {
|
||||
Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
Text(stringResource(R.string.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionItemView(save, disabled = disabled) {
|
||||
Text(stringResource(MR.strings.save_and_notify_contact), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
|
||||
val ttlValues = TimedMessagesPreference.ttlValues
|
||||
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
|
||||
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(MR.strings.save_preferences_question),
|
||||
confirmText = generalGetString(MR.strings.save_and_notify_contact),
|
||||
dismissText = generalGetString(MR.strings.exit_without_saving),
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_contact),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -8,14 +8,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
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 chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.item.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
@@ -46,7 +46,7 @@ fun ContextItemView(
|
||||
.padding(horizontal = 8.dp)
|
||||
.height(20.dp)
|
||||
.width(20.dp),
|
||||
contentDescription = stringResource(MR.strings.icon_descr_context),
|
||||
contentDescription = stringResource(R.string.icon_descr_context),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
)
|
||||
MarkdownText(
|
||||
@@ -58,8 +58,8 @@ fun ContextItemView(
|
||||
}
|
||||
IconButton(onClick = cancelContextItem) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_close),
|
||||
contentDescription = stringResource(MR.strings.cancel_verb),
|
||||
painterResource(R.drawable.ic_close),
|
||||
contentDescription = stringResource(R.string.cancel_verb),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
@@ -73,7 +73,7 @@ fun PreviewContextItemView() {
|
||||
SimpleXTheme {
|
||||
ContextItemView(
|
||||
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
|
||||
contextIcon = painterResource(MR.images.ic_edit_filled)
|
||||
contextIcon = painterResource(R.drawable.ic_edit_filled)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCodeScanner
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
ScanCodeLayout(verifyCode, close)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.scan_code), false)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio = 1F)
|
||||
.padding(bottom = DEFAULT_PADDING)
|
||||
) {
|
||||
QRCodeScanner { text ->
|
||||
verifyCode(text) {
|
||||
if (it) {
|
||||
close()
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.incorrect_code)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.scan_code_from_contacts_app))
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
package chat.simplex.common.views.chat
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.*
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
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.*
|
||||
@@ -15,19 +28,29 @@ import androidx.compose.ui.draw.alpha
|
||||
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.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.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.item.ItemAction
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.core.widget.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.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
|
||||
import kotlinx.coroutines.*
|
||||
import java.lang.reflect.Field
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
@@ -41,43 +64,30 @@ 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)
|
||||
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
||||
PlatformTextField(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
|
||||
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(MR.strings.observer_cant_send_message_title),
|
||||
text = generalGetString(MR.strings.observer_cant_send_message_desc)
|
||||
)
|
||||
})
|
||||
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) {
|
||||
@@ -86,6 +96,7 @@ fun SendMsgView(
|
||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||
val sendButtonSize = remember { Animatable(36f) }
|
||||
val sendButtonAlpha = remember { Animatable(1f) }
|
||||
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
// Making LiveMessage alive when screen orientation was changed
|
||||
@@ -108,16 +119,15 @@ fun SendMsgView(
|
||||
}
|
||||
}
|
||||
}
|
||||
!allowedToRecordVoiceByPlatform() ->
|
||||
VoiceButtonWithoutPermissionByPlatform()
|
||||
!permissionsState.allPermissionsGranted ->
|
||||
VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
|
||||
else ->
|
||||
RecordVoiceView(recState, stopRecOnNextClick)
|
||||
}
|
||||
if (sendLiveMessage != null
|
||||
&& updateLiveMessage != null
|
||||
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
|
||||
&& cs.contextItem is ComposeContextItem.NoContextItem
|
||||
) {
|
||||
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) {
|
||||
@@ -134,55 +144,29 @@ fun SendMsgView(
|
||||
}
|
||||
else -> {
|
||||
val cs = composeState.value
|
||||
val icon = if (cs.editing || cs.liveMessage != null) painterResource(MR.images.ic_check_filled) else painterResource(MR.images.ic_arrow_upward)
|
||||
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(MR.strings.send_live_message),
|
||||
BoltFilled,
|
||||
onClick = {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
showDropdown.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (timedMessageAllowed) {
|
||||
menuItems.add {
|
||||
ItemAction(
|
||||
generalGetString(MR.strings.disappearing_message),
|
||||
painterResource(MR.images.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)
|
||||
@@ -194,101 +178,121 @@ fun SendMsgView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun allowedToRecordVoiceByPlatform(): Boolean
|
||||
|
||||
@Composable
|
||||
expect fun VoiceButtonWithoutPermissionByPlatform()
|
||||
|
||||
@Composable
|
||||
private fun CustomDisappearingMessageDialog(
|
||||
sendMessage: (Int?) -> Unit,
|
||||
setShowDialog: (Boolean) -> Unit,
|
||||
customDisappearingMessageTimePref: SharedPreference<Int>?
|
||||
private fun NativeKeyboard(
|
||||
composeState: MutableState<ComposeState>,
|
||||
textStyle: MutableState<TextStyle>,
|
||||
showDeleteTextButton: MutableState<Boolean>,
|
||||
userIsObserver: Boolean,
|
||||
onMessageChange: (String) -> Unit
|
||||
) {
|
||||
val showCustomTimePicker = remember { mutableStateOf(false) }
|
||||
|
||||
if (showCustomTimePicker.value) {
|
||||
val selectedDisappearingMessageTime = remember {
|
||||
mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300)
|
||||
}
|
||||
CustomTimePickerDialog(
|
||||
selectedDisappearingMessageTime,
|
||||
title = generalGetString(MR.strings.delete_after),
|
||||
confirmButtonText = generalGetString(MR.strings.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DefaultDialog(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(MR.strings.send_disappearing_message),
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
Icon(
|
||||
painterResource(MR.images.ic_close),
|
||||
generalGetString(MR.strings.icon_descr_close_button),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.size(25.dp)
|
||||
.clickable { setShowDialog(false) }
|
||||
)
|
||||
}
|
||||
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_30_seconds)) {
|
||||
sendMessage(30)
|
||||
setShowDialog(false)
|
||||
}
|
||||
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_1_minute)) {
|
||||
sendMessage(60)
|
||||
setShowDialog(false)
|
||||
}
|
||||
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_5_minutes)) {
|
||||
sendMessage(300)
|
||||
setShowDialog(false)
|
||||
}
|
||||
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_custom_time)) {
|
||||
showCustomTimePicker.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val cs = composeState.value
|
||||
val textColor = MaterialTheme.colors.onBackground
|
||||
val tintColor = MaterialTheme.colors.secondaryVariant
|
||||
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
|
||||
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(cs.contextItem) {
|
||||
if (cs.contextItem is ComposeContextItem.QuotedItem) {
|
||||
delay(100)
|
||||
showKeyboard = true
|
||||
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||
// Keyboard will not show up if we try to show it too fast
|
||||
delay(300)
|
||||
showKeyboard = true
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(modifier = Modifier, factory = {
|
||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||
override fun setOnReceiveContentListener(
|
||||
mimeTypes: Array<out String>?,
|
||||
listener: android.view.OnReceiveContentListener?
|
||||
) {
|
||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||
}
|
||||
|
||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||
val connection = super.onCreateInputConnection(editorInfo)
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||
try {
|
||||
inputContentInfo.requestPermission()
|
||||
} catch (e: Exception) {
|
||||
return@OnCommitContentListener false
|
||||
}
|
||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Media("", listOf(inputContentInfo.contentUri))
|
||||
true
|
||||
}
|
||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||
}
|
||||
}
|
||||
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
editText.maxLines = 16
|
||||
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
||||
editText.setTextColor(textColor.toArgb())
|
||||
editText.textSize = textStyle.value.fontSize.value
|
||||
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
||||
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
||||
editText.background = drawable
|
||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||
editText.setText(cs.message)
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, CurrentColors.value.colors.secondary.toArgb()) }
|
||||
} else {
|
||||
try {
|
||||
val f: Field = TextView::class.java.getDeclaredField("mCursorDrawableRes")
|
||||
f.isAccessible = true
|
||||
f.set(editText, R.drawable.edit_text_cursor)
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
editText.doOnTextChanged { text, _, _, _ ->
|
||||
if (!composeState.value.inProgress) {
|
||||
onMessageChange(text.toString())
|
||||
} else if (text.toString() != composeState.value.message) {
|
||||
editText.setText(composeState.value.message)
|
||||
}
|
||||
}
|
||||
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||
editText
|
||||
}) {
|
||||
it.setTextColor(textColor.toArgb())
|
||||
it.textSize = textStyle.value.fontSize.value
|
||||
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
||||
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview && !cs.inProgress
|
||||
it.isFocusableInTouchMode = it.isFocusable
|
||||
if (cs.message != it.text.toString()) {
|
||||
it.setText(cs.message)
|
||||
// Set cursor to the end of the text
|
||||
it.setSelection(it.text.length)
|
||||
}
|
||||
if (showKeyboard) {
|
||||
it.requestFocus()
|
||||
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||
showKeyboard = false
|
||||
}
|
||||
showDeleteTextButton.value = it.lineCount >= 4 && !cs.inProgress
|
||||
}
|
||||
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
ComposeOverlay(R.string.voice_message_send_text, textStyle, padding)
|
||||
} else if (userIsObserver) {
|
||||
ComposeOverlay(R.string.you_are_observer, textStyle, padding)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComposeOverlay(textId: Int, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
|
||||
Text(
|
||||
generalGetString(textId),
|
||||
Modifier.padding(padding),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -297,13 +301,13 @@ private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>)
|
||||
{ composeState.value = composeState.value.copy(message = "") },
|
||||
Modifier.align(Alignment.TopEnd).size(36.dp)
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary)
|
||||
Icon(painterResource(R.drawable.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
|
||||
val rec: RecorderInterface = remember { RecorderNative() }
|
||||
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||
DisposableEffect(Unit) { onDispose { rec.stop() } }
|
||||
val stopRecordingAndAddAudio: () -> Unit = {
|
||||
recState.value.filePathNullable?.let {
|
||||
@@ -354,8 +358,8 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
|
||||
private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp), enabled = enabled) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_keyboard_voice),
|
||||
stringResource(MR.strings.icon_descr_record_voice_message),
|
||||
painterResource(R.drawable.ic_keyboard_voice),
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
@@ -365,11 +369,11 @@ private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
||||
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_keyboard_voice_filled),
|
||||
stringResource(MR.strings.icon_descr_record_voice_message),
|
||||
painterResource(R.drawable.ic_keyboard_voice_filled),
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
@@ -378,12 +382,30 @@ fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockToCurrentOrientationUntilDispose() {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context as Activity
|
||||
val manager = context.getSystemService(Activity.WINDOW_SERVICE) as WindowManager
|
||||
val rotation = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) manager.defaultDisplay.rotation else activity.display?.rotation
|
||||
activity.requestedOrientation = when (rotation) {
|
||||
android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||
android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
// Unlock orientation
|
||||
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StopRecordButton(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_stop_filled),
|
||||
stringResource(MR.strings.icon_descr_record_voice_message),
|
||||
painterResource(R.drawable.ic_stop_filled),
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
@@ -396,8 +418,8 @@ private fun StopRecordButton(onClick: () -> Unit) {
|
||||
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
|
||||
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_keyboard_voice_filled),
|
||||
stringResource(MR.strings.icon_descr_record_voice_message),
|
||||
painterResource(R.drawable.ic_keyboard_voice_filled),
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
@@ -417,8 +439,8 @@ private fun CancelLiveMessageButton(
|
||||
) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_close),
|
||||
stringResource(MR.strings.icon_descr_cancel_live_message),
|
||||
painterResource(R.drawable.ic_close),
|
||||
stringResource(R.string.icon_descr_cancel_live_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
@@ -433,14 +455,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,
|
||||
@@ -451,7 +473,7 @@ private fun SendMsgButton(
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(MR.strings.icon_descr_send_message),
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(sizeDp.value.dp)
|
||||
@@ -480,7 +502,7 @@ private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
) {
|
||||
Icon(
|
||||
BoltFilled,
|
||||
stringResource(MR.strings.icon_descr_send_message),
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
@@ -531,9 +553,9 @@ private fun startLiveMessage(
|
||||
start()
|
||||
} else {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.live_message),
|
||||
text = generalGetString(MR.strings.send_live_message_desc),
|
||||
confirmText = generalGetString(MR.strings.send_verb),
|
||||
title = generalGetString(R.string.live_message),
|
||||
text = generalGetString(R.string.send_live_message_desc),
|
||||
confirmText = generalGetString(R.string.send_verb),
|
||||
onConfirm = {
|
||||
liveMessageAlertShown.set(true)
|
||||
start()
|
||||
@@ -543,31 +565,32 @@ private fun startLiveMessage(
|
||||
|
||||
private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.allow_voice_messages_question),
|
||||
text = generalGetString(MR.strings.you_need_to_allow_to_send_voice),
|
||||
confirmText = generalGetString(MR.strings.allow_verb),
|
||||
dismissText = generalGetString(MR.strings.cancel_verb),
|
||||
title = generalGetString(R.string.allow_voice_messages_question),
|
||||
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
|
||||
confirmText = generalGetString(R.string.allow_verb),
|
||||
dismissText = generalGetString(R.string.cancel_verb),
|
||||
onConfirm = onConfirm,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.voice_messages_prohibited),
|
||||
title = generalGetString(R.string.voice_messages_prohibited),
|
||||
text = generalGetString(
|
||||
if (isDirectChat)
|
||||
MR.strings.ask_your_contact_to_enable_voice
|
||||
R.string.ask_your_contact_to_enable_voice
|
||||
else
|
||||
MR.strings.only_group_owners_can_enable_voice
|
||||
R.string.only_group_owners_can_enable_voice
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSendMsgView() {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
@@ -584,7 +607,6 @@ fun PreviewSendMsgView() {
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
timedMessageAllowed = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
@@ -592,11 +614,12 @@ fun PreviewSendMsgView() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSendMsgViewEditing() {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
@@ -614,7 +637,6 @@ fun PreviewSendMsgViewEditing() {
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
timedMessageAllowed = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
@@ -622,11 +644,12 @@ fun PreviewSendMsgViewEditing() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSendMsgViewInProgress() {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
@@ -644,7 +667,6 @@ fun PreviewSendMsgViewInProgress() {
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
timedMessageAllowed = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionView
|
||||
@@ -10,18 +10,16 @@ import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.common.platform.shareText
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.QRCode
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@Composable
|
||||
fun VerifyCodeView(
|
||||
@@ -63,14 +61,14 @@ private fun VerifyCodeLayout(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.security_code), false)
|
||||
AppBarTitle(stringResource(R.string.security_code), false)
|
||||
val splitCode = splitToParts(connectionCode, 24)
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
|
||||
if (connectionVerified) {
|
||||
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 4.dp).size(22.dp), tint = MaterialTheme.colors.secondary)
|
||||
Text(String.format(stringResource(MR.strings.is_verified), displayName))
|
||||
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 4.dp).size(22.dp), tint = MaterialTheme.colors.secondary)
|
||||
Text(String.format(stringResource(R.string.is_verified), displayName))
|
||||
} else {
|
||||
Text(String.format(stringResource(MR.strings.is_not_verified), displayName))
|
||||
Text(String.format(stringResource(R.string.is_not_verified), displayName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,17 +86,17 @@ private fun VerifyCodeLayout(
|
||||
maxLines = 20
|
||||
)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
Box(Modifier.weight(1f)) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
IconButton({ clipboard.shareText(connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
|
||||
Icon(painterResource(MR.images.ic_share_filled), null, tint = MaterialTheme.colors.primary)
|
||||
IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
|
||||
Icon(painterResource(R.drawable.ic_share_filled), null, tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Text(
|
||||
generalGetString(MR.strings.to_verify_compare),
|
||||
generalGetString(R.string.to_verify_compare),
|
||||
Modifier.padding(bottom = DEFAULT_PADDING)
|
||||
)
|
||||
|
||||
@@ -107,22 +105,20 @@ private fun VerifyCodeLayout(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (connectionVerified) {
|
||||
SimpleButton(generalGetString(MR.strings.clear_verification), painterResource(MR.images.ic_shield)) {
|
||||
SimpleButton(generalGetString(R.string.clear_verification), painterResource(R.drawable.ic_shield)) {
|
||||
verifyCode(null) {}
|
||||
}
|
||||
} else {
|
||||
if (appPlatform.isAndroid) {
|
||||
SimpleButton(generalGetString(MR.strings.scan_code), painterResource(MR.images.ic_qr_code)) {
|
||||
ModalManager.shared.showModal {
|
||||
ScanCodeView(verifyCode) { }
|
||||
}
|
||||
SimpleButton(generalGetString(R.string.scan_code), painterResource(R.drawable.ic_qr_code)) {
|
||||
ModalManager.shared.showModal {
|
||||
ScanCodeView(verifyCode) { }
|
||||
}
|
||||
}
|
||||
SimpleButton(generalGetString(MR.strings.mark_code_verified), painterResource(MR.images.ic_verified_user)) {
|
||||
SimpleButton(generalGetString(R.string.mark_code_verified), painterResource(R.drawable.ic_verified_user)) {
|
||||
verifyCode(connectionCode) { verified ->
|
||||
if (!verified) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.incorrect_code)
|
||||
title = generalGetString(R.string.incorrect_code)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat.group
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionCustomFooter
|
||||
@@ -6,49 +6,43 @@ import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.graphics.painter.Painter
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.ChatInfoToolbarTitle
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.InfoAboutIncognito
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.common.model.GroupInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.group.GroupPreferencesView
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.InfoAboutIncognito
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
|
||||
@Composable
|
||||
fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) {
|
||||
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,
|
||||
@@ -112,12 +104,12 @@ fun AddGroupMembersLayout(
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.button_add_members))
|
||||
AppBarTitle(stringResource(R.string.button_add_members))
|
||||
InfoAboutIncognito(
|
||||
chatModelIncognito,
|
||||
false,
|
||||
generalGetString(MR.strings.group_unsupported_incognito_main_profile_sent),
|
||||
generalGetString(MR.strings.group_main_profile_sent),
|
||||
generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
|
||||
generalGetString(R.string.group_main_profile_sent),
|
||||
true
|
||||
)
|
||||
Spacer(Modifier.size(DEFAULT_PADDING))
|
||||
@@ -133,13 +125,13 @@ fun AddGroupMembersLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (contactsToAdd.isEmpty() && searchText.value.text.isEmpty()) {
|
||||
if (contactsToAdd.isEmpty()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
stringResource(MR.strings.no_contacts_to_add),
|
||||
stringResource(R.string.no_contacts_to_add),
|
||||
Modifier.padding(),
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
@@ -148,7 +140,7 @@ fun AddGroupMembersLayout(
|
||||
SectionView {
|
||||
if (creatingGroup) {
|
||||
SectionItemView(openPreferences) {
|
||||
Text(stringResource(MR.strings.set_group_preferences))
|
||||
Text(stringResource(R.string.set_group_preferences))
|
||||
}
|
||||
}
|
||||
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
||||
@@ -162,10 +154,8 @@ fun AddGroupMembersLayout(
|
||||
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
||||
}
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView(stringResource(MR.strings.select_contacts)) {
|
||||
SectionItemView(padding = PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF)) {
|
||||
SearchRowView(searchText, selectedContacts.size)
|
||||
}
|
||||
|
||||
SectionView(stringResource(R.string.select_contacts)) {
|
||||
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(MR.images.ic_search), stringResource(MR.strings.search_verb), 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 = LocalMultiplatformView()
|
||||
LaunchedEffect(selectedContactsSize) {
|
||||
searchText.value = searchText.value.copy("")
|
||||
hideKeyboard(view)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>, enabled: Boolean) {
|
||||
Row(
|
||||
@@ -201,7 +172,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
||||
) {
|
||||
val values = GroupMemberRole.values().filter { it <= groupInfo.membership.memberRole }.map { it to it.text }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(MR.strings.new_member_role),
|
||||
generalGetString(R.string.new_member_role),
|
||||
values,
|
||||
selectedRole,
|
||||
icon = null,
|
||||
@@ -213,8 +184,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
||||
@Composable
|
||||
fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_check),
|
||||
stringResource(MR.strings.invite_to_group_button),
|
||||
painterResource(R.drawable.ic_check),
|
||||
stringResource(R.string.invite_to_group_button),
|
||||
click = onClick,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
@@ -225,8 +196,8 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
||||
@Composable
|
||||
fun SkipInvitingButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_check),
|
||||
stringResource(MR.strings.skip_inviting_button),
|
||||
painterResource(R.drawable.ic_check),
|
||||
stringResource(R.string.skip_inviting_button),
|
||||
click = onClick,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
@@ -242,7 +213,7 @@ fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelec
|
||||
) {
|
||||
if (selectedContactsCount >= 1) {
|
||||
Text(
|
||||
String.format(generalGetString(MR.strings.num_contacts_selected), selectedContactsCount),
|
||||
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
@@ -250,14 +221,14 @@ fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelec
|
||||
Modifier.clickable { if (enabled) clearSelection() }
|
||||
) {
|
||||
Text(
|
||||
stringResource(MR.strings.clear_contacts_selection_button),
|
||||
stringResource(R.string.clear_contacts_selection_button),
|
||||
color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
stringResource(MR.strings.no_contacts_selected),
|
||||
stringResource(R.string.no_contacts_selected),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
@@ -298,13 +269,13 @@ fun ContactCheckRow(
|
||||
val icon: Painter
|
||||
val iconColor: Color
|
||||
if (prohibitedToInviteIncognito) {
|
||||
icon = painterResource(MR.images.ic_theater_comedy_filled)
|
||||
icon = painterResource(R.drawable.ic_theater_comedy_filled)
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
} else if (checked) {
|
||||
icon = painterResource(MR.images.ic_check_circle_filled)
|
||||
icon = painterResource(R.drawable.ic_check_circle_filled)
|
||||
iconColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
} else {
|
||||
icon = painterResource(MR.images.ic_circle)
|
||||
icon = painterResource(R.drawable.ic_circle)
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
}
|
||||
SectionItemView(
|
||||
@@ -322,16 +293,13 @@ fun ContactCheckRow(
|
||||
ProfileImage(size = 36.dp, contact.image)
|
||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
||||
Text(
|
||||
contact.chatViewName,
|
||||
modifier = Modifier.weight(10f, fill = true),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = stringResource(MR.strings.icon_descr_contact_checked),
|
||||
contentDescription = stringResource(R.string.icon_descr_contact_checked),
|
||||
tint = iconColor
|
||||
)
|
||||
}
|
||||
@@ -339,9 +307,9 @@ fun ContactCheckRow(
|
||||
|
||||
fun showProhibitedToInviteIncognitoAlertDialog() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.invite_prohibited),
|
||||
text = generalGetString(MR.strings.invite_prohibited_description),
|
||||
confirmText = generalGetString(MR.strings.ok),
|
||||
title = generalGetString(R.string.invite_prohibited),
|
||||
text = generalGetString(R.string.invite_prohibited_description),
|
||||
confirmText = generalGetString(R.string.ok),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -357,7 +325,6 @@ fun PreviewAddGroupMembersLayout() {
|
||||
selectedContacts = remember { mutableStateListOf() },
|
||||
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
||||
allowModifyMembers = true,
|
||||
searchText = remember { mutableStateOf(TextFieldValue("")) },
|
||||
openPreferences = {},
|
||||
inviteMembers = {},
|
||||
clearSelection = {},
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat.group
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import InfoRow
|
||||
import SectionBottomSpacer
|
||||
@@ -7,34 +7,31 @@ import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
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 dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
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.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.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chatlist.cantInviteIncognitoAlert
|
||||
import chat.simplex.common.views.chatlist.setGroupMembers
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.model.GroupInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.ClearChatButton
|
||||
import chat.simplex.common.views.chat.clearChatDialog
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
|
||||
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
|
||||
@Composable
|
||||
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit, close: () -> Unit) {
|
||||
@@ -61,8 +58,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
|
||||
},
|
||||
showMemberInfo = { member ->
|
||||
withApi {
|
||||
val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
val stats = r?.second
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
val (_, code) = if (member.memberActive) {
|
||||
try {
|
||||
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
|
||||
@@ -110,19 +106,19 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
|
||||
|
||||
fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
val alertTextKey =
|
||||
if (groupInfo.membership.memberCurrent) MR.strings.delete_group_for_all_members_cannot_undo_warning
|
||||
else MR.strings.delete_group_for_self_cannot_undo_warning
|
||||
if (groupInfo.membership.memberCurrent) R.string.delete_group_for_all_members_cannot_undo_warning
|
||||
else R.string.delete_group_for_self_cannot_undo_warning
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.delete_group_question),
|
||||
title = generalGetString(R.string.delete_group_question),
|
||||
text = generalGetString(alertTextKey),
|
||||
confirmText = generalGetString(MR.strings.delete_verb),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (r) {
|
||||
chatModel.removeChat(chatInfo.id)
|
||||
chatModel.chatId.value = null
|
||||
ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -133,9 +129,9 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM
|
||||
|
||||
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.leave_group_question),
|
||||
text = generalGetString(MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
|
||||
confirmText = generalGetString(MR.strings.leave_group_button),
|
||||
title = generalGetString(R.string.leave_group_question),
|
||||
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
|
||||
confirmText = generalGetString(R.string.leave_group_button),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.leaveGroup(groupInfo.groupId)
|
||||
@@ -185,10 +181,10 @@ fun GroupChatInfoLayout(
|
||||
}
|
||||
GroupPreferencesButton(openPreferences)
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs))
|
||||
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
|
||||
SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) {
|
||||
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
||||
if (groupInfo.canAddMembers) {
|
||||
if (groupLink == null) {
|
||||
CreateGroupLinkButton(manageGroupLink)
|
||||
@@ -200,17 +196,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 = remember { 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 {
|
||||
@@ -225,9 +214,9 @@ fun GroupChatInfoLayout(
|
||||
|
||||
if (developerTools) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
|
||||
InfoRow(stringResource(MR.strings.info_row_local_name), groupInfo.localDisplayName)
|
||||
InfoRow(stringResource(MR.strings.info_row_database_id), groupInfo.apiId.toString())
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
|
||||
InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
@@ -244,16 +233,14 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
Text(
|
||||
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 4,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 8,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
@@ -263,8 +250,8 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
@Composable
|
||||
private fun GroupPreferencesButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_toggle_on),
|
||||
stringResource(MR.strings.group_preferences),
|
||||
painterResource(R.drawable.ic_toggle_on),
|
||||
stringResource(R.string.group_preferences),
|
||||
click = onClick
|
||||
)
|
||||
}
|
||||
@@ -272,8 +259,8 @@ private fun GroupPreferencesButton(onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_add),
|
||||
stringResource(MR.strings.button_add_members),
|
||||
painterResource(R.drawable.ic_add),
|
||||
stringResource(R.string.button_add_members),
|
||||
onClick,
|
||||
iconColor = tint,
|
||||
textColor = tint
|
||||
@@ -317,7 +304,7 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
)
|
||||
}
|
||||
val s = member.memberStatus.shortText
|
||||
val statusDescr = if (user) String.format(generalGetString(MR.strings.group_info_member_you), s) else s
|
||||
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
|
||||
Text(
|
||||
statusDescr,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
@@ -336,14 +323,14 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
|
||||
@Composable
|
||||
private fun MemberVerifiedShield() {
|
||||
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary)
|
||||
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupLinkButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_link),
|
||||
stringResource(MR.strings.group_link),
|
||||
painterResource(R.drawable.ic_link),
|
||||
stringResource(R.string.group_link),
|
||||
onClick,
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
)
|
||||
@@ -352,8 +339,8 @@ private fun GroupLinkButton(onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun CreateGroupLinkButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_add_link),
|
||||
stringResource(MR.strings.create_group_link),
|
||||
painterResource(R.drawable.ic_add_link),
|
||||
stringResource(R.string.create_group_link),
|
||||
onClick,
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
)
|
||||
@@ -362,8 +349,8 @@ private fun CreateGroupLinkButton(onClick: () -> Unit) {
|
||||
@Composable
|
||||
fun EditGroupProfileButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_edit),
|
||||
stringResource(MR.strings.button_edit_group_profile),
|
||||
painterResource(R.drawable.ic_edit),
|
||||
stringResource(R.string.button_edit_group_profile),
|
||||
onClick,
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
)
|
||||
@@ -372,12 +359,12 @@ fun EditGroupProfileButton(onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun AddOrEditWelcomeMessage(welcomeMessage: String?, onClick: () -> Unit) {
|
||||
val text = if (welcomeMessage == null) {
|
||||
stringResource(MR.strings.button_add_welcome_message)
|
||||
stringResource(R.string.button_add_welcome_message)
|
||||
} else {
|
||||
stringResource(MR.strings.button_welcome_message)
|
||||
stringResource(R.string.button_welcome_message)
|
||||
}
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_maps_ugc),
|
||||
painterResource(R.drawable.ic_maps_ugc),
|
||||
text,
|
||||
onClick,
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
@@ -387,8 +374,8 @@ private fun AddOrEditWelcomeMessage(welcomeMessage: String?, onClick: () -> Unit
|
||||
@Composable
|
||||
private fun LeaveGroupButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_logout),
|
||||
stringResource(MR.strings.button_leave_group),
|
||||
painterResource(R.drawable.ic_logout),
|
||||
stringResource(R.string.button_leave_group),
|
||||
onClick,
|
||||
iconColor = Color.Red,
|
||||
textColor = Color.Red
|
||||
@@ -398,27 +385,14 @@ private fun LeaveGroupButton(onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun DeleteGroupButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_delete),
|
||||
stringResource(MR.strings.button_delete_group),
|
||||
painterResource(R.drawable.ic_delete),
|
||||
stringResource(R.string.button_delete_group),
|
||||
onClick,
|
||||
iconColor = Color.Red,
|
||||
textColor = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchRowView(
|
||||
searchText: MutableState<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
|
||||
) {
|
||||
Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), 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() {
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat.group
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import SectionBottomSpacer
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -10,23 +10,23 @@ 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.LocalClipboardManager
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
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 chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.shareText
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.QRCode
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@Composable
|
||||
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit) {
|
||||
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
|
||||
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
|
||||
var creatingLink by rememberSaveable { mutableStateOf(false) }
|
||||
val cxt = LocalContext.current
|
||||
fun createLink() {
|
||||
creatingLink = true
|
||||
withApi {
|
||||
@@ -44,14 +44,13 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
||||
createLink()
|
||||
}
|
||||
}
|
||||
val clipboard = LocalClipboardManager.current
|
||||
GroupLinkLayout(
|
||||
groupLink = groupLink,
|
||||
groupInfo,
|
||||
groupLinkMemberRole,
|
||||
creatingLink,
|
||||
createLink = ::createLink,
|
||||
share = { clipboard.shareText(groupLink ?: return@GroupLinkLayout) },
|
||||
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
|
||||
updateLink = {
|
||||
val role = groupLinkMemberRole.value
|
||||
if (role != null) {
|
||||
@@ -67,9 +66,9 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
||||
},
|
||||
deleteLink = {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.delete_link_question),
|
||||
text = generalGetString(MR.strings.all_group_members_will_remain_connected),
|
||||
confirmText = generalGetString(MR.strings.delete_verb),
|
||||
title = generalGetString(R.string.delete_link_question),
|
||||
text = generalGetString(R.string.all_group_members_will_remain_connected),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
|
||||
@@ -103,9 +102,9 @@ fun GroupLinkLayout(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.group_link))
|
||||
AppBarTitle(stringResource(R.string.group_link))
|
||||
Text(
|
||||
stringResource(MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect),
|
||||
stringResource(R.string.you_can_share_group_link_anybody_will_be_able_to_connect),
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 12.dp),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
@@ -115,7 +114,7 @@ fun GroupLinkLayout(
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
if (groupLink == null) {
|
||||
SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink)
|
||||
SimpleButton(stringResource(R.string.button_create_group_link), icon = painterResource(R.drawable.ic_add_link), disabled = creatingLink, click = createLink)
|
||||
} else {
|
||||
RoleSelectionRow(groupInfo, groupLinkMemberRole)
|
||||
var initialLaunch by remember { mutableStateOf(true) }
|
||||
@@ -132,13 +131,13 @@ fun GroupLinkLayout(
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp)
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(MR.strings.share_link),
|
||||
icon = painterResource(MR.images.ic_share),
|
||||
stringResource(R.string.share_link),
|
||||
icon = painterResource(R.drawable.ic_share),
|
||||
click = share
|
||||
)
|
||||
SimpleButton(
|
||||
stringResource(MR.strings.delete_link),
|
||||
icon = painterResource(MR.images.ic_delete),
|
||||
stringResource(R.string.delete_link),
|
||||
icon = painterResource(R.drawable.ic_delete),
|
||||
color = Color.Red,
|
||||
click = deleteLink
|
||||
)
|
||||
@@ -158,7 +157,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
||||
) {
|
||||
val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(MR.strings.initial_member_role),
|
||||
generalGetString(R.string.initial_member_role),
|
||||
values,
|
||||
selectedRole,
|
||||
icon = null,
|
||||
@@ -0,0 +1,363 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import InfoRow
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.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.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
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.QRCode
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoView(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
connectionCode: String?,
|
||||
chatModel: ChatModel,
|
||||
close: () -> Unit,
|
||||
closeAll: () -> Unit, // Close all open windows up to ChatView
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
if (chat != null) {
|
||||
val newRole = remember { mutableStateOf(member.memberRole) }
|
||||
GroupMemberInfoLayout(
|
||||
groupInfo,
|
||||
member,
|
||||
connStats,
|
||||
newRole,
|
||||
developerTools,
|
||||
connectionCode,
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
openDirectChat = {
|
||||
withApi {
|
||||
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||
if (c != null) {
|
||||
if (chatModel.getContactChat(it) == null) {
|
||||
chatModel.addChat(c)
|
||||
}
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(c.chatItems)
|
||||
chatModel.chatId.value = c.id
|
||||
closeAll()
|
||||
}
|
||||
}
|
||||
},
|
||||
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
|
||||
onRoleSelected = {
|
||||
if (it == newRole.value) return@GroupMemberInfoLayout
|
||||
val prevValue = newRole.value
|
||||
newRole.value = it
|
||||
updateMemberRoleDialog(it, member, onDismiss = {
|
||||
newRole.value = prevValue
|
||||
}) {
|
||||
withApi {
|
||||
kotlin.runCatching {
|
||||
val mem = chatModel.controller.apiMemberRole(groupInfo.groupId, member.groupMemberId, it)
|
||||
chatModel.upsertGroupMember(groupInfo, mem)
|
||||
}.onFailure {
|
||||
newRole.value = prevValue
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
switchMemberAddress = {
|
||||
switchMemberAddress(chatModel, groupInfo, member)
|
||||
},
|
||||
verifyClicked = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||
VerifyCodeView(
|
||||
mem.displayName,
|
||||
connectionCode,
|
||||
mem.verified,
|
||||
verify = { code ->
|
||||
chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r ->
|
||||
val (verified, existingCode) = r
|
||||
chatModel.upsertGroupMember(
|
||||
groupInfo,
|
||||
mem.copy(
|
||||
activeConn = mem.activeConn?.copy(
|
||||
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||
)
|
||||
)
|
||||
)
|
||||
r
|
||||
}
|
||||
},
|
||||
close,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.button_remove_member),
|
||||
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
|
||||
confirmText = generalGetString(R.string.remove_member_confirmation),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
|
||||
if (removedMember != null) {
|
||||
chatModel.upsertGroupMember(groupInfo, removedMember)
|
||||
}
|
||||
close?.invoke()
|
||||
}
|
||||
},
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoLayout(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
newRole: MutableState<GroupMemberRole>,
|
||||
developerTools: Boolean,
|
||||
connectionCode: String?,
|
||||
getContactChat: (Long) -> Chat?,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
removeMember: () -> Unit,
|
||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||
switchMemberAddress: () -> Unit,
|
||||
verifyClicked: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
GroupMemberInfoHeader(member)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (member.memberActive) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId != null) {
|
||||
SectionView {
|
||||
val chat = getContactChat(contactId)
|
||||
if ((chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) || groupInfo.fullGroupPreferences.directMessages.on) {
|
||||
OpenChatButton(onClick = { openDirectChat(contactId) })
|
||||
}
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(member.verified, verifyClicked)
|
||||
}
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
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) }
|
||||
if (roles != null) {
|
||||
RoleSelectionRow(roles, newRole, onRoleSelected)
|
||||
} else {
|
||||
InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
|
||||
}
|
||||
val conn = member.activeConn
|
||||
if (conn != null) {
|
||||
val connLevelDesc =
|
||||
if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
|
||||
else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
|
||||
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
|
||||
}
|
||||
}
|
||||
if (connStats != null) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (member.canBeRemoved(groupInfo)) {
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
SectionView {
|
||||
RemoveMemberButton(removeMember)
|
||||
}
|
||||
}
|
||||
|
||||
if (developerTools) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
|
||||
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoHeader(member: GroupMember) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (member.verified) {
|
||||
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
Text(
|
||||
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (member.fullName != "" && member.fullName != member.displayName) {
|
||||
Text(
|
||||
member.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoveMemberButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(R.drawable.ic_delete),
|
||||
stringResource(R.string.button_remove_member),
|
||||
click = onClick,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpenChatButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(R.drawable.ic_chat),
|
||||
stringResource(R.string.button_send_direct_message),
|
||||
click = onClick,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoleSelectionRow(
|
||||
roles: List<GroupMemberRole>,
|
||||
selectedRole: MutableState<GroupMemberRole>,
|
||||
onSelected: (GroupMemberRole) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
val values = remember { roles.map { it to it.text } }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.change_role),
|
||||
values,
|
||||
selectedRole,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMemberRoleDialog(
|
||||
newRole: GroupMemberRole,
|
||||
member: GroupMember,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.change_member_role_question),
|
||||
text = if (member.memberCurrent)
|
||||
String.format(generalGetString(R.string.member_role_will_be_changed_with_notification), newRole.text)
|
||||
else
|
||||
String.format(generalGetString(R.string.member_role_will_be_changed_with_invitation), newRole.text),
|
||||
confirmText = generalGetString(R.string.change_verb),
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = onConfirm,
|
||||
onDismissRequest = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
private fun switchMemberAddress(m: ChatModel, groupInfo: GroupInfo, member: GroupMember) = withApi {
|
||||
m.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewGroupMemberInfoLayout() {
|
||||
SimpleXTheme {
|
||||
GroupMemberInfoLayout(
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
member = GroupMember.sampleData,
|
||||
connStats = null,
|
||||
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
||||
developerTools = false,
|
||||
connectionCode = "123",
|
||||
getContactChat = { Chat.sampleData },
|
||||
openDirectChat = {},
|
||||
removeMember = {},
|
||||
onRoleSelected = {},
|
||||
switchMemberAddress = {},
|
||||
verifyClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat.group
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import InfoRow
|
||||
import SectionBottomSpacer
|
||||
@@ -13,12 +13,13 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.res.MR
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.TimedMessagesTTLPicker
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
|
||||
|
||||
@Composable
|
||||
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
|
||||
@@ -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()
|
||||
@@ -71,14 +72,14 @@ private fun GroupPreferencesLayout(
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.group_preferences))
|
||||
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(MR.strings.delete_after),
|
||||
dropdownValues = TimedMessagesPreference.ttlValues,
|
||||
customPickerTitle = generalGetString(MR.strings.delete_after),
|
||||
customPickerConfirmButtonText = generalGetString(MR.strings.custom_time_picker_select),
|
||||
onSelected = onTTLUpdated
|
||||
)
|
||||
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||
}
|
||||
} else {
|
||||
InfoRow(
|
||||
@@ -164,7 +146,7 @@ private fun FeatureSection(
|
||||
iconTint = iconTint,
|
||||
)
|
||||
if (timedOn) {
|
||||
InfoRow(generalGetString(MR.strings.delete_after), timeText(preferences.timedMessages.ttl))
|
||||
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,19 +157,19 @@ private fun FeatureSection(
|
||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
SectionItemView(reset, disabled = disabled) {
|
||||
Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
Text(stringResource(R.string.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionItemView(save, disabled = disabled) {
|
||||
Text(stringResource(MR.strings.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(MR.strings.save_preferences_question),
|
||||
confirmText = generalGetString(MR.strings.save_and_notify_group_members),
|
||||
dismissText = generalGetString(MR.strings.exit_without_saving),
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
@@ -1,7 +1,8 @@
|
||||
package chat.simplex.common.views.chat.group
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import SectionBottomSpacer
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -12,22 +13,23 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
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.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.ProfileNameField
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.isValidDisplayName
|
||||
import chat.simplex.common.views.onboarding.ReadableText
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.ProfileNameField
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
|
||||
@@ -55,7 +57,7 @@ fun GroupProfileLayout(
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) }
|
||||
val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) }
|
||||
val chosenImage = rememberSaveable { mutableStateOf<URI?>(null) }
|
||||
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -103,7 +105,7 @@ fun GroupProfileLayout(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
ReadableText(MR.strings.group_profile_is_stored_on_members_devices, TextAlign.Center)
|
||||
ReadableText(R.string.group_profile_is_stored_on_members_devices, TextAlign.Center)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -122,13 +124,13 @@ fun GroupProfileLayout(
|
||||
}
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(MR.strings.group_display_name_field),
|
||||
stringResource(R.string.group_display_name_field),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
if (!isValidDisplayName(displayName.value)) {
|
||||
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||
Text(
|
||||
stringResource(MR.strings.no_spaces),
|
||||
stringResource(R.string.no_spaces),
|
||||
fontSize = 16.sp,
|
||||
color = Color.Red
|
||||
)
|
||||
@@ -137,7 +139,7 @@ fun GroupProfileLayout(
|
||||
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Text(
|
||||
stringResource(MR.strings.group_full_name_field),
|
||||
stringResource(R.string.group_full_name_field),
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
@@ -146,7 +148,7 @@ fun GroupProfileLayout(
|
||||
val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
if (enabled) {
|
||||
Text(
|
||||
stringResource(MR.strings.save_group_profile),
|
||||
stringResource(R.string.save_group_profile),
|
||||
modifier = Modifier.clickable {
|
||||
saveProfile(
|
||||
groupProfile.copy(
|
||||
@@ -160,7 +162,7 @@ fun GroupProfileLayout(
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(MR.strings.save_group_profile),
|
||||
stringResource(R.string.save_group_profile),
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
@@ -180,19 +182,20 @@ fun GroupProfileLayout(
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(MR.strings.save_preferences_question),
|
||||
confirmText = generalGetString(MR.strings.save_and_notify_group_members),
|
||||
dismissText = generalGetString(MR.strings.exit_without_saving),
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewGroupProfileLayout() {
|
||||
SimpleXTheme {
|
||||
@@ -0,0 +1,157 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import TextIconSpaced
|
||||
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.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 chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.lang.Exception
|
||||
|
||||
@Composable
|
||||
fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
|
||||
var groupInfo by remember { mutableStateOf(groupInfo) }
|
||||
val welcomeText = remember { mutableStateOf(groupInfo.groupProfile.description ?: "") }
|
||||
|
||||
fun save(afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
var welcome: String? = welcomeText.value.trim('\n', ' ')
|
||||
if (welcome?.length == 0) {
|
||||
welcome = null
|
||||
}
|
||||
val groupProfileUpdated = groupInfo.groupProfile.copy(description = welcome)
|
||||
val res = m.controller.apiUpdateGroup(groupInfo.groupId, groupProfileUpdated)
|
||||
if (res != null) {
|
||||
groupInfo = res
|
||||
m.updateGroup(res)
|
||||
welcomeText.value = welcome ?: ""
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
|
||||
ModalView(
|
||||
close = {
|
||||
if (welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)) close()
|
||||
else showUnsavedChangesAlert({ save(close) }, close)
|
||||
},
|
||||
) {
|
||||
GroupWelcomeLayout(
|
||||
welcomeText,
|
||||
groupInfo,
|
||||
m.controller.appPrefs.simplexLinkMode.get(),
|
||||
save = ::save
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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 welcomeText = rememberSaveable { welcomeText }
|
||||
if (groupInfo.canEdit) {
|
||||
if (editMode.value) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
TextEditor(welcomeText, Modifier.heightIn(min = 100.dp), stringResource(R.string.enter_welcome_message), focusRequester = focusRequester)
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
} else {
|
||||
TextEditorPreview(welcomeText.value, linkMode)
|
||||
}
|
||||
ChangeModeButton(
|
||||
editMode.value,
|
||||
click = {
|
||||
editMode.value = !editMode.value
|
||||
},
|
||||
welcomeText.value.isEmpty()
|
||||
)
|
||||
CopyTextButton { copyText(SimplexApp.context, welcomeText.value) }
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
SaveButton(
|
||||
save = save,
|
||||
disabled = welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)
|
||||
)
|
||||
} else {
|
||||
TextEditorPreview(welcomeText.value, linkMode)
|
||||
CopyTextButton { copyText(SimplexApp.context, welcomeText.value) }
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveButton(save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
SectionItemView(save, disabled = disabled) {
|
||||
Text(stringResource(R.string.save_and_update_group_profile), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
confirmText = generalGetString(R.string.save_and_update_group_profile),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@@ -6,14 +6,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
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.common.ui.theme.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.views.helpers.SimpleButton
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
|
||||
@@ -22,20 +21,20 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
@Composable fun ConnectingCallIcon() = Icon(painterResource(MR.images.ic_settings_phone), stringResource(MR.strings.icon_descr_call_connecting), tint = SimplexGreen)
|
||||
@Composable fun ConnectingCallIcon() = Icon(painterResource(R.drawable.ic_settings_phone), stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
|
||||
when (status) {
|
||||
CICallStatus.Pending -> if (sent) {
|
||||
Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_pending_sent))
|
||||
Icon(painterResource(R.drawable.ic_call), stringResource(R.string.icon_descr_call_pending_sent))
|
||||
} else {
|
||||
AcceptCallButton(cInfo, acceptCall)
|
||||
}
|
||||
CICallStatus.Missed -> Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_missed), tint = Color.Red)
|
||||
CICallStatus.Rejected -> Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_rejected), tint = Color.Red)
|
||||
CICallStatus.Missed -> Icon(painterResource(R.drawable.ic_call), stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
|
||||
CICallStatus.Rejected -> Icon(painterResource(R.drawable.ic_call_end), stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
|
||||
CICallStatus.Accepted -> ConnectingCallIcon()
|
||||
CICallStatus.Negotiated -> ConnectingCallIcon()
|
||||
CICallStatus.Progress -> Icon(painterResource(MR.images.ic_phone_in_talk_filled), stringResource(MR.strings.icon_descr_call_progress), tint = SimplexGreen)
|
||||
CICallStatus.Progress -> Icon(painterResource(R.drawable.ic_phone_in_talk_filled), stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
||||
CICallStatus.Ended -> Row {
|
||||
Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp))
|
||||
Icon(painterResource(R.drawable.ic_call_end), stringResource(R.string.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(durationText(duration), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
CICallStatus.Error -> {}
|
||||
@@ -53,9 +52,9 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
||||
@Composable
|
||||
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
SimpleButton(stringResource(MR.strings.answer_call), painterResource(MR.images.ic_ring_volume)) { acceptCall(cInfo.contact) }
|
||||
SimpleButton(stringResource(R.string.answer_call), painterResource(R.drawable.ic_ring_volume)) { acceptCall(cInfo.contact) }
|
||||
} else {
|
||||
Icon(painterResource(MR.images.ic_ring_volume), stringResource(MR.strings.answer_call), tint = MaterialTheme.colors.secondary)
|
||||
Icon(painterResource(R.drawable.ic_ring_volume), stringResource(R.string.answer_call), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
// if case let .direct(contact) = chatInfo {
|
||||
// Button {
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@@ -9,8 +9,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.common.model.Feature
|
||||
import chat.simplex.app.model.*
|
||||
|
||||
@Composable
|
||||
fun CIChatFeatureView(
|
||||
@@ -1,6 +1,6 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.*
|
||||
@@ -9,10 +9,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CIEventView(ci: ChatItem) {
|
||||
@@ -45,10 +46,11 @@ fun chatEventText(ci: ChatItem): AnnotatedString =
|
||||
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun CIEventViewPreview() {
|
||||
SimpleXTheme {
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@@ -9,9 +9,9 @@ import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun CIFeaturePreferenceView(
|
||||
@@ -30,7 +30,7 @@ fun CIFeaturePreferenceView(
|
||||
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
|
||||
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
|
||||
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
|
||||
val acceptTextId = if (setParam) MR.strings.accept_feature_set_1_day else MR.strings.accept_feature
|
||||
val acceptTextId = if (setParam) R.string.accept_feature_set_1_day else R.string.accept_feature
|
||||
val param = if (setParam) 86400 else null
|
||||
val annotatedText = buildAnnotatedString {
|
||||
withStyle(chatEventStyle) { append(chatItem.content.text + " ") }
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
@@ -11,18 +12,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun CIFileView(
|
||||
@@ -30,7 +31,8 @@ fun CIFileView(
|
||||
edited: Boolean,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
|
||||
val context = LocalContext.current
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = file)
|
||||
|
||||
@Composable
|
||||
fun fileIcon(
|
||||
@@ -41,15 +43,15 @@ fun CIFileView(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_draft_filled),
|
||||
stringResource(MR.strings.icon_descr_file),
|
||||
painterResource(R.drawable.ic_draft_filled),
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = color
|
||||
)
|
||||
if (innerIcon != null) {
|
||||
Icon(
|
||||
innerIcon,
|
||||
stringResource(MR.strings.icon_descr_file),
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.size(32.dp)
|
||||
.padding(top = 12.dp),
|
||||
@@ -74,8 +76,8 @@ fun CIFileView(
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -83,23 +85,21 @@ fun CIFileView(
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.waiting_for_file),
|
||||
generalGetString(MR.strings.file_will_be_received_when_contact_completes_uploading)
|
||||
generalGetString(R.string.waiting_for_file),
|
||||
generalGetString(R.string.file_will_be_received_when_contact_completes_uploading)
|
||||
)
|
||||
FileProtocol.SMP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.waiting_for_file),
|
||||
generalGetString(MR.strings.file_will_be_received_when_contact_is_online)
|
||||
generalGetString(R.string.waiting_for_file),
|
||||
generalGetString(R.string.file_will_be_received_when_contact_is_online)
|
||||
)
|
||||
}
|
||||
is CIFileStatus.RcvComplete -> {
|
||||
val filePath = getLoadedFilePath(file)
|
||||
val filePath = getLoadedFilePath(context, file)
|
||||
if (filePath != null) {
|
||||
withApi {
|
||||
saveFileLauncher.launch(file.fileName)
|
||||
}
|
||||
saveFileLauncher.launch(file.fileName)
|
||||
} else {
|
||||
showToast(generalGetString(MR.strings.file_not_found))
|
||||
Toast.makeText(context, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
@@ -151,15 +151,15 @@ fun CIFileView(
|
||||
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||
FileProtocol.SMP -> progressIndicator()
|
||||
}
|
||||
is CIFileStatus.SndComplete -> fileIcon(innerIcon = painterResource(MR.images.ic_check_filled))
|
||||
is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
||||
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
||||
is CIFileStatus.SndComplete -> fileIcon(innerIcon = painterResource(R.drawable.ic_check_filled))
|
||||
is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||
is CIFileStatus.RcvInvitation ->
|
||||
if (fileSizeValid())
|
||||
fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary)
|
||||
fileIcon(innerIcon = painterResource(R.drawable.ic_arrow_downward), color = MaterialTheme.colors.primary)
|
||||
else
|
||||
fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = painterResource(MR.images.ic_more_horiz))
|
||||
fileIcon(innerIcon = painterResource(R.drawable.ic_priority_high), color = WarningOrange)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = painterResource(R.drawable.ic_more_horiz))
|
||||
is CIFileStatus.RcvTransfer ->
|
||||
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||
@@ -167,8 +167,8 @@ fun CIFileView(
|
||||
progressIndicator()
|
||||
}
|
||||
is CIFileStatus.RcvComplete -> fileIcon()
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
||||
is CIFileStatus.RcvError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||
is CIFileStatus.RcvError -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||
}
|
||||
} else {
|
||||
fileIcon()
|
||||
@@ -205,23 +205,12 @@ fun CIFileView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
|
||||
rememberFileChooserLauncher(false) { to: URI? ->
|
||||
val filePath = getLoadedFilePath(ciFile)
|
||||
if (filePath != null && to != null) {
|
||||
copyFileToFile(File(filePath), to) {}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||
private val sentFile = ChatItem(
|
||||
chatDir = CIDirection.DirectSnd(),
|
||||
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(
|
||||
@@ -229,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(
|
||||
@@ -253,4 +241,4 @@ fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatI
|
||||
SimpleXTheme {
|
||||
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
|
||||
}
|
||||
}*/
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -8,15 +8,16 @@ import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
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.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun CIGroupInvitationView(
|
||||
@@ -42,7 +43,7 @@ fun CIGroupInvitationView(
|
||||
.padding(vertical = 4.dp)
|
||||
.padding(end = 2.dp)
|
||||
) {
|
||||
ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled, color = iconColor)
|
||||
ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = R.drawable.ic_supervised_user_circle_filled, color = iconColor)
|
||||
Spacer(Modifier.padding(horizontal = 3.dp))
|
||||
Column(
|
||||
Modifier.defaultMinSize(minHeight = 60.dp),
|
||||
@@ -59,11 +60,11 @@ fun CIGroupInvitationView(
|
||||
@Composable
|
||||
fun groupInvitationText() {
|
||||
when {
|
||||
sent -> Text(stringResource(MR.strings.you_sent_group_invitation))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Pending -> Text(stringResource(MR.strings.you_are_invited_to_group))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Accepted -> Text(stringResource(MR.strings.you_joined_this_group))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Rejected -> Text(stringResource(MR.strings.you_rejected_group_invitation))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Expired -> Text(stringResource(MR.strings.group_invitation_expired))
|
||||
sent -> Text(stringResource(R.string.you_sent_group_invitation))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Pending -> Text(stringResource(R.string.you_are_invited_to_group))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Accepted -> Text(stringResource(R.string.you_joined_this_group))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Rejected -> Text(stringResource(R.string.you_rejected_group_invitation))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Expired -> Text(stringResource(R.string.group_invitation_expired))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +95,7 @@ fun CIGroupInvitationView(
|
||||
if (action) {
|
||||
groupInvitationText()
|
||||
Text(stringResource(
|
||||
if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join),
|
||||
if (chatIncognito) R.string.group_invitation_tap_to_join_incognito else R.string.group_invitation_tap_to_join),
|
||||
color = if (chatIncognito) Indigo else MaterialTheme.colors.primary)
|
||||
} else {
|
||||
Box(Modifier.padding(end = 48.dp)) {
|
||||
@@ -113,10 +114,11 @@ fun CIGroupInvitationView(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PendingCIGroupInvitationViewPreview() {
|
||||
SimpleXTheme {
|
||||
@@ -129,10 +131,11 @@ fun PendingCIGroupInvitationViewPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun CIGroupInvitationViewAcceptedPreview() {
|
||||
SimpleXTheme {
|
||||
@@ -145,7 +148,7 @@ fun CIGroupInvitationViewAcceptedPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun CIGroupInvitationViewLongNamePreview() {
|
||||
SimpleXTheme {
|
||||
@@ -1,5 +1,8 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -8,22 +11,28 @@ import androidx.compose.material.Icon
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import coil.ImageLoader
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.request.ImageRequest
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
fun CIImageView(
|
||||
@@ -43,7 +52,7 @@ fun CIImageView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun fileIcon(icon: Painter, stringId: StringResource) {
|
||||
fun fileIcon(icon: Painter, @StringRes stringId: Int) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(stringId),
|
||||
@@ -68,14 +77,14 @@ fun CIImageView(
|
||||
FileProtocol.SMP -> {}
|
||||
}
|
||||
is CIFileStatus.SndTransfer -> progressIndicator()
|
||||
is CIFileStatus.SndComplete -> fileIcon(painterResource(MR.images.ic_check_filled), MR.strings.icon_descr_image_snd_complete)
|
||||
is CIFileStatus.SndCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_asked_to_receive)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_image)
|
||||
is CIFileStatus.SndComplete -> fileIcon(painterResource(R.drawable.ic_check_filled), R.string.icon_descr_image_snd_complete)
|
||||
is CIFileStatus.SndCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||
is CIFileStatus.SndError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(R.drawable.ic_arrow_downward), R.string.icon_descr_asked_to_receive)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(R.drawable.ic_more_horiz), R.string.icon_descr_waiting_for_image)
|
||||
is CIFileStatus.RcvTransfer -> progressIndicator()
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||
is CIFileStatus.RcvError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -85,14 +94,14 @@ fun CIImageView(
|
||||
@Composable
|
||||
fun imageViewFullWidth(): Dp {
|
||||
val approximatePadding = 100.dp
|
||||
return with(LocalDensity.current) { minOf(1000.dp, LocalWindowWidth() - approximatePadding) }
|
||||
return with(LocalDensity.current) { minOf(1000.dp, LocalView.current.width.toDp() - approximatePadding) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun imageView(imageBitmap: ImageBitmap, onClick: () -> Unit) {
|
||||
fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
|
||||
Image(
|
||||
imageBitmap,
|
||||
contentDescription = stringResource(MR.strings.image_descr),
|
||||
imageBitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
|
||||
// if text is short and take all available width if text is long
|
||||
modifier = Modifier
|
||||
@@ -106,10 +115,10 @@ fun CIImageView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageView(painter: Painter, onClick: () -> Unit) {
|
||||
fun imageView(painter: Painter, onClick: () -> Unit) {
|
||||
Image(
|
||||
painter,
|
||||
contentDescription = stringResource(MR.strings.image_descr),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
|
||||
// if text is short and take all available width if text is long
|
||||
modifier = Modifier
|
||||
@@ -129,9 +138,9 @@ fun CIImageView(
|
||||
return false
|
||||
}
|
||||
|
||||
fun imageAndFilePath(file: CIFile?): Pair<ImageBitmap?, String?> {
|
||||
val imageBitmap: ImageBitmap? = getLoadedImage(file)
|
||||
val filePath = getLoadedFilePath(file)
|
||||
fun imageAndFilePath(file: CIFile?): Pair<Bitmap?, String?> {
|
||||
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, file)
|
||||
return imageBitmap to filePath
|
||||
}
|
||||
|
||||
@@ -139,10 +148,24 @@ fun CIImageView(
|
||||
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) }
|
||||
if (imageBitmap != null && filePath != null) {
|
||||
val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) }
|
||||
SimpleAndAnimatedImageView(uri, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) })
|
||||
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
|
||||
val imagePainter = rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(context).data(data = uri).size(coil.size.Size.ORIGINAL).build(),
|
||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
)
|
||||
val view = LocalView.current
|
||||
imageView(imagePainter, onClick = {
|
||||
hideKeyboard(view)
|
||||
if (getLoadedFilePath(context, file) != null) {
|
||||
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||
ImageFullScreenView(imageProvider, close)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
imageView(base64ToBitmap(image), onClick = {
|
||||
if (file != null) {
|
||||
@@ -152,21 +175,21 @@ fun CIImageView(
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
)
|
||||
}
|
||||
CIFileStatus.RcvAccepted ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.waiting_for_image),
|
||||
generalGetString(MR.strings.image_will_be_received_when_contact_completes_uploading)
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_completes_uploading)
|
||||
)
|
||||
FileProtocol.SMP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.waiting_for_image),
|
||||
generalGetString(MR.strings.image_will_be_received_when_contact_is_online)
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||
)
|
||||
}
|
||||
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||
@@ -181,11 +204,12 @@ fun CIImageView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun SimpleAndAnimatedImageView(
|
||||
uri: URI,
|
||||
imageBitmap: ImageBitmap,
|
||||
file: CIFile?,
|
||||
imageProvider: () -> ImageGalleryProvider,
|
||||
ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit
|
||||
)
|
||||
private val imageLoader = ImageLoader.Builder(SimplexApp.context)
|
||||
.components {
|
||||
if (SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -7,16 +8,15 @@ import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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 chat.simplex.common.platform.shareText
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
|
||||
@Composable
|
||||
fun CIInvalidJSONView(json: String) {
|
||||
@@ -24,7 +24,7 @@ fun CIInvalidJSONView(json: String) {
|
||||
.clickable { ModalManager.shared.showModal(true) { InvalidJSONView(json) } }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(stringResource(MR.strings.invalid_data), color = Color.Red, fontStyle = FontStyle.Italic)
|
||||
Text(stringResource(R.string.invalid_data), color = Color.Red, fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ fun InvalidJSONView(json: String) {
|
||||
Column {
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
SectionView {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
SettingsActionItem(painterResource(MR.images.ic_share), generalGetString(MR.strings.share_verb), click = {
|
||||
clipboard.shareText(json)
|
||||
val context = LocalContext.current
|
||||
SettingsActionItem(painterResource(R.drawable.ic_share), generalGetString(R.string.share_verb), click = {
|
||||
shareText(context, json)
|
||||
})
|
||||
}
|
||||
Column(Modifier.padding(DEFAULT_PADDING).fillMaxWidth().verticalScroll(rememberScrollState())) {
|
||||
@@ -1,20 +1,20 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import chat.simplex.app.R
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.CurrentColors
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
@@ -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 {
|
||||
@@ -37,31 +37,27 @@ fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = Ma
|
||||
// changing this function requires updating reserveSpaceForMeta
|
||||
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
|
||||
if (meta.itemEdited) {
|
||||
StatusIconText(painterResource(MR.images.ic_edit), color)
|
||||
StatusIconText(painterResource(R.drawable.ic_edit), color)
|
||||
Spacer(Modifier.width(3.dp))
|
||||
}
|
||||
if (meta.disappearing) {
|
||||
StatusIconText(painterResource(MR.images.ic_timer), 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))
|
||||
}
|
||||
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
|
||||
if (statusIcon != null) {
|
||||
val (icon, statusColor) = statusIcon
|
||||
if (meta.itemStatus is CIStatus.SndSent || meta.itemStatus is CIStatus.SndRcvd) {
|
||||
Icon(painterResource(icon), null, Modifier.height(17.dp), tint = statusColor)
|
||||
} else {
|
||||
StatusIconText(painterResource(icon), statusColor)
|
||||
}
|
||||
StatusIconText(painterResource(icon), statusColor)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
} else if (!meta.disappearing) {
|
||||
StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent)
|
||||
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
|
||||
@@ -73,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) {
|
||||
@@ -163,7 +159,7 @@ fun PreviewCIMetaViewEditedUnread() {
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
|
||||
itemEdited = true,
|
||||
status= CIStatus.RcvNew()
|
||||
status=CIStatus.RcvNew()
|
||||
),
|
||||
null
|
||||
)
|
||||
@@ -176,7 +172,7 @@ fun PreviewCIMetaViewEditedSent() {
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
itemEdited = true,
|
||||
status= CIStatus.SndSent()
|
||||
status=CIStatus.SndSent()
|
||||
),
|
||||
null
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun CIRcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean) {
|
||||
CIMsgError(ci, timedMessagesTTL, showMember) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.decryption_error),
|
||||
text = when (msgDecryptError) {
|
||||
MsgDecryptError.RatchetHeader -> String.format(generalGetString(R.string.alert_text_decryption_error_header), msgCount.toLong()) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_permanent_error_reconnect)
|
||||
MsgDecryptError.TooManySkipped -> String.format(generalGetString(R.string.alert_text_decryption_error_too_many_skipped), msgCount.toLong()) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_permanent_error_reconnect)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
@@ -8,21 +12,25 @@ import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.*
|
||||
import androidx.compose.ui.platform.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
fun CIVideoView(
|
||||
@@ -37,11 +45,12 @@ fun CIVideoView(
|
||||
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val filePath = remember(file) { getLoadedFilePath(file) }
|
||||
val context = LocalContext.current
|
||||
val filePath = remember(file) { getLoadedFilePath(SimplexApp.context, file) }
|
||||
val preview = remember(image) { base64ToBitmap(image) }
|
||||
if (file != null && filePath != null) {
|
||||
val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) }
|
||||
val view = LocalMultiplatformView()
|
||||
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
|
||||
val view = LocalView.current
|
||||
VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = {
|
||||
hideKeyboard(view)
|
||||
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||
@@ -59,14 +68,14 @@ fun CIVideoView(
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.waiting_for_video),
|
||||
generalGetString(MR.strings.video_will_be_received_when_contact_completes_uploading)
|
||||
generalGetString(R.string.waiting_for_video),
|
||||
generalGetString(R.string.video_will_be_received_when_contact_completes_uploading)
|
||||
)
|
||||
|
||||
FileProtocol.SMP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.waiting_for_video),
|
||||
generalGetString(MR.strings.video_will_be_received_when_contact_is_online)
|
||||
generalGetString(R.string.waiting_for_video),
|
||||
generalGetString(R.string.video_will_be_received_when_contact_is_online)
|
||||
)
|
||||
}
|
||||
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||
@@ -89,13 +98,14 @@ fun CIVideoView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true) }
|
||||
private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true, context) }
|
||||
val videoPlaying = remember(uri.path) { player.videoPlaying }
|
||||
val progress = remember(uri.path) { player.progress }
|
||||
val duration = remember(uri.path) { player.duration }
|
||||
val preview by remember { player.preview }
|
||||
// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled }
|
||||
// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled }
|
||||
val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo }
|
||||
val play = {
|
||||
player.enableSound(true)
|
||||
@@ -114,12 +124,20 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
|
||||
Box {
|
||||
val windowWidth = LocalWindowWidth()
|
||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||
PlayerView(
|
||||
player,
|
||||
width,
|
||||
onClick = onClick,
|
||||
onLongClick = { showMenu.value = true },
|
||||
stop
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
StyledPlayerView(ctx).apply {
|
||||
useController = false
|
||||
resizeMode = RESIZE_MODE_FIXED_WIDTH
|
||||
this.player = player.player
|
||||
}
|
||||
},
|
||||
Modifier
|
||||
.width(width)
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = { if (player.player.playWhenReady) stop() else onClick() }
|
||||
)
|
||||
)
|
||||
if (showPreview.value) {
|
||||
ImageView(preview, showMenu, onClick)
|
||||
@@ -129,9 +147,6 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit)
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) {
|
||||
Surface(
|
||||
@@ -146,7 +161,7 @@ private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_play_arrow_filled),
|
||||
painterResource(R.drawable.ic_play_arrow_filled),
|
||||
contentDescription = null,
|
||||
tint = if (error) WarningOrange else Color.White
|
||||
)
|
||||
@@ -174,7 +189,7 @@ private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, durat
|
||||
color = Color.White
|
||||
)
|
||||
/*if (!soundEnabled.value) {
|
||||
Icon(painterResource(MR.images.ic_volume_off_filled), null,
|
||||
Icon(painterResource(R.drawable.ic_volume_off_filled), null,
|
||||
Modifier.padding(start = 5.dp).size(10.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
@@ -200,12 +215,12 @@ private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, durat
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
private fun ImageView(preview: Bitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
val windowWidth = LocalWindowWidth()
|
||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||
Image(
|
||||
preview,
|
||||
contentDescription = stringResource(MR.strings.video_descr),
|
||||
preview.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.video_descr),
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.combinedClickable(
|
||||
@@ -217,7 +232,15 @@ private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onC
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun LocalWindowWidth(): Dp
|
||||
private fun LocalWindowWidth(): Dp {
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current.density
|
||||
return remember {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
(rect.width() / density).dp
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun progressIndicator() {
|
||||
@@ -229,7 +252,7 @@ private fun progressIndicator() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fileIcon(icon: Painter, stringId: StringResource) {
|
||||
private fun fileIcon(icon: Painter, @StringRes stringId: Int) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(stringId),
|
||||
@@ -272,19 +295,19 @@ private fun loadingIndicator(file: CIFile?) {
|
||||
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||
FileProtocol.SMP -> progressIndicator()
|
||||
}
|
||||
is CIFileStatus.SndComplete -> fileIcon(painterResource(MR.images.ic_check_filled), MR.strings.icon_descr_video_snd_complete)
|
||||
is CIFileStatus.SndCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_video)
|
||||
is CIFileStatus.SndComplete -> fileIcon(painterResource(R.drawable.ic_check_filled), R.string.icon_descr_video_snd_complete)
|
||||
is CIFileStatus.SndCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||
is CIFileStatus.SndError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(R.drawable.ic_arrow_downward), R.string.icon_descr_video_asked_to_receive)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(R.drawable.ic_more_horiz), R.string.icon_descr_waiting_for_video)
|
||||
is CIFileStatus.RcvTransfer ->
|
||||
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||
} else {
|
||||
progressIndicator()
|
||||
}
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||
is CIFileStatus.RcvError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -303,8 +326,8 @@ private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -9,21 +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 dev.icerock.moko.resources.compose.painterResource
|
||||
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.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.getLoadedFilePath
|
||||
import chat.simplex.common.platform.AudioPlayer
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
|
||||
|
||||
@@ -37,14 +36,14 @@ 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) {
|
||||
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(file) }
|
||||
val context = LocalContext.current
|
||||
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
|
||||
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
|
||||
@@ -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 = LocalWindowWidth()
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,7 +173,7 @@ private fun PlayPauseButton(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
if (audioPlaying) painterResource(MR.images.ic_pause_filled) else painterResource(MR.images.ic_play_arrow_filled),
|
||||
if (audioPlaying) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||
contentDescription = null,
|
||||
Modifier.size(36.dp),
|
||||
tint = if (error) WarningOrange else if (!enabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
@@ -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
|
||||
@@ -256,7 +202,7 @@ private fun VoiceMsgIndicator(
|
||||
if (hasText) {
|
||||
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) {
|
||||
Icon(
|
||||
if (audioPlaying) painterResource(MR.images.ic_pause_filled) else painterResource(MR.images.ic_play_arrow_filled),
|
||||
if (audioPlaying) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||
contentDescription = null,
|
||||
Modifier.size(36.dp),
|
||||
tint = 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(
|
||||
@@ -0,0 +1,457 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
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.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.ComposeContextItem
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||
|
||||
@Composable
|
||||
fun ChatItemView(
|
||||
cInfo: ChatInfo,
|
||||
cItem: ChatItem,
|
||||
composeState: MutableState<ComposeState>,
|
||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||
showMember: Boolean = false,
|
||||
useLinkPreviews: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
scrollToItem: (Long) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val sent = cItem.chatDir.sent
|
||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val revealed = remember { mutableStateOf(false) }
|
||||
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
val live = composeState.value.liveMessage != null
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
val onClick = {
|
||||
when (cItem.meta.itemStatus) {
|
||||
is CIStatus.SndErrorAuth -> {
|
||||
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
|
||||
}
|
||||
is CIStatus.SndError -> {
|
||||
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
)
|
||||
}
|
||||
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()
|
||||
} 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 {
|
||||
framedItemView()
|
||||
MsgContentItemDropdownMenu()
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CancelFileItemAction(
|
||||
fileId: Long,
|
||||
showMenu: MutableState<Boolean>,
|
||||
cancelFile: (Long) -> Unit,
|
||||
cancelAction: CancelAction
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(cancelAction.uiActionId),
|
||||
painterResource(R.drawable.ic_close),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
cancelFileAlertDialog(fileId, cancelFile = cancelFile, cancelAction = cancelAction)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItemAction(
|
||||
cItem: ChatItem,
|
||||
showMenu: MutableState<Boolean>,
|
||||
questionText: String,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
painterResource(R.drawable.ic_delete),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModerateItemAction(
|
||||
cItem: ChatItem,
|
||||
questionText: String,
|
||||
showMenu: MutableState<Boolean>,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.moderate_verb),
|
||||
painterResource(R.drawable.ic_flag),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||
val finalColor = if (color == Color.Unspecified) {
|
||||
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||
} else color
|
||||
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = finalColor
|
||||
)
|
||||
Icon(icon, text, tint = finalColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||
val finalColor = if (color == Color.Unspecified) {
|
||||
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||
} else color
|
||||
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = finalColor
|
||||
)
|
||||
Icon(icon, text, tint = finalColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(cancelAction.alert.titleId),
|
||||
text = generalGetString(cancelAction.alert.messageId),
|
||||
confirmText = generalGetString(cancelAction.alert.confirmId),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
cancelFile(fileId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(R.string.delete_message__question),
|
||||
text = questionText,
|
||||
buttons = {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_me_only), color = MaterialTheme.colors.error) }
|
||||
if (chatItem.meta.editable) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_everybody), color = MaterialTheme.colors.error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.delete_member_message__question),
|
||||
text = questionText,
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun showMsgDeliveryErrorAlert(description: String) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.message_delivery_error_title),
|
||||
text = description,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemView() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
acceptFeature = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemViewDeletedContent() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
acceptFeature = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@@ -9,11 +10,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
@@ -41,10 +42,11 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean =
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.app.model.ChatItem
|
||||
|
||||
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
|
||||
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -14,18 +14,18 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.*
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.base64ToBitmap
|
||||
import chat.simplex.res.MR
|
||||
import androidx.compose.ui.util.fastMap
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -116,10 +116,10 @@ fun FramedItemView(
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
val imageBitmap = base64ToBitmap(qi.content.image)
|
||||
val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
contentDescription = stringResource(MR.strings.image_descr),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
)
|
||||
@@ -128,10 +128,10 @@ fun FramedItemView(
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
val imageBitmap = base64ToBitmap(qi.content.image)
|
||||
val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
contentDescription = stringResource(MR.strings.video_descr),
|
||||
contentDescription = stringResource(R.string.video_descr),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
)
|
||||
@@ -141,8 +141,8 @@ fun FramedItemView(
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
Icon(
|
||||
if (qi.content is MsgContent.MCFile) painterResource(MR.images.ic_draft_filled) else painterResource(MR.images.ic_mic_filled),
|
||||
if (qi.content is MsgContent.MCFile) stringResource(MR.strings.icon_descr_file) else stringResource(MR.strings.voice_message),
|
||||
if (qi.content is MsgContent.MCFile) painterResource(R.drawable.ic_draft_filled) else painterResource(R.drawable.ic_mic_filled),
|
||||
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
|
||||
Modifier
|
||||
.padding(top = 6.dp, end = 4.dp)
|
||||
.size(22.dp),
|
||||
@@ -182,12 +182,12 @@ fun FramedItemView(
|
||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||
if (ci.meta.itemDeleted != null) {
|
||||
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||
FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
|
||||
FramedItemHeader(String.format(stringResource(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(R.drawable.ic_flag))
|
||||
} else {
|
||||
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
|
||||
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, painterResource(R.drawable.ic_delete))
|
||||
}
|
||||
} else if (ci.meta.isLive) {
|
||||
FramedItemHeader(stringResource(MR.strings.live), false)
|
||||
FramedItemHeader(stringResource(R.string.live), false)
|
||||
}
|
||||
ci.quotedItem?.let { ciQuoteView(it) }
|
||||
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -283,8 +283,8 @@ fun PriorityLayout(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
/**
|
||||
* Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints]
|
||||
* */
|
||||
* Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints]
|
||||
* */
|
||||
fun maxSafeHeight(width: Int) = when { // width bits + height bits should be <= 31
|
||||
width < 0x1FFF /*MaxNonFocusMask*/ -> 0x3FFFF - 1 /* MaxFocusMask */ // 13 bits width + 18 bits height
|
||||
width < 0x7FFF /*MinNonFocusMask*/ -> 0xFFFF - 1 /* MinFocusMask */ // 15 bits width + 16 bits height
|
||||
@@ -300,7 +300,7 @@ fun PriorityLayout(
|
||||
// Find important element which should tell what max width other elements can use
|
||||
// Expecting only one such element. Can be less than one but not more
|
||||
val imagePlaceable = measureable.firstOrNull { it.layoutId == priorityLayoutId }?.measure(constraints)
|
||||
val placeables: List<Placeable> = measureable.map {
|
||||
val placeables: List<Placeable> = measureable.fastMap {
|
||||
if (it.layoutId == priorityLayoutId)
|
||||
imagePlaceable!!
|
||||
else
|
||||
@@ -317,7 +317,6 @@ fun PriorityLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
|
||||
class EditedProvider: PreviewParameterProvider<Boolean> {
|
||||
override val values = listOf(false, true).asSequence()
|
||||
@@ -507,4 +506,3 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
|
||||
)
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -1,23 +1,42 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.ProviderMedia
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.isVisible
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.chat.ProviderMedia
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import coil.ImageLoader
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import com.google.accompanist.pager.*
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
interface ImageGalleryProvider {
|
||||
@@ -29,6 +48,7 @@ interface ImageGalleryProvider {
|
||||
fun onDismiss(index: Int)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) {
|
||||
val provider = remember { imageProvider() }
|
||||
@@ -44,11 +64,11 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val playersToRelease = rememberSaveable { mutableSetOf<URI>() }
|
||||
val playersToRelease = rememberSaveable { mutableSetOf<Uri>() }
|
||||
DisposableEffectOnGone(
|
||||
whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } }
|
||||
)
|
||||
HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
|
||||
HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -120,11 +140,29 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
)
|
||||
}
|
||||
.fillMaxSize()
|
||||
// LALAL
|
||||
// https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24
|
||||
if (media is ProviderMedia.Image) {
|
||||
val (uri: URI, imageBitmap: ImageBitmap) = media
|
||||
FullScreenImageView(modifier, uri, imageBitmap)
|
||||
val (uri: Uri, imageBitmap: Bitmap) = media
|
||||
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
Image(
|
||||
rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else if (media is ProviderMedia.Video) {
|
||||
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
|
||||
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
|
||||
@@ -138,11 +176,9 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap)
|
||||
|
||||
@Composable
|
||||
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) {
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true) }
|
||||
private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true, context) }
|
||||
val isCurrentPage = rememberUpdatedState(currentPage)
|
||||
val play = {
|
||||
player.play(true)
|
||||
@@ -158,9 +194,25 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap,
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
FullScreenVideoView(player, modifier)
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
StyledPlayerView(ctx).apply {
|
||||
resizeMode = if (ctx.resources.configuration.screenWidthDp > ctx.resources.configuration.screenHeightDp) {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
setShowPreviousButton(false)
|
||||
setShowNextButton(false)
|
||||
setShowSubtitleButton(false)
|
||||
setShowVrButton(false)
|
||||
controllerAutoShow = false
|
||||
findViewById<View>(com.google.android.exoplayer2.R.id.exo_controls_background).setBackgroundColor(Color.Black.copy(alpha = 0.3f).toArgb())
|
||||
findViewById<View>(com.google.android.exoplayer2.R.id.exo_settings).isVisible = false
|
||||
this.player = player.player
|
||||
}
|
||||
},
|
||||
modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier)
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -12,16 +13,16 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.common.model.MsgErrorType
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.ui.theme.SimpleXTheme
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.model.MsgErrorType
|
||||
import chat.simplex.app.ui.theme.CurrentColors
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
@@ -29,21 +30,21 @@ fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTT
|
||||
when (msgError) {
|
||||
is MsgErrorType.MsgSkipped ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.alert_title_skipped_messages),
|
||||
text = generalGetString(MR.strings.alert_text_skipped_messages_it_can_happen_when)
|
||||
title = generalGetString(R.string.alert_title_skipped_messages),
|
||||
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
||||
)
|
||||
is MsgErrorType.MsgBadHash ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.alert_title_msg_bad_hash),
|
||||
text = generalGetString(MR.strings.alert_text_msg_bad_hash) + "\n" +
|
||||
generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||
generalGetString(MR.strings.alert_text_fragment_please_report_to_developers)
|
||||
title = generalGetString(R.string.alert_title_msg_bad_hash),
|
||||
text = generalGetString(R.string.alert_text_msg_bad_hash) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_please_report_to_developers)
|
||||
)
|
||||
is MsgErrorType.MsgBadId, is MsgErrorType.MsgDuplicate ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.alert_title_msg_bad_id),
|
||||
text = generalGetString(MR.strings.alert_text_msg_bad_id) + "\n" +
|
||||
generalGetString(MR.strings.alert_text_fragment_please_report_to_developers)
|
||||
title = generalGetString(R.string.alert_title_msg_bad_id),
|
||||
text = generalGetString(R.string.alert_text_msg_bad_id) + "\n" +
|
||||
generalGetString(R.string.alert_text_fragment_please_report_to_developers)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -74,10 +75,11 @@ fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun IntegrityErrorItemViewPreview() {
|
||||
SimpleXTheme {
|
||||
@@ -1,4 +1,6 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@@ -9,15 +11,14 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.CIDeleted
|
||||
import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.datetime.Clock
|
||||
import chat.simplex.app.R
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
@@ -33,9 +34,9 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Bool
|
||||
) {
|
||||
Box(Modifier.weight(1f, false)) {
|
||||
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||
MarkedDeletedText(String.format(generalGetString(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName))
|
||||
MarkedDeletedText(String.format(generalGetString(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName))
|
||||
} else {
|
||||
MarkedDeletedText(generalGetString(MR.strings.marked_deleted_description))
|
||||
MarkedDeletedText(generalGetString(R.string.marked_deleted_description))
|
||||
}
|
||||
}
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
@@ -57,15 +58,16 @@ private fun MarkedDeletedText(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewMarkedDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted(Clock.System.now())),
|
||||
ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted()),
|
||||
null
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.util.Log
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
@@ -15,12 +19,11 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.Log
|
||||
import chat.simplex.common.platform.TAG
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.views.helpers.DisposableEffectOnGone
|
||||
import chat.simplex.common.views.helpers.detectGesture
|
||||
import androidx.core.text.BidiFormatter
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.CurrentColors
|
||||
import chat.simplex.app.views.helpers.detectGesture
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
||||
@@ -53,7 +56,7 @@ private val typingIndicators: List<AnnotatedString> = listOf(
|
||||
)
|
||||
|
||||
|
||||
private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = buildAnnotatedString {
|
||||
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
|
||||
pushStyle(SpanStyle(color = CurrentColors.value.colors.secondary, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
|
||||
append(if (recent) typingIndicators[typingIdx] else noTyping)
|
||||
}
|
||||
@@ -79,7 +82,7 @@ fun MarkdownText (
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
val textLayoutDirection = remember (text) {
|
||||
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
}
|
||||
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
|
||||
"\n"
|
||||
@@ -114,14 +117,18 @@ fun MarkdownText (
|
||||
}
|
||||
}
|
||||
if (meta?.isLive == true) {
|
||||
val activity = LocalContext.current as Activity
|
||||
LaunchedEffect(meta.recent, meta.isLive) {
|
||||
switchTyping()
|
||||
}
|
||||
DisposableEffectOnGone(
|
||||
whenGone = {
|
||||
stopTyping()
|
||||
DisposableEffect(Unit) {
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
if (orientation == activity.resources.configuration.orientation) {
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (formattedText == null) {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
@@ -176,7 +183,7 @@ fun MarkdownText (
|
||||
.firstOrNull()?.let { annotation ->
|
||||
try {
|
||||
uriHandler.openUri(annotation.item)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
|
||||
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
|
||||
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
|
||||
@@ -221,12 +228,12 @@ fun ClickableText(
|
||||
}
|
||||
}
|
||||
}, shouldConsumeEvent = { pos ->
|
||||
var consume = false
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
var consume = false
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
consume
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -243,13 +250,3 @@ fun ClickableText(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun isRtl(s: CharSequence): Boolean {
|
||||
for (element in s) {
|
||||
val d = Character.getDirectionality(element)
|
||||
if (d == Character.DIRECTIONALITY_RIGHT_TO_LEFT || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_EMBEDDING || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_OVERRIDE) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
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.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.usersettings.MarkdownHelpView
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
|
||||
val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
||||
|
||||
@Composable
|
||||
fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp)
|
||||
ReadableTextWithLink(R.string.you_can_connect_to_simplex_chat_founder, simplexTeamUri)
|
||||
Column(
|
||||
Modifier.padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.to_start_a_new_chat_help_header),
|
||||
style = MaterialTheme.typography.h2,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.chat_help_tap_button))
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_person_add),
|
||||
stringResource(R.string.add_contact),
|
||||
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
|
||||
)
|
||||
Text(stringResource(R.string.above_then_preposition_continuation))
|
||||
}
|
||||
Text(annotatedStringResource(R.string.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp)
|
||||
Text(annotatedStringResource(R.string.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
|
||||
Text(stringResource(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp)
|
||||
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
|
||||
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.padding(vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.markdown_in_messages), style = MaterialTheme.typography.h2)
|
||||
MarkdownHelpView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatHelpLayout() {
|
||||
SimpleXTheme {
|
||||
ChatHelpView {}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@@ -8,25 +9,23 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
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.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.chat.group.deleteGroupDialog
|
||||
import chat.simplex.common.views.chat.group.leaveGroupDialog
|
||||
import chat.simplex.common.views.chat.item.InvalidJSONView
|
||||
import chat.simplex.common.views.chat.item.ItemAction
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.ContactConnectionInfoView
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.ntfManager
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.chat.group.deleteGroupDialog
|
||||
import chat.simplex.app.views.chat.group.leaveGroupDialog
|
||||
import chat.simplex.app.views.chat.item.InvalidJSONView
|
||||
import chat.simplex.app.views.chat.item.ItemAction
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.ContactConnectionInfoView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@@ -135,19 +134,8 @@ suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: St
|
||||
|
||||
suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId)
|
||||
val currentMembers = chatModel.groupMembers
|
||||
val newMembers = groupMembers.map { newMember ->
|
||||
val currentMember = currentMembers.find { it.id == newMember.id }
|
||||
val currentMemberStats = currentMember?.activeConn?.connectionStats
|
||||
val newMemberConn = newMember.activeConn
|
||||
if (currentMemberStats != null && newMemberConn != null && newMemberConn.connectionStats == null) {
|
||||
newMember.copy(activeConn = newMemberConn.copy(connectionStats = currentMemberStats))
|
||||
} else {
|
||||
newMember
|
||||
}
|
||||
}
|
||||
chatModel.groupMembers.clear()
|
||||
chatModel.groupMembers.addAll(newMembers)
|
||||
chatModel.groupMembers.addAll(groupMembers)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -157,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)
|
||||
@@ -172,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) {
|
||||
@@ -202,11 +180,11 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM
|
||||
@Composable
|
||||
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.mark_read),
|
||||
painterResource(MR.images.ic_check),
|
||||
stringResource(R.string.mark_read),
|
||||
painterResource(R.drawable.ic_check),
|
||||
onClick = {
|
||||
markChatRead(chat, chatModel)
|
||||
ntfManager.cancelNotificationsForChat(chat.id)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
@@ -215,8 +193,8 @@ fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<
|
||||
@Composable
|
||||
fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.mark_unread),
|
||||
painterResource(MR.images.ic_mark_chat_unread),
|
||||
stringResource(R.string.mark_unread),
|
||||
painterResource(R.drawable.ic_mark_chat_unread),
|
||||
onClick = {
|
||||
markChatUnread(chat, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -224,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(MR.strings.unfavorite_chat) else stringResource(MR.strings.favorite_chat),
|
||||
if (favorite) painterResource(MR.images.ic_star_off) else painterResource(MR.images.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(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat),
|
||||
if (ntfsEnabled) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications),
|
||||
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
|
||||
}
|
||||
)
|
||||
@@ -251,8 +217,8 @@ fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled:
|
||||
@Composable
|
||||
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.clear_chat_menu_action),
|
||||
painterResource(MR.images.ic_settings_backup_restore),
|
||||
stringResource(R.string.clear_chat_menu_action),
|
||||
painterResource(R.drawable.ic_settings_backup_restore),
|
||||
onClick = {
|
||||
clearChatDialog(chat.chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -264,8 +230,8 @@ fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boo
|
||||
@Composable
|
||||
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.delete_contact_menu_action),
|
||||
painterResource(MR.images.ic_delete),
|
||||
stringResource(R.string.delete_contact_menu_action),
|
||||
painterResource(R.drawable.ic_delete),
|
||||
onClick = {
|
||||
deleteContactDialog(chat.chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -277,8 +243,8 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState
|
||||
@Composable
|
||||
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.delete_group_menu_action),
|
||||
painterResource(MR.images.ic_delete),
|
||||
stringResource(R.string.delete_group_menu_action),
|
||||
painterResource(R.drawable.ic_delete),
|
||||
onClick = {
|
||||
deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -291,8 +257,8 @@ fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, sh
|
||||
fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
|
||||
ItemAction(
|
||||
if (chat.chatInfo.incognito) stringResource(MR.strings.join_group_incognito_button) else stringResource(MR.strings.join_group_button),
|
||||
if (chat.chatInfo.incognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_login),
|
||||
if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button),
|
||||
if (chat.chatInfo.incognito) painterResource(R.drawable.ic_theater_comedy_filled) else painterResource(R.drawable.ic_login),
|
||||
color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground,
|
||||
onClick = {
|
||||
joinGroup()
|
||||
@@ -304,8 +270,8 @@ fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, show
|
||||
@Composable
|
||||
fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.leave_group_button),
|
||||
painterResource(MR.images.ic_logout),
|
||||
stringResource(R.string.leave_group_button),
|
||||
painterResource(R.drawable.ic_logout),
|
||||
onClick = {
|
||||
leaveGroupDialog(groupInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -317,8 +283,8 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab
|
||||
@Composable
|
||||
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
if (chatModel.incognito.value) stringResource(MR.strings.accept_contact_incognito_button) else stringResource(MR.strings.accept_contact_button),
|
||||
if (chatModel.incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_check),
|
||||
if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button),
|
||||
if (chatModel.incognito.value) painterResource(R.drawable.ic_theater_comedy_filled) else painterResource(R.drawable.ic_check),
|
||||
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
|
||||
onClick = {
|
||||
acceptContactRequest(chatInfo.apiId, chatInfo, true, chatModel)
|
||||
@@ -326,8 +292,8 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo
|
||||
}
|
||||
)
|
||||
ItemAction(
|
||||
stringResource(MR.strings.reject_contact_button),
|
||||
painterResource(MR.images.ic_close),
|
||||
stringResource(R.string.reject_contact_button),
|
||||
painterResource(R.drawable.ic_close),
|
||||
onClick = {
|
||||
rejectContactRequest(chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -339,8 +305,8 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo
|
||||
@Composable
|
||||
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.set_contact_name),
|
||||
painterResource(MR.images.ic_edit),
|
||||
stringResource(R.string.set_contact_name),
|
||||
painterResource(R.drawable.ic_edit),
|
||||
onClick = {
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
|
||||
@@ -349,8 +315,8 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
|
||||
},
|
||||
)
|
||||
ItemAction(
|
||||
stringResource(MR.strings.delete_verb),
|
||||
painterResource(MR.images.ic_delete),
|
||||
stringResource(R.string.delete_verb),
|
||||
painterResource(R.drawable.ic_delete),
|
||||
onClick = {
|
||||
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
|
||||
showMenu.value = false
|
||||
@@ -362,14 +328,14 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
|
||||
@Composable
|
||||
private fun InvalidDataView() {
|
||||
Row {
|
||||
ProfileImage(72.dp, null, MR.images.ic_account_circle_filled, MaterialTheme.colors.secondary)
|
||||
ProfileImage(72.dp, null, R.drawable.ic_account_circle_filled, MaterialTheme.colors.secondary)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1F)
|
||||
) {
|
||||
Text(
|
||||
stringResource(MR.strings.invalid_data),
|
||||
stringResource(R.string.invalid_data),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
@@ -426,11 +392,11 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
|
||||
|
||||
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.accept_connection_request__question),
|
||||
text = generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
||||
confirmText = if (chatModel.incognito.value) generalGetString(MR.strings.accept_contact_incognito_button) else generalGetString(MR.strings.accept_contact_button),
|
||||
title = generalGetString(R.string.accept_connection_request__question),
|
||||
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
||||
confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button),
|
||||
onConfirm = { acceptContactRequest(contactRequest.apiId, contactRequest, true, chatModel) },
|
||||
dismissText = generalGetString(MR.strings.reject_contact_button),
|
||||
dismissText = generalGetString(R.string.reject_contact_button),
|
||||
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
|
||||
)
|
||||
}
|
||||
@@ -455,12 +421,12 @@ fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: Cha
|
||||
fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(
|
||||
if (connection.initiated) MR.strings.you_invited_your_contact
|
||||
else MR.strings.you_accepted_connection
|
||||
if (connection.initiated) R.string.you_invited_your_contact
|
||||
else R.string.you_accepted_connection
|
||||
),
|
||||
text = generalGetString(
|
||||
if (connection.viaContactUri) MR.strings.you_will_be_connected_when_your_connection_request_is_accepted
|
||||
else MR.strings.you_will_be_connected_when_your_contacts_device_is_online
|
||||
if (connection.viaContactUri) R.string.you_will_be_connected_when_your_connection_request_is_accepted
|
||||
else R.string.you_will_be_connected_when_your_contacts_device_is_online
|
||||
),
|
||||
buttons = {
|
||||
Row(
|
||||
@@ -473,11 +439,11 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
|
||||
AlertManager.shared.hideAlert()
|
||||
deleteContactConnectionAlert(connection, chatModel) {}
|
||||
}) {
|
||||
Text(stringResource(MR.strings.delete_verb))
|
||||
Text(stringResource(R.string.delete_verb))
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
|
||||
Text(stringResource(MR.strings.ok))
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -486,12 +452,12 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
|
||||
|
||||
fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.delete_pending_connection__question),
|
||||
title = generalGetString(R.string.delete_pending_connection__question),
|
||||
text = generalGetString(
|
||||
if (connection.initiated) MR.strings.contact_you_shared_link_with_wont_be_able_to_connect
|
||||
else MR.strings.connection_you_accepted_will_be_cancelled
|
||||
if (connection.initiated) R.string.contact_you_shared_link_with_wont_be_able_to_connect
|
||||
else R.string.connection_you_accepted_will_be_cancelled
|
||||
),
|
||||
confirmText = generalGetString(MR.strings.delete_verb),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
AlertManager.shared.hideAlert()
|
||||
@@ -507,9 +473,9 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel
|
||||
|
||||
fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.alert_title_contact_connection_pending),
|
||||
text = generalGetString(MR.strings.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry),
|
||||
confirmText = generalGetString(MR.strings.button_delete_contact),
|
||||
title = generalGetString(R.string.alert_title_contact_connection_pending),
|
||||
text = generalGetString(R.string.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry),
|
||||
confirmText = generalGetString(R.string.button_delete_contact),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||
@@ -520,26 +486,26 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
}
|
||||
},
|
||||
destructive = true,
|
||||
dismissText = generalGetString(MR.strings.cancel_verb),
|
||||
dismissText = generalGetString(R.string.cancel_verb),
|
||||
)
|
||||
}
|
||||
|
||||
fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.join_group_question),
|
||||
text = generalGetString(MR.strings.you_are_invited_to_group_join_to_connect_with_group_members),
|
||||
confirmText = if (groupInfo.membership.memberIncognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button),
|
||||
title = generalGetString(R.string.join_group_question),
|
||||
text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members),
|
||||
confirmText = if (groupInfo.membership.memberIncognito) generalGetString(R.string.join_group_incognito_button) else generalGetString(R.string.join_group_button),
|
||||
onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } },
|
||||
dismissText = generalGetString(MR.strings.delete_verb),
|
||||
dismissText = generalGetString(R.string.delete_verb),
|
||||
onDismiss = { deleteGroup(groupInfo, chatModel) }
|
||||
)
|
||||
}
|
||||
|
||||
fun cantInviteIncognitoAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.alert_title_cant_invite_contacts),
|
||||
text = generalGetString(MR.strings.alert_title_cant_invite_contacts_descr),
|
||||
confirmText = generalGetString(MR.strings.ok),
|
||||
title = generalGetString(R.string.alert_title_cant_invite_contacts),
|
||||
text = generalGetString(R.string.alert_title_cant_invite_contacts_descr),
|
||||
confirmText = generalGetString(R.string.ok),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -549,35 +515,25 @@ fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
if (r) {
|
||||
chatModel.removeChat(groupInfo.id)
|
||||
chatModel.chatId.value = null
|
||||
ntfManager.cancelNotificationsForChat(groupInfo.id)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(groupInfo.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun groupInvitationAcceptedAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.joining_group),
|
||||
generalGetString(MR.strings.youve_accepted_group_invitation_connecting_to_inviting_group_member)
|
||||
generalGetString(R.string.joining_group),
|
||||
generalGetString(R.string.youve_accepted_group_invitation_connecting_to_inviting_group_member)
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -593,13 +549,10 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo
|
||||
}
|
||||
if (res && newChatInfo != null) {
|
||||
chatModel.updateChatInfo(newChatInfo)
|
||||
if (!chatSettings.enableNtfs) {
|
||||
ntfManager.cancelNotificationsForChat(chat.id)
|
||||
}
|
||||
val current = currentState?.value
|
||||
if (current != null) {
|
||||
currentState.value = !current
|
||||
if (!enabled) {
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
}
|
||||
currentState.value = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -630,11 +583,12 @@ fun ChatListNavLinkLayout(
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkDirect() {
|
||||
SimpleXTheme {
|
||||
@@ -670,11 +624,12 @@ fun PreviewChatListNavLinkDirect() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkGroup() {
|
||||
SimpleXTheme {
|
||||
@@ -710,11 +665,12 @@ fun PreviewChatListNavLinkGroup() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkContactRequest() {
|
||||
SimpleXTheme {
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
@@ -12,29 +13,29 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.WhatsNewView
|
||||
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
|
||||
import chat.simplex.common.views.usersettings.SettingsView
|
||||
import chat.simplex.common.views.usersettings.simplexTeamUri
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.res.MR
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.NewChatSheet
|
||||
import chat.simplex.app.views.onboarding.WhatsNewView
|
||||
import chat.simplex.app.views.onboarding.shouldShowWhatsNew
|
||||
import chat.simplex.app.views.usersettings.SettingsView
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean, FragmentActivity) -> Unit, stopped: Boolean) {
|
||||
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||
val showNewChatSheet = {
|
||||
@@ -86,7 +87,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group))
|
||||
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(R.drawable.ic_edit_filled) else painterResource(R.drawable.ic_close), stringResource(R.string.add_contact_or_create_group))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||
OnboardingButtons(showNewChatSheet)
|
||||
}
|
||||
Text(stringResource(MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
|
||||
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,11 +130,11 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
ConnectButton(generalGetString(MR.strings.chat_with_developers)) {
|
||||
ConnectButton(generalGetString(R.string.chat_with_developers)) {
|
||||
uriHandler.openUriCatching(simplexTeamUri)
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
ConnectButton(generalGetString(MR.strings.tap_to_start_new_chat), openNewChatSheet)
|
||||
ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatSheet)
|
||||
val color = MaterialTheme.colors.primaryVariant
|
||||
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
|
||||
val trianglePath = Path().apply {
|
||||
@@ -175,10 +176,10 @@ 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(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
|
||||
Icon(painterResource(R.drawable.ic_search_500), stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,13 +187,13 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
||||
barButtons.add {
|
||||
IconButton(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
||||
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
||||
)
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_report_filled),
|
||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
||||
painterResource(R.drawable.ic_report_filled),
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
tint = Color.Red,
|
||||
)
|
||||
}
|
||||
@@ -221,21 +222,18 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (chatModel.incognito.value) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_theater_comedy_filled),
|
||||
stringResource(MR.strings.incognito),
|
||||
tint = Indigo,
|
||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(MR.strings.your_chats),
|
||||
stringResource(R.string.your_chats),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (chatModel.chats.size > 0) {
|
||||
ToggleFilterButton()
|
||||
if (chatModel.incognito.value) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_theater_comedy_filled),
|
||||
stringResource(R.string.incognito),
|
||||
tint = Indigo,
|
||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -277,24 +275,6 @@ private fun BoxScope.unreadBadge(text: String? = "") {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleFilterButton() {
|
||||
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
|
||||
IconButton(onClick = { pref.set(!pref.get()) }) {
|
||||
Icon(
|
||||
painterResource(MR.images.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(
|
||||
@@ -306,45 +286,18 @@ private fun ProgressIndicator() {
|
||||
)
|
||||
}
|
||||
|
||||
fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
chatModel.appOpenUrl.value = uri
|
||||
} else {
|
||||
withUriAction(uri) { linkType ->
|
||||
val title = when (linkType) {
|
||||
ConnectionLinkType.CONTACT -> generalGetString(MR.strings.connect_via_contact_link)
|
||||
ConnectionLinkType.INVITATION -> generalGetString(MR.strings.connect_via_invitation_link)
|
||||
ConnectionLinkType.GROUP -> generalGetString(MR.strings.connect_via_group_link)
|
||||
}
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = title,
|
||||
text = if (linkType == ConnectionLinkType.GROUP)
|
||||
generalGetString(MR.strings.you_will_join_group)
|
||||
else
|
||||
generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link),
|
||||
confirmText = generalGetString(MR.strings.connect_via_link_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: connecting")
|
||||
connectViaUri(chatModel, linkType, uri)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { ChatController.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
|
||||
@@ -353,43 +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(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filteredChats(showUnreadAndFavorites: Boolean, searchText: String): List<Chat> {
|
||||
val chatModel = 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())
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -11,22 +12,20 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.ComposePreview
|
||||
import chat.simplex.common.views.chat.ComposeState
|
||||
import chat.simplex.common.views.chat.item.MarkdownText
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.GroupInfo
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.ComposePreview
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.chat.item.MarkdownText
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ChatPreviewView(
|
||||
@@ -44,8 +43,8 @@ fun ChatPreviewView(
|
||||
@Composable
|
||||
fun groupInactiveIcon() {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_cancel_filled),
|
||||
stringResource(MR.strings.icon_descr_group_inactive),
|
||||
painterResource(R.drawable.ic_cancel_filled),
|
||||
stringResource(R.string.icon_descr_group_inactive),
|
||||
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
@@ -77,15 +76,15 @@ fun ChatPreviewView(
|
||||
|
||||
@Composable
|
||||
fun VerifiedIcon() {
|
||||
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
|
||||
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
|
||||
fun messageDraft(draft: ComposeState): Pair<AnnotatedString, Map<String, InlineTextContent>> {
|
||||
fun attachment(): Pair<ImageResource, String?>? =
|
||||
fun attachment(): Pair<Int, String?>? =
|
||||
when (draft.preview) {
|
||||
is ComposePreview.FilePreview -> MR.images.ic_draft_filled to draft.preview.fileName
|
||||
is ComposePreview.MediaPreview -> MR.images.ic_image to null
|
||||
is ComposePreview.VoicePreview -> MR.images.ic_play_arrow_filled to durationText(draft.preview.durationMs / 1000)
|
||||
is ComposePreview.FilePreview -> R.drawable.ic_draft_filled to draft.preview.fileName
|
||||
is ComposePreview.MediaPreview -> R.drawable.ic_image to null
|
||||
is ComposePreview.VoicePreview -> R.drawable.ic_play_arrow_filled to durationText(draft.preview.durationMs / 1000)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -106,12 +105,12 @@ fun ChatPreviewView(
|
||||
"editIcon" to InlineTextContent(
|
||||
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_edit_note), null, tint = MaterialTheme.colors.primary)
|
||||
Icon(painterResource(R.drawable.ic_edit_note), null, tint = MaterialTheme.colors.primary)
|
||||
},
|
||||
"attachmentIcon" to InlineTextContent(
|
||||
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Icon(if (attachment?.first != null) painterResource(attachment.first) else painterResource(MR.images.ic_edit_note), null, tint = MaterialTheme.colors.secondary)
|
||||
Icon(if (attachment?.first != null) painterResource(attachment.first) else painterResource(R.drawable.ic_edit_note), null, tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
)
|
||||
return text to inlineContent
|
||||
@@ -144,7 +143,7 @@ fun ChatPreviewView(
|
||||
val (text: CharSequence, inlineTextContent) = when {
|
||||
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
|
||||
ci.meta.itemDeleted == null -> ci.text to null
|
||||
else -> generalGetString(MR.strings.marked_deleted_description) to null
|
||||
else -> generalGetString(R.string.marked_deleted_description) to null
|
||||
}
|
||||
val formattedText = when {
|
||||
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
|
||||
@@ -171,12 +170,12 @@ fun ChatPreviewView(
|
||||
when (cInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
if (!cInfo.ready) {
|
||||
Text(stringResource(MR.strings.contact_connection_pending), color = MaterialTheme.colors.secondary)
|
||||
Text(stringResource(R.string.contact_connection_pending), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
|
||||
GroupMemberStatus.MemAccepted -> Text(stringResource(MR.strings.group_connection_pending), color = MaterialTheme.colors.secondary)
|
||||
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = MaterialTheme.colors.secondary)
|
||||
else -> {}
|
||||
}
|
||||
else -> {}
|
||||
@@ -237,23 +236,8 @@ fun ChatPreviewView(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_notifications_off_filled),
|
||||
contentDescription = generalGetString(MR.strings.notifications),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp)
|
||||
.size(17.dp)
|
||||
)
|
||||
}
|
||||
} else if (chat.chatInfo.chatSettings?.favorite == true) {
|
||||
Box(
|
||||
Modifier.padding(top = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_star_filled),
|
||||
contentDescription = generalGetString(MR.strings.favorite_chat),
|
||||
painterResource(R.drawable.ic_notifications_off_filled),
|
||||
contentDescription = generalGetString(R.string.notifications),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
@@ -277,16 +261,16 @@ fun ChatPreviewView(
|
||||
@Composable
|
||||
private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
|
||||
return if (groupInfo.membership.memberIncognito)
|
||||
String.format(stringResource(MR.strings.group_preview_join_as), groupInfo.membership.memberProfile.displayName)
|
||||
String.format(stringResource(R.string.group_preview_join_as), groupInfo.membership.memberProfile.displayName)
|
||||
else if (chatModelIncognito)
|
||||
String.format(stringResource(MR.strings.group_preview_join_as), currentUserProfileDisplayName ?: "")
|
||||
String.format(stringResource(R.string.group_preview_join_as), currentUserProfileDisplayName ?: "")
|
||||
else
|
||||
stringResource(MR.strings.group_preview_you_are_invited)
|
||||
stringResource(R.string.group_preview_you_are_invited)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun unreadCountStr(n: Int): String {
|
||||
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(MR.strings.thousand_abbreviation)
|
||||
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -294,7 +278,7 @@ fun ChatStatusImage(s: NetworkStatus?) {
|
||||
val descr = s?.statusString
|
||||
if (s is NetworkStatus.Error) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_error),
|
||||
painterResource(R.drawable.ic_error),
|
||||
contentDescription = descr,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
@@ -311,11 +295,12 @@ fun ChatStatusImage(s: NetworkStatus?) {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@@ -7,21 +7,21 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.ProfileImage
|
||||
import chat.simplex.common.model.PendingContactConnection
|
||||
import chat.simplex.common.model.getTimestampText
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
|
||||
@Composable
|
||||
fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
||||
Row {
|
||||
Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
|
||||
ProfileImage(size = 54.dp, null, if (contactConnection.initiated) MR.images.ic_add_link else MR.images.ic_link)
|
||||
ProfileImage(size = 54.dp, null, if (contactConnection.initiated) R.drawable.ic_add_link else R.drawable.ic_link)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@@ -6,16 +6,15 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.ChatInfoImage
|
||||
import chat.simplex.common.model.ChatInfo
|
||||
import chat.simplex.common.model.getTimestampText
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||
|
||||
@Composable
|
||||
fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) {
|
||||
@@ -35,7 +34,7 @@ fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.Con
|
||||
color = if (chatModelIncognito) Indigo else MaterialTheme.colors.primary
|
||||
)
|
||||
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||
Text(stringResource(MR.strings.contact_wants_to_connect_with_you), Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
Text(stringResource(R.string.contact_wants_to_connect_with_you), Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
}
|
||||
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
||||
Column(
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -9,9 +9,9 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.ui.theme.Indigo
|
||||
import chat.simplex.common.views.helpers.ProfileImage
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.Indigo
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
|
||||
@Composable
|
||||
fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
@@ -1,5 +1,7 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -9,16 +11,16 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.Chat
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.BackHandler
|
||||
import chat.simplex.res.MR
|
||||
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.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
@@ -50,7 +52,7 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
@Composable
|
||||
private fun EmptyList() {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(MR.strings.you_have_no_chats), color = MaterialTheme.colors.secondary)
|
||||
Text(stringResource(R.string.you_have_no_chats), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +82,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
|
||||
if (chatModel.chats.size >= 8) {
|
||||
barButtons.add {
|
||||
IconButton({ showSearch = true }) {
|
||||
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
|
||||
Icon(painterResource(R.drawable.ic_search_500), stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,13 +90,13 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
|
||||
barButtons.add {
|
||||
IconButton(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
||||
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
||||
)
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_report_filled),
|
||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
||||
painterResource(R.drawable.ic_report_filled),
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
tint = Color.Red,
|
||||
)
|
||||
}
|
||||
@@ -107,18 +109,18 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
when (chatModel.sharedContent.value) {
|
||||
is SharedContent.Text -> stringResource(MR.strings.share_message)
|
||||
is SharedContent.Media -> stringResource(MR.strings.share_image)
|
||||
is SharedContent.File -> stringResource(MR.strings.share_file)
|
||||
else -> stringResource(MR.strings.share_message)
|
||||
is SharedContent.Text -> stringResource(R.string.share_message)
|
||||
is SharedContent.Media -> stringResource(R.string.share_image)
|
||||
is SharedContent.File -> stringResource(R.string.share_file)
|
||||
else -> stringResource(R.string.share_message)
|
||||
},
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (chatModel.incognito.value) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_theater_comedy_filled),
|
||||
stringResource(MR.strings.incognito),
|
||||
painterResource(R.drawable.ic_theater_comedy_filled),
|
||||
stringResource(R.string.incognito),
|
||||
tint = Indigo,
|
||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -12,18 +13,19 @@ import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.User
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -88,10 +90,10 @@ fun UserPicker(
|
||||
}
|
||||
}
|
||||
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
|
||||
val maxWidth = with(LocalDensity.current) { screenWidth() * density }
|
||||
val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density }
|
||||
Box(Modifier
|
||||
.fillMaxSize()
|
||||
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) }
|
||||
.offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else xOffset, 0) }
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
|
||||
.padding(bottom = 10.dp, top = 10.dp)
|
||||
.graphicsLayer {
|
||||
@@ -165,9 +167,9 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues
|
||||
) {
|
||||
UserProfileRow(u)
|
||||
if (u.activeUser) {
|
||||
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
Icon(painterResource(R.drawable.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
} else if (u.hidden) {
|
||||
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
Icon(painterResource(R.drawable.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
} else if (unreadCount > 0) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -183,7 +185,7 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues
|
||||
)
|
||||
}
|
||||
} else if (!u.showNtfs) {
|
||||
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
Icon(painterResource(R.drawable.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
} else {
|
||||
Box(Modifier.size(20.dp))
|
||||
}
|
||||
@@ -194,7 +196,7 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues
|
||||
fun UserProfileRow(u: User) {
|
||||
Row(
|
||||
Modifier
|
||||
.widthIn(max = screenWidth() * 0.7f)
|
||||
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -215,8 +217,8 @@ fun UserProfileRow(u: User) {
|
||||
@Composable
|
||||
private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||
Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||
Icon(painterResource(R.drawable.ic_settings), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||
Text(
|
||||
text,
|
||||
@@ -228,8 +230,8 @@ private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun CancelPickerItem(onClick: () -> Unit) {
|
||||
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||
val text = generalGetString(MR.strings.cancel_verb)
|
||||
Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
val text = generalGetString(R.string.cancel_verb)
|
||||
Icon(painterResource(R.drawable.ic_close), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||
Text(
|
||||
text,
|
||||
@@ -0,0 +1,140 @@
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import kotlinx.datetime.*
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun ChatArchiveView(m: ChatModel, title: String, archiveName: String, archiveTime: Instant) {
|
||||
val context = LocalContext.current
|
||||
val archivePath = "${getFilesDirectory(context)}/$archiveName"
|
||||
val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, archivePath)
|
||||
ChatArchiveLayout(
|
||||
title,
|
||||
archiveTime,
|
||||
saveArchive = { saveArchiveLauncher.launch(archivePath.substringAfterLast("/")) },
|
||||
deleteArchiveAlert = { deleteArchiveAlert(m, archivePath) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatArchiveLayout(
|
||||
title: String,
|
||||
archiveTime: Instant,
|
||||
saveArchive: () -> Unit,
|
||||
deleteArchiveAlert: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(title)
|
||||
SectionView(stringResource(R.string.chat_archive_section)) {
|
||||
SettingsActionItem(
|
||||
painterResource(R.drawable.ic_ios_share),
|
||||
stringResource(R.string.save_archive),
|
||||
saveArchive,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
SettingsActionItem(
|
||||
painterResource(R.drawable.ic_delete),
|
||||
stringResource(R.string.delete_archive),
|
||||
deleteArchiveAlert,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
}
|
||||
val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
|
||||
SectionTextFooter(
|
||||
String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
|
||||
)
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberSaveArchiveLauncher(cxt: Context, chatArchivePath: String): ManagedActivityResultLauncher<String, Uri?> =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(),
|
||||
onResult = { destination ->
|
||||
try {
|
||||
destination?.let {
|
||||
val contentResolver = cxt.contentResolver
|
||||
contentResolver.openOutputStream(destination)?.let { stream ->
|
||||
val outputStream = BufferedOutputStream(stream)
|
||||
File(chatArchivePath).inputStream().use { it.copyTo(outputStream) }
|
||||
outputStream.close()
|
||||
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Error) {
|
||||
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
|
||||
Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.delete_chat_archive_question),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
val fileDeleted = File(archivePath).delete()
|
||||
if (fileDeleted) {
|
||||
m.controller.appPrefs.chatArchiveName.set(null)
|
||||
m.controller.appPrefs.chatArchiveTime.set(null)
|
||||
ModalManager.shared.closeModal()
|
||||
} else {
|
||||
Log.e(TAG, "deleteArchiveAlert delete() error")
|
||||
}
|
||||
},
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatArchiveLayout() {
|
||||
SimpleXTheme {
|
||||
ChatArchiveLayout(
|
||||
"New database archive",
|
||||
archiveTime = Clock.System.now(),
|
||||
saveArchive = {},
|
||||
deleteArchiveAlert = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.database
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionItemView
|
||||
@@ -18,18 +18,18 @@ import androidx.compose.ui.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.log2
|
||||
@@ -71,14 +71,14 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
||||
sqliteError is SQLiteError.ErrorNotADatabase -> {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.wrong_passphrase_title),
|
||||
generalGetString(MR.strings.enter_correct_current_passphrase)
|
||||
generalGetString(R.string.wrong_passphrase_title),
|
||||
generalGetString(R.string.enter_correct_current_passphrase)
|
||||
)
|
||||
}
|
||||
}
|
||||
error != null -> {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database),
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database),
|
||||
"failed to set storage encryption: ${error.responseType} ${error.details}"
|
||||
)
|
||||
}
|
||||
@@ -91,13 +91,13 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
||||
}
|
||||
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted))
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_encrypted))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,16 +136,16 @@ fun DatabaseEncryptionLayout(
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.database_passphrase))
|
||||
AppBarTitle(stringResource(R.string.database_passphrase))
|
||||
SectionView(null) {
|
||||
SavePassphraseSetting(useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, progressIndicator.value) { checked ->
|
||||
if (checked) {
|
||||
setUseKeychain(true, useKeychain, prefs)
|
||||
} else if (storedKey.value) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.remove_passphrase_from_keychain),
|
||||
text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
|
||||
confirmText = generalGetString(MR.strings.remove_passphrase),
|
||||
title = generalGetString(R.string.remove_passphrase_from_keychain),
|
||||
text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
|
||||
confirmText = generalGetString(R.string.remove_passphrase),
|
||||
onConfirm = {
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
setUseKeychain(false, useKeychain, prefs)
|
||||
@@ -161,7 +161,7 @@ fun DatabaseEncryptionLayout(
|
||||
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
|
||||
PassphraseField(
|
||||
currentKey,
|
||||
generalGetString(MR.strings.current_passphrase),
|
||||
generalGetString(R.string.current_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
isValid = ::validKey,
|
||||
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
|
||||
@@ -170,7 +170,7 @@ fun DatabaseEncryptionLayout(
|
||||
|
||||
PassphraseField(
|
||||
newKey,
|
||||
generalGetString(MR.strings.new_passphrase),
|
||||
generalGetString(R.string.new_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
showStrength = true,
|
||||
isValid = ::validKey,
|
||||
@@ -201,7 +201,7 @@ fun DatabaseEncryptionLayout(
|
||||
|
||||
PassphraseField(
|
||||
confirmNewKey,
|
||||
generalGetString(MR.strings.confirm_new_passphrase),
|
||||
generalGetString(R.string.confirm_new_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
@@ -211,27 +211,27 @@ fun DatabaseEncryptionLayout(
|
||||
)
|
||||
|
||||
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) {
|
||||
Text(generalGetString(MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
if (chatDbEncrypted == false) {
|
||||
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
|
||||
SectionTextFooter(generalGetString(R.string.database_is_not_encrypted))
|
||||
} else if (useKeychain.value) {
|
||||
if (storedKey.value) {
|
||||
SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely))
|
||||
SectionTextFooter(generalGetString(R.string.keychain_is_storing_securely))
|
||||
if (initialRandomDBPassphrase.value) {
|
||||
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
|
||||
SectionTextFooter(generalGetString(R.string.encrypted_with_random_passphrase))
|
||||
} else {
|
||||
SectionTextFooter(generalGetString(MR.strings.impossible_to_recover_passphrase))
|
||||
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
|
||||
}
|
||||
} else {
|
||||
SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs))
|
||||
SectionTextFooter(generalGetString(R.string.keychain_allows_to_receive_ntfs))
|
||||
}
|
||||
} else {
|
||||
SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time))
|
||||
SectionTextFooter(generalGetString(MR.strings.impossible_to_recover_passphrase))
|
||||
SectionTextFooter(generalGetString(R.string.you_have_to_enter_passphrase_every_time))
|
||||
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
@@ -240,9 +240,9 @@ fun DatabaseEncryptionLayout(
|
||||
|
||||
fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.encrypt_database_question),
|
||||
text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
|
||||
confirmText = generalGetString(MR.strings.encrypt_database),
|
||||
title = generalGetString(R.string.encrypt_database_question),
|
||||
text = generalGetString(R.string.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
|
||||
confirmText = generalGetString(R.string.encrypt_database),
|
||||
onConfirm = onConfirm,
|
||||
destructive = true,
|
||||
)
|
||||
@@ -250,9 +250,9 @@ fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
|
||||
|
||||
fun encryptDatabaseAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.encrypt_database_question),
|
||||
text = generalGetString(MR.strings.database_will_be_encrypted) +"\n" + storeSecurelyDanger(),
|
||||
confirmText = generalGetString(MR.strings.encrypt_database),
|
||||
title = generalGetString(R.string.encrypt_database_question),
|
||||
text = generalGetString(R.string.database_will_be_encrypted) +"\n" + storeSecurelyDanger(),
|
||||
confirmText = generalGetString(R.string.encrypt_database),
|
||||
onConfirm = onConfirm,
|
||||
destructive = true,
|
||||
)
|
||||
@@ -260,9 +260,9 @@ fun encryptDatabaseAlert(onConfirm: () -> Unit) {
|
||||
|
||||
fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.change_database_passphrase_question),
|
||||
text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(),
|
||||
confirmText = generalGetString(MR.strings.update_database),
|
||||
title = generalGetString(R.string.change_database_passphrase_question),
|
||||
text = generalGetString(R.string.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(),
|
||||
confirmText = generalGetString(R.string.update_database),
|
||||
onConfirm = onConfirm,
|
||||
destructive = false,
|
||||
)
|
||||
@@ -270,9 +270,9 @@ fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
|
||||
|
||||
fun changeDatabaseKeyAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.change_database_passphrase_question),
|
||||
text = generalGetString(MR.strings.database_passphrase_will_be_updated) + "\n" + storeSecurelyDanger(),
|
||||
confirmText = generalGetString(MR.strings.update_database),
|
||||
title = generalGetString(R.string.change_database_passphrase_question),
|
||||
text = generalGetString(R.string.database_passphrase_will_be_updated) + "\n" + storeSecurelyDanger(),
|
||||
confirmText = generalGetString(R.string.update_database),
|
||||
onConfirm = onConfirm,
|
||||
destructive = true,
|
||||
)
|
||||
@@ -290,13 +290,13 @@ fun SavePassphraseSetting(
|
||||
SectionItemView(minHeight = minHeight) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled),
|
||||
stringResource(MR.strings.save_passphrase_in_keychain),
|
||||
if (storedKey) painterResource(R.drawable.ic_vpn_key_filled) else painterResource(R.drawable.ic_vpn_key_off_filled),
|
||||
stringResource(R.string.save_passphrase_in_keychain),
|
||||
tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
stringResource(MR.strings.save_passphrase_in_keychain),
|
||||
stringResource(R.string.save_passphrase_in_keychain),
|
||||
Modifier.padding(end = 24.dp),
|
||||
color = Color.Unspecified
|
||||
)
|
||||
@@ -333,9 +333,9 @@ fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, prefs: Ap
|
||||
prefs.storeDBPassphrase.set(value)
|
||||
}
|
||||
|
||||
fun storeSecurelySaved() = generalGetString(MR.strings.store_passphrase_securely)
|
||||
fun storeSecurelySaved() = generalGetString(R.string.store_passphrase_securely)
|
||||
|
||||
fun storeSecurelyDanger() = generalGetString(MR.strings.store_passphrase_securely_without_recover)
|
||||
fun storeSecurelyDanger() = generalGetString(R.string.store_passphrase_securely_without_recover)
|
||||
|
||||
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
|
||||
m.chatDbChanged.value = true
|
||||
@@ -357,8 +357,8 @@ fun PassphraseField(
|
||||
var valid by remember { mutableStateOf(validKey(key.value)) }
|
||||
var showKey by remember { mutableStateOf(false) }
|
||||
val icon = if (valid) {
|
||||
if (showKey) painterResource(MR.images.ic_visibility_off_filled) else painterResource(MR.images.ic_visibility_filled)
|
||||
} else painterResource(MR.images.ic_error)
|
||||
if (showKey) painterResource(R.drawable.ic_visibility_off_filled) else painterResource(R.drawable.ic_visibility_filled)
|
||||
} else painterResource(R.drawable.ic_error)
|
||||
val iconColor = if (valid) {
|
||||
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else MaterialTheme.colors.secondary
|
||||
} else Color.Red
|
||||
@@ -497,7 +497,7 @@ fun PreviewDatabaseEncryptionLayout() {
|
||||
SimpleXTheme {
|
||||
DatabaseEncryptionLayout(
|
||||
useKeychain = remember { mutableStateOf(true) },
|
||||
prefs = AppPreferences(),
|
||||
prefs = AppPreferences(SimplexApp.context),
|
||||
chatDbEncrypted = true,
|
||||
currentKey = remember { mutableStateOf("") },
|
||||
newKey = remember { mutableStateOf("") },
|
||||
@@ -1,9 +1,11 @@
|
||||
package chat.simplex.common.views.database
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -12,15 +14,17 @@ 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.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.AppPreferences
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.AppVersionText
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.AppVersionText
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
import java.io.File
|
||||
@@ -37,7 +41,8 @@ fun DatabaseErrorView(
|
||||
val dbKey = remember { mutableStateOf("") }
|
||||
var storedDBKey by remember { mutableStateOf(DatabaseUtils.ksDatabasePassword.get()) }
|
||||
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
|
||||
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences)) }
|
||||
val context = LocalContext.current
|
||||
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
|
||||
|
||||
fun callRunChat(confirmMigrations: MigrationConfirmation? = null) {
|
||||
val useKey = if (useKeychain) null else dbKey.value
|
||||
@@ -54,7 +59,7 @@ fun DatabaseErrorView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DatabaseErrorDetails(title: StringResource, content: @Composable ColumnScope.() -> Unit) {
|
||||
fun DatabaseErrorDetails(@StringRes title: Int, content: @Composable ColumnScope.() -> Unit) {
|
||||
Text(
|
||||
generalGetString(title),
|
||||
Modifier.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
|
||||
@@ -65,12 +70,12 @@ fun DatabaseErrorView(
|
||||
|
||||
@Composable
|
||||
fun FileNameText(dbFile: String) {
|
||||
Text(String.format(generalGetString(MR.strings.file_with_path), dbFile.split("/").lastOrNull() ?: dbFile))
|
||||
Text(String.format(generalGetString(R.string.file_with_path), dbFile.split("/").lastOrNull() ?: dbFile))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MigrationsText(ms: List<String>) {
|
||||
Text(String.format(generalGetString(MR.strings.database_migrations), ms.joinToString(", ")))
|
||||
Text(String.format(generalGetString(R.string.database_migrations), ms.joinToString(", ")))
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -81,8 +86,8 @@ fun DatabaseErrorView(
|
||||
when (val status = chatDbStatus.value) {
|
||||
is DBMigrationResult.ErrorNotADatabase ->
|
||||
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
|
||||
DatabaseErrorDetails(MR.strings.wrong_passphrase) {
|
||||
Text(generalGetString(MR.strings.passphrase_is_different))
|
||||
DatabaseErrorDetails(R.string.wrong_passphrase) {
|
||||
Text(generalGetString(R.string.passphrase_is_different))
|
||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
||||
saveAndRunChatOnClick()
|
||||
}
|
||||
@@ -91,8 +96,8 @@ fun DatabaseErrorView(
|
||||
FileNameText(status.dbFile)
|
||||
}
|
||||
} else {
|
||||
DatabaseErrorDetails(MR.strings.encrypted_database) {
|
||||
Text(generalGetString(MR.strings.database_passphrase_is_required))
|
||||
DatabaseErrorDetails(R.string.encrypted_database) {
|
||||
Text(generalGetString(R.string.database_passphrase_is_required))
|
||||
if (useKeychain) {
|
||||
DatabaseKeyField(dbKey, buttonEnabled, ::saveAndRunChatOnClick)
|
||||
SaveAndOpenButton(buttonEnabled, ::saveAndRunChatOnClick)
|
||||
@@ -104,9 +109,9 @@ fun DatabaseErrorView(
|
||||
}
|
||||
is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) {
|
||||
is MigrationError.Upgrade ->
|
||||
DatabaseErrorDetails(MR.strings.database_upgrade) {
|
||||
DatabaseErrorDetails(R.string.database_upgrade) {
|
||||
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUp) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||
Text(generalGetString(MR.strings.upgrade_and_open_chat))
|
||||
Text(generalGetString(R.string.upgrade_and_open_chat))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
FileNameText(status.dbFile)
|
||||
@@ -114,52 +119,52 @@ fun DatabaseErrorView(
|
||||
AppVersionText()
|
||||
}
|
||||
is MigrationError.Downgrade ->
|
||||
DatabaseErrorDetails(MR.strings.database_downgrade) {
|
||||
DatabaseErrorDetails(R.string.database_downgrade) {
|
||||
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUpDown) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||
Text(generalGetString(MR.strings.downgrade_and_open_chat))
|
||||
Text(generalGetString(R.string.downgrade_and_open_chat))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(generalGetString(MR.strings.database_downgrade_warning), fontWeight = FontWeight.Bold)
|
||||
Text(generalGetString(R.string.database_downgrade_warning), fontWeight = FontWeight.Bold)
|
||||
FileNameText(status.dbFile)
|
||||
MigrationsText(err.downMigrations)
|
||||
AppVersionText()
|
||||
}
|
||||
is MigrationError.Error ->
|
||||
DatabaseErrorDetails(MR.strings.incompatible_database_version) {
|
||||
DatabaseErrorDetails(R.string.incompatible_database_version) {
|
||||
FileNameText(status.dbFile)
|
||||
Text(String.format(generalGetString(MR.strings.error_with_info), mtrErrorDescription(err.mtrError)))
|
||||
Text(String.format(generalGetString(R.string.error_with_info), mtrErrorDescription(err.mtrError)))
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.ErrorSQL ->
|
||||
DatabaseErrorDetails(MR.strings.database_error) {
|
||||
DatabaseErrorDetails(R.string.database_error) {
|
||||
FileNameText(status.dbFile)
|
||||
Text(String.format(generalGetString(MR.strings.error_with_info), status.migrationSQLError))
|
||||
Text(String.format(generalGetString(R.string.error_with_info), status.migrationSQLError))
|
||||
}
|
||||
is DBMigrationResult.ErrorKeychain ->
|
||||
DatabaseErrorDetails(MR.strings.keychain_error) {
|
||||
Text(generalGetString(MR.strings.cannot_access_keychain))
|
||||
DatabaseErrorDetails(R.string.keychain_error) {
|
||||
Text(generalGetString(R.string.cannot_access_keychain))
|
||||
}
|
||||
is DBMigrationResult.InvalidConfirmation ->
|
||||
DatabaseErrorDetails(MR.strings.invalid_migration_confirmation) {
|
||||
DatabaseErrorDetails(R.string.invalid_migration_confirmation) {
|
||||
// this can only happen if incorrect parameter is passed
|
||||
}
|
||||
is DBMigrationResult.Unknown ->
|
||||
DatabaseErrorDetails(MR.strings.database_error) {
|
||||
Text(String.format(generalGetString(MR.strings.unknown_database_error_with_info), status.json))
|
||||
DatabaseErrorDetails(R.string.database_error) {
|
||||
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
|
||||
}
|
||||
is DBMigrationResult.OK -> {}
|
||||
null -> {}
|
||||
}
|
||||
if (restoreDbFromBackup.value) {
|
||||
SectionSpacer()
|
||||
Text(generalGetString(MR.strings.database_backup_can_be_restored))
|
||||
Text(generalGetString(R.string.database_backup_can_be_restored))
|
||||
Spacer(Modifier.size(DEFAULT_PADDING))
|
||||
RestoreDbButton {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.restore_database_alert_title),
|
||||
text = generalGetString(MR.strings.restore_database_alert_desc),
|
||||
confirmText = generalGetString(MR.strings.restore_database_alert_confirm),
|
||||
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences) },
|
||||
title = generalGetString(R.string.restore_database_alert_title),
|
||||
text = generalGetString(R.string.restore_database_alert_desc),
|
||||
confirmText = generalGetString(R.string.restore_database_alert_confirm),
|
||||
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
@@ -193,68 +198,72 @@ private fun runChat(
|
||||
if (progressIndicator.value) return@launch
|
||||
progressIndicator.value = true
|
||||
try {
|
||||
initChatController(dbKey, confirmMigrations)
|
||||
SimplexApp.context.initChatController(dbKey, confirmMigrations)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
|
||||
}
|
||||
progressIndicator.value = false
|
||||
when (val status = chatDbStatus.value) {
|
||||
is DBMigrationResult.OK -> {
|
||||
platform.androidChatStartedAfterBeingOff()
|
||||
SimplexService.cancelPassphraseNotification()
|
||||
when (prefs.notificationsMode.get()) {
|
||||
NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) }
|
||||
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.ErrorNotADatabase ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.wrong_passphrase_title), generalGetString(MR.strings.enter_correct_passphrase))
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
|
||||
is DBMigrationResult.ErrorSQL ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_error), status.migrationSQLError)
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationSQLError)
|
||||
is DBMigrationResult.ErrorKeychain ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.keychain_error))
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.keychain_error))
|
||||
is DBMigrationResult.Unknown ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), status.json)
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), status.json)
|
||||
is DBMigrationResult.InvalidConfirmation ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.invalid_migration_confirmation))
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.invalid_migration_confirmation))
|
||||
is DBMigrationResult.ErrorMigration -> {}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldShowRestoreDbButton(prefs: AppPreferences): Boolean {
|
||||
private fun shouldShowRestoreDbButton(prefs: AppPreferences, context: Context): Boolean {
|
||||
val startedAt = prefs.encryptionStartedAt.get() ?: return false
|
||||
/** Just in case there is any small difference between reported Java's [Clock.System.now] and Linux's time on a file */
|
||||
val safeDiffInTime = 10_000L
|
||||
val filesChat = File(dataDir.absolutePath + File.separator + "${chatDatabaseFileName}.bak")
|
||||
val filesAgent = File(dataDir.absolutePath + File.separator + "${agentDatabaseFileName}.bak")
|
||||
val filesChat = File(context.dataDir.absolutePath + File.separator + "files_chat.db.bak")
|
||||
val filesAgent = File(context.dataDir.absolutePath + File.separator + "files_agent.db.bak")
|
||||
return filesChat.exists() &&
|
||||
filesAgent.exists() &&
|
||||
startedAt.toEpochMilliseconds() - safeDiffInTime <= filesChat.lastModified() &&
|
||||
startedAt.toEpochMilliseconds() - safeDiffInTime <= filesAgent.lastModified()
|
||||
}
|
||||
|
||||
private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPreferences) {
|
||||
val filesChatBase = dataDir.absolutePath + File.separator + chatDatabaseFileName
|
||||
val filesAgentBase = dataDir.absolutePath + File.separator + agentDatabaseFileName
|
||||
private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPreferences, context: Context) {
|
||||
val filesChatBase = context.dataDir.absolutePath + File.separator + "files_chat.db"
|
||||
val filesAgentBase = context.dataDir.absolutePath + File.separator + "files_agent.db"
|
||||
try {
|
||||
Files.copy(Path("$filesChatBase.bak"), Path(filesChatBase), StandardCopyOption.REPLACE_EXISTING)
|
||||
Files.copy(Path("$filesAgentBase.bak"), Path(filesAgentBase), StandardCopyOption.REPLACE_EXISTING)
|
||||
restoreDbFromBackup.value = false
|
||||
prefs.encryptionStartedAt.set(null)
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_restore_error), e.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_restore_error), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun mtrErrorDescription(err: MTRError): String =
|
||||
when (err) {
|
||||
is MTRError.NoDown ->
|
||||
String.format(generalGetString(MR.strings.mtr_error_no_down_migration), err.dbMigrations.joinToString(", "))
|
||||
String.format(generalGetString(R.string.mtr_error_no_down_migration), err.dbMigrations.joinToString(", "))
|
||||
is MTRError.Different ->
|
||||
String.format(generalGetString(MR.strings.mtr_error_different), err.appMigration, err.dbMigration)
|
||||
String.format(generalGetString(R.string.mtr_error_different), err.appMigration, err.dbMigration)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
|
||||
PassphraseField(
|
||||
text,
|
||||
generalGetString(MR.strings.enter_passphrase),
|
||||
generalGetString(R.string.enter_passphrase),
|
||||
isValid = ::validKey,
|
||||
keyboardActions = KeyboardActions(onDone = if (enabled) {
|
||||
{ onClick?.invoke() }
|
||||
@@ -266,21 +275,21 @@ private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onCli
|
||||
@Composable
|
||||
private fun ColumnScope.SaveAndOpenButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
|
||||
Text(generalGetString(MR.strings.save_passphrase_and_open_chat))
|
||||
Text(generalGetString(R.string.save_passphrase_and_open_chat))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.OpenChatButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
|
||||
Text(generalGetString(MR.strings.open_chat))
|
||||
Text(generalGetString(R.string.open_chat))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.RestoreDbButton(onClick: () -> Unit) {
|
||||
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally)) {
|
||||
Text(generalGetString(MR.strings.restore_database), color = MaterialTheme.colors.error)
|
||||
Text(generalGetString(R.string.restore_database), color = MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +299,7 @@ fun PreviewChatInfoLayout() {
|
||||
SimpleXTheme {
|
||||
DatabaseErrorView(
|
||||
remember { mutableStateOf(DBMigrationResult.ErrorNotADatabase("simplex_v1_chat.db")) },
|
||||
AppPreferences()
|
||||
AppPreferences(SimplexApp.context)
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user