Compare commits
1 Commits
v5.3.0-bet
...
_archived-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e099d08325 |
68
.github/ISSUE_TEMPLATE/bug.yml
vendored
68
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,68 +0,0 @@
|
||||
name: Bug
|
||||
description: File a bug report/issue
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Multiple selections are possible.
|
||||
multiple: true
|
||||
options:
|
||||
- Linux
|
||||
- Mac
|
||||
- Windows
|
||||
- Android
|
||||
- iOS
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: OS version
|
||||
description: Specify the OS version
|
||||
placeholder: ex. Android 12, Ubuntu 20.04
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: App version
|
||||
description: Specify the SimpleX version
|
||||
placeholder: ex. 4.3.2
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
placeholder: Bug happened!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
placeholder: No bug should happen!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
3. Click on ...
|
||||
4. See error...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
1
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
40
.github/ISSUE_TEMPLATE/feature.yml
vendored
40
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: Feature
|
||||
description: Suggest your feature
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Multiple selections are possible. If selected input is "all", this considered to be a general feature.
|
||||
multiple: true
|
||||
options:
|
||||
- Linux
|
||||
- Mac
|
||||
- Windows
|
||||
- Android
|
||||
- iOS
|
||||
- all
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: App version
|
||||
description: Specify the SimpleX version
|
||||
placeholder: ex. 4.3.2
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Feature
|
||||
description: Describe the feature you would like to see added
|
||||
placeholder: SimpleX Chat should make me coffee!
|
||||
validations:
|
||||
required: true
|
||||
16
.github/ISSUE_TEMPLATE/question.yml
vendored
16
.github/ISSUE_TEMPLATE/question.yml
vendored
@@ -1,16 +0,0 @@
|
||||
name: Question
|
||||
description: Ask your question
|
||||
title: "[Q]: "
|
||||
labels: ["question", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Generally, we encourage you to ask questions in our [official group](https://simplex.chat/invitation/#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3Dsimplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D), but you can do it anyway :)
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Question
|
||||
description: Please ask your question in plain english.
|
||||
placeholder: Is SimpleX - chat?
|
||||
validations:
|
||||
required: true
|
||||
127
.github/workflows/build.yml
vendored
127
.github/workflows/build.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- stable
|
||||
- users
|
||||
- sqlcipher
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
@@ -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
|
||||
@@ -52,39 +52,27 @@ jobs:
|
||||
- os: ubuntu-20.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
|
||||
- os: ubuntu-22.04
|
||||
- os: ubuntu-18.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
|
||||
asset_name: simplex-chat-ubuntu-18_04-x86-64
|
||||
- os: macos-latest
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
|
||||
- os: windows-latest
|
||||
cache_path: C:/cabal
|
||||
asset_name: simplex-chat-windows-x86-64
|
||||
desktop_asset_name: simplex-desktop-windows-x86_64.msi
|
||||
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: "9.6.2"
|
||||
cabal-version: "3.10.1.0"
|
||||
ghc-version: "8.10.7"
|
||||
cabal-version: "latest"
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
@@ -103,110 +91,37 @@ jobs:
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Install AppImage dependencies
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
run: sudo apt install -y desktop-file-utils
|
||||
|
||||
- name: Install pkg-config for Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: brew install pkg-config
|
||||
|
||||
- name: Unix prepare cabal.project.local for Ubuntu
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-18.04'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Unix build CLI
|
||||
id: unix_cli_build
|
||||
- name: Unix build
|
||||
id: unix_build
|
||||
if: matrix.os != 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
cabal build --enable-tests
|
||||
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||
|
||||
- name: Unix upload CLI binary to release
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest' && matrix.os != 'ubuntu-20.04'
|
||||
timeout-minutes: 10
|
||||
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
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.unix_cli_build.outputs.bin_path }}
|
||||
file: ${{ steps.unix_build.outputs.bin_path }}
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Setup Java
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'corretto'
|
||||
java-version: '17'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Linux build desktop
|
||||
id: linux_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/build-lib-linux.sh
|
||||
cd apps/multiplatform
|
||||
./gradlew packageDeb
|
||||
echo "::set-output name=package_path::$(echo $PWD/release/main/deb/simplex_*_amd64.deb)"
|
||||
|
||||
- name: Linux make AppImage
|
||||
id: linux_appimage_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/make-appimage-linux.sh
|
||||
echo "::set-output name=appimage_path::$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage)"
|
||||
|
||||
- name: Mac build desktop
|
||||
id: mac_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
|
||||
APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
|
||||
APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
|
||||
run: |
|
||||
scripts/desktop/build-desktop-mac-ci.sh
|
||||
echo "::set-output name=package_path::$(echo $PWD/release/main/dmg/SimpleX-*.dmg)"
|
||||
|
||||
- name: Linux upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.linux_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Linux upload AppImage to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.linux_appimage_build.outputs.appimage_path }}
|
||||
asset_name: simplex-desktop-x86_64.AppImage
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Mac upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.mac_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_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
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -42,18 +42,14 @@ stack.yaml.lock
|
||||
|
||||
# Temporary test files
|
||||
tests/tmp
|
||||
tests/tmp*
|
||||
logs/
|
||||
|
||||
*.devcontainer
|
||||
|
||||
# for website
|
||||
website/node_modules/
|
||||
website/src/blog/
|
||||
website/src/docs/
|
||||
website/translations.json
|
||||
website/src/img/images/
|
||||
website/src/images/
|
||||
website/src/js/lottie.min.js
|
||||
# Generated files
|
||||
website/package/generated*
|
||||
|
||||
@@ -76,4 +72,3 @@ website/package-lock.json
|
||||
# Ignore test files
|
||||
website/.cache
|
||||
website/test/stubs-layout-cache/_includes/*.js
|
||||
apps/android/app/release
|
||||
|
||||
70
PRIVACY.md
70
PRIVACY.md
@@ -6,63 +6,27 @@ If you believe that some of the clauses in this document are not aligned with ou
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and encryption to provide secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the servers via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack).
|
||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||
|
||||
SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol allowing to establish private connections without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||
|
||||
SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
SimpleX Chat security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
### Information you provide
|
||||
|
||||
#### User profiles
|
||||
We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
||||
|
||||
We do not store user profiles. The profile you create in the app is local to your device.
|
||||
Messages. SimpleX Chat cannot decrypt or otherwise access the content or even size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are offline, these messages are permanently removed as soon as they are delivered. Your message history is stored only on your own devices.
|
||||
|
||||
When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
||||
|
||||
#### Messages and Files
|
||||
|
||||
SimpleX Chat cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 256kb, 1mb or 8mb via all or some of the configured file servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band.
|
||||
|
||||
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline SimpleX Chat temporarily stores end-to-end encrypted messages on the messaging (SMP) servers that are preset in the app or chosen by the users.
|
||||
|
||||
The messages are permanently removed from the preset servers as soon as they are delivered. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
|
||||
|
||||
The files are stored on file (XFTP) servers for the time configured in the file servers you use (48 hours for preset file servers).
|
||||
|
||||
If a messaging or file servers are restarted, the encrypted message or the record of the file can be stored in a backup file until it is overwritten by the next restart (usually within 1 week).
|
||||
|
||||
#### Connections with other users
|
||||
|
||||
When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on chosen messaging servers, that can be the preset servers or the servers that you configured in the app, in case it allows such configuration. SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default.
|
||||
|
||||
At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
|
||||
|
||||
#### iOS Push Notifications
|
||||
|
||||
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
|
||||
|
||||
Notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers.
|
||||
|
||||
It also does not allow to see message content or sizes, as the actual messages are not sent via the notification server, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot observe it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
|
||||
|
||||
#### Another information stored on the servers
|
||||
Connections with other users. When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on our servers, or on the servers that you configured in the app, in case it allows such configuration (SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default). At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. The exception to that is when you choose to use instant push notifications in our iOS app, because the design of push notifications requires storing the device token on notification server, and the server can observe how many messaging queues your device uses, and approximate how many messages are sent to each queue. It does not allow though to determine the actual addresses of these queues, as a separate address is used to subscibe to the notifications (unless notification and messaging servers exchange information), and who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers. It also does not allow to see message content or sizes, as the actual messages are not sent via the notification service, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot see it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
|
||||
|
||||
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
|
||||
|
||||
#### SimpleX Directory Service
|
||||
|
||||
[SimpleX directory service](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the group. You can connect to SimpleX Directory Service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
|
||||
|
||||
#### User Support.
|
||||
|
||||
If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
|
||||
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
|
||||
|
||||
### Information we may share
|
||||
|
||||
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
|
||||
|
||||
We use a third party for email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according to their privacy policies and terms of service.
|
||||
We use Third party to provide email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according their privacy policies and terms of service.
|
||||
|
||||
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
|
||||
|
||||
@@ -75,19 +39,19 @@ At the time of updating this document, we have never provided or have been reque
|
||||
|
||||
### Updates
|
||||
|
||||
We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
||||
We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
||||
|
||||
Please also read our Terms of Service below.
|
||||
Please also read our Terms of Service.
|
||||
|
||||
If you have questions about our Privacy Policy please contact us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
|
||||
If you have questions about our Privacy Policy please contact us at chat@simplex.chat.
|
||||
|
||||
## Terms of Service
|
||||
|
||||
You accept our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
||||
You accept to our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
||||
|
||||
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
|
||||
|
||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we do cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or cyphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||
|
||||
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
|
||||
|
||||
@@ -103,17 +67,15 @@ You accept our Terms of Service ("Terms") by installing or using any of our apps
|
||||
|
||||
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
|
||||
|
||||
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the application you use. Legacy databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app. In this case, if you make a backup of the app data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the beta version of desktop app currently stores the database passphrase in the configuration file in plaintext, so you may need to remove passphrase from the device via the app configuration.
|
||||
|
||||
**Storing the files on the device**. The files are stored on your device unencrypted. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access the files.
|
||||
**Storing the messages on the device**. Currently the messages are stored in the database on your device without encryption. It means that if you make a backup of the app and store it unecrypted, the backup provider may be able to access the messages.
|
||||
|
||||
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
|
||||
|
||||
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
|
||||
|
||||
**Your Rights**. You own the messages and the information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the app.
|
||||
**Your Rights**. You own the mesasges and information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices.
|
||||
|
||||
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
||||
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 licence](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
||||
|
||||
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
|
||||
|
||||
@@ -131,4 +93,4 @@ You accept our Terms of Service ("Terms") by installing or using any of our apps
|
||||
|
||||
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
|
||||
|
||||
Updated August 17, 2022
|
||||
Updated November 8, 2022
|
||||
|
||||
288
README.md
288
README.md
@@ -1,28 +1,13 @@
|
||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||
|
||||
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
|
||||
|
||||
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
[](https://mastodon.social/@simplex)
|
||||
|
||||
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||
|
||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||
|
||||
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
|
||||
|
||||
[<img src="./images/trail-of-bits.jpg" height="100">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [<img src="./images/privacy-guides.jpg" height="80">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [<img src="./images/kuketz-blog.jpg" height="80">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
|
||||
|
||||
## Welcome to SimpleX Chat!
|
||||
|
||||
1. 📲 [Install the app](#install-the-app).
|
||||
2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups).
|
||||
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
||||
|
||||
[Learn more about SimpleX Chat](#contents).
|
||||
|
||||
## Install the app
|
||||
[](https://twitter.com/SimpleXChat)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||
|
||||
@@ -38,126 +23,9 @@
|
||||
- 🔐 Double ratchet end-to-end encryption, with additional encryption layer.
|
||||
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
|
||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
|
||||
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
|
||||
|
||||
## Connect to the team via the app
|
||||
|
||||
- to ask any questions
|
||||
- to suggest any improvements
|
||||
- to share anything relevant
|
||||
|
||||
## Join user groups
|
||||
|
||||
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
|
||||
|
||||
You also can:
|
||||
- criticize the app, and make comparisons with other messengers.
|
||||
- share new messengers you think could be interesting for privacy, as long as you don't spam.
|
||||
- share some privacy related publications, infrequently.
|
||||
- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it.
|
||||
|
||||
You must:
|
||||
- be polite to other users
|
||||
- avoid spam (too frequent messages, even if they are relevant)
|
||||
- avoid any personal attacks or hostility.
|
||||
- avoid sharing any content that is not relevant to the above (that includes, but is not limited to, discussing politics or any aspects of society other than privacy, security, technology and communications, sharing any content that may be found offensive by other users, etc.).
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||
|
||||
You can also join the group created by other users by searching for them via the [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups.
|
||||
|
||||
## Make a private connection
|
||||
|
||||
You need to share a link with your friend or scan a QR code from their phone, in person or during a video call, to make a connection and start messaging.
|
||||
|
||||
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
||||
|
||||
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
|
||||
|
||||
After you connect, you can [verify connection security code](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification).
|
||||
|
||||
## User guide (NEW)
|
||||
|
||||
Read about the app features and settings in the new [User guide](./docs/guide/README.md).
|
||||
|
||||
## Help translating SimpleX Chat
|
||||
|
||||
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
|
||||
|
||||
Join our translators to help SimpleX grow!
|
||||
|
||||
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
||||
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
||||
|🇬🇧 en|English | |✓|✓|✓|✓|
|
||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||
|🇧🇬 bg|Български |-|[](https://hosted.weblate.org/projects/simplex-chat/android/bg/)<br>-|||
|
||||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
||||
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
||||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||
|🇯🇵 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/pl/)<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/)|||
|
||||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|
||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|
||||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||
|
||||
## Contribute
|
||||
|
||||
We would love to have you join the development! You can help us with:
|
||||
|
||||
- [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.
|
||||
|
||||
## Help us with donations
|
||||
|
||||
Huge thank you to everybody who donated to SimpleX Chat!
|
||||
|
||||
We are prioritizing users privacy and security - it would be impossible without your support.
|
||||
|
||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||
|
||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||
|
||||
It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- USDT:
|
||||
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
|
||||
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
||||
|
||||
Thank you,
|
||||
|
||||
Evgeny
|
||||
|
||||
SimpleX Chat founder
|
||||
**NEW**: Security audit by [Trail of Bits](https://www.trailofbits.com/about), the [new website](https://simplex.chat) and v4.2 released! [See the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -169,11 +37,15 @@ SimpleX Chat founder
|
||||
- [Users own SimpleX network](#users-own-simplex-network)
|
||||
- [Frequently asked questions](#frequently-asked-questions)
|
||||
- [News and updates](#news-and-updates)
|
||||
- [Make a private connection](#make-a-private-connection)
|
||||
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
||||
- [SimpleX Platform design](#simplex-platform-design)
|
||||
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
||||
- [For developers](#for-developers)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Join a user group](#join-a-user-group)
|
||||
- [Contribute](#contribute)
|
||||
- [Help us with donations](#help-us-with-donations)
|
||||
- [Disclaimers, Security contact, License](#disclaimers)
|
||||
|
||||
## Why privacy matters
|
||||
@@ -204,38 +76,34 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release announcement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
|
||||
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release annoucement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
|
||||
|
||||
2. _Why should I not just use Signal?_ Signal is a centralized platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
|
||||
2. _Why should I not just use Signal?_ Signal is a centralised platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
|
||||
|
||||
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identities?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages – it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
|
||||
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identites?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages – it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
|
||||
|
||||
## News and updates
|
||||
|
||||
Recent updates:
|
||||
|
||||
[July 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
||||
[Dec 06, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration.](./blog/20221206-simplex-chat-v4.3-voice-messages.md)
|
||||
|
||||
[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).
|
||||
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
||||
|
||||
[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).
|
||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.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).
|
||||
|
||||
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
|
||||
[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
||||
|
||||
[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
||||
|
||||
[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
|
||||
|
||||
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
|
||||
|
||||
[All updates](./blog)
|
||||
|
||||
## Make a private connection
|
||||
|
||||
You need to share a link or scan a QR code (in person or during a video call) to make a connection and start messaging.
|
||||
|
||||
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
||||
|
||||
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
|
||||
|
||||
## :zap: Quick installation of a terminal app
|
||||
|
||||
```sh
|
||||
@@ -266,25 +134,22 @@ 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 notificaitons on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||
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.
|
||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
||||
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
||||
|
||||
We plan to add soon:
|
||||
|
||||
1. Automatic message queue rotation. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
|
||||
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
|
||||
@@ -294,13 +159,11 @@ You can:
|
||||
|
||||
- use SimpleX Chat library to integrate chat functionality into your mobile apps.
|
||||
- create chat bots and services in Haskell - see [simple](./apps/simplex-bot/) and more [advanced chat bot example](./apps/simplex-bot-advanced/).
|
||||
- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
|
||||
- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScipt chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
|
||||
- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
|
||||
|
||||
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
||||
|
||||
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.
|
||||
@@ -328,42 +191,71 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
||||
- ✅ View deleted messages, full message deletion by sender (with recipient opt-in per contact).
|
||||
- ✅ Block screenshots and view in recent apps.
|
||||
- ✅ Advanced server configuration.
|
||||
- ✅ Disappearing messages (with recipient opt-in per-contact).
|
||||
- ✅ "Live" messages.
|
||||
- ✅ Contact verification via a separate out-of-band channel.
|
||||
- ✅ Multiple user profiles in the same chat database.
|
||||
- ✅ Optionally avoid re-using the same TCP session for multiple connections.
|
||||
- ✅ Preserve message drafts.
|
||||
- ✅ File server to optimize for efficient and private sending of large files.
|
||||
- ✅ Improved audio & video calls.
|
||||
- ✅ Support older Android OS and 32-bit CPUs.
|
||||
- ✅ Hidden chat profiles.
|
||||
- ✅ Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
- ✅ Video messages.
|
||||
- ✅ 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).
|
||||
- Include optional message into connection request sent via contact address.
|
||||
- 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.
|
||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||
- 🏗 Contact verification via a separate out-of-band channel.
|
||||
- 🏗 Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Optionally avoid re-using the same TCP session for multiple connections.
|
||||
- Access password/pin (with optional alternative access password).
|
||||
- Media server to optimize sending large files to groups.
|
||||
- Video messages.
|
||||
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||
- Multiple user profiles in the same chat database.
|
||||
- Feeds/broadcasts.
|
||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Privately share your location.
|
||||
- Unconfirmed: disappearing messages (with recipient opt-in per-contact).
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
||||
- Supporting the same profile on multiple devices.
|
||||
- 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.
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- High capacity multi-node SMP relays.
|
||||
- Channels server for large groups and broadcast channels.
|
||||
|
||||
## Join a user group
|
||||
|
||||
You can join a general group with more than 100 members: [#SimpleX-Group](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D).
|
||||
|
||||
You can also join smaller groups by countries/languages: [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FmIorjTDPG24jdLKXwutS6o9hdQQRZwfQ%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA9N0BZaECrAw3we3S1Wq4QO7NERBuPt9447immrB50wo%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22S8aISlOgkTMytSox9gAM2Q%3D%3D%22%7D) (German), [\#SimpleX-US](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FlTWmQplLEaoJyHnEL1-B3f2PtDsikcTs%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-hMBlsQjNxK2vaVhqW_UyAVtuoYqgYTigK4B9dJ9CGc%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22G0UtRHIn0TmPoo08h_cbTA%3D%3D%22%7D) (US/English), [\#SimpleX-France](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F11r6XyjwVMj0WDIUMbmNDXO996M_EN_1%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAXDmc2Lrj9WQOjEcWa0DeQHF3HcYOp9b68s8M_BJ7gEk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22EZCeSYpeIBkaQwCcpcF00w%3D%3D%22%7D), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FZSYM278L5WoZiApx3925EAjSXcsAVNVu%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA7RJ2wfT8zdfOLyE5OtWLEAPowj-q6F2HB0ExbATw8Gk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22fsVoklNGptt7n-droqJYUQ%3D%3D%22%7D) (Russian), [#SimpleX-NL](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmP0LbswSbfxoVkkxiWE2NYnBCgZ9Snvj%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAVwZuSsw4Mf52EaBNdNI3RebsLm0jg65ZIkcmH9E5uy8%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22M9xIULUNZx51Wsa5Kdb0Sg%3D%3D%22%7D) (Netherlands/Dutch), [#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaZ_wjh6QAYHB-LjyGtp8bllkzoq880u-%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-_Wulzc3j16i7t77XJ5wgwxeW8_Ea8GxetMo7K4MgjI%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22QWmXdrFzIeMd2OoEPMFkBQ%3D%3D%22%7D) (Italian).
|
||||
|
||||
You can join these groups either by opening these links in the app or by opening them in a desktop browser and scanning QR code.
|
||||
|
||||
Let us know if you'd like to add some other countries to the list.
|
||||
|
||||
Join via the app to share what's going on and ask any questions!
|
||||
|
||||
## Contribute
|
||||
|
||||
We would love to have you join the development! You can contribute to SimpleX Chat with:
|
||||
|
||||
- developing features - please connect to us via chat so we can help you get started.
|
||||
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
|
||||
- translate UI to some language - we are currently setting up the UI to simplify it, please get in touch and let us know if you would be able to support and update the translations.
|
||||
- translate website homepage - there is a lot of content we would like to share, it would help to bring the new users.
|
||||
|
||||
## Help us with donations
|
||||
|
||||
Huge thank you to everybody who donated to SimpleX Chat!
|
||||
|
||||
We are prioritizing users privacy and security - it would be impossible without your support.
|
||||
|
||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||
|
||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||
|
||||
It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- please let us know, via GitHub issue or chat, if you want to create a donation in some other cryptocurrency - we will add the address to the list.
|
||||
|
||||
Thank you,
|
||||
|
||||
Evgeny
|
||||
|
||||
SimpleX Chat founder
|
||||
|
||||
## Disclaimers
|
||||
|
||||
|
||||
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>
|
||||
199
apps/android/app/build.gradle
Normal file
199
apps/android/app/build.gradle
Normal file
@@ -0,0 +1,199 @@
|
||||
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 29
|
||||
targetSdk 32
|
||||
versionCode 77
|
||||
versionName "4.3.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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 "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"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
def buildType = "unknown"
|
||||
// Don't do anything if no compression is needed
|
||||
if (compression_level != "0") {
|
||||
tasks.whenTaskAdded { task ->
|
||||
if (task.name == 'packageDebug') {
|
||||
task.doLast {
|
||||
buildType = "debug"
|
||||
}
|
||||
task.finalizedBy compressApk
|
||||
} else if (task.name == 'packageRelease') {
|
||||
task.doLast {
|
||||
buildType = "release"
|
||||
}
|
||||
task.finalizedBy compressApk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("compressApk") {
|
||||
doLast {
|
||||
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"))
|
||||
}
|
||||
|
||||
// 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" />
|
||||
@@ -24,14 +23,14 @@
|
||||
|
||||
<application
|
||||
android:name="SimplexApp"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupOnly="false"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupOnly="true"
|
||||
android:backupAgent="BackupAgent"
|
||||
android:icon="@mipmap/icon"
|
||||
android:label="${app_name}"
|
||||
android:extractNativeLibs="${extract_native_libs}"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
<!-- android:localeConfig="@xml/locales_config"-->
|
||||
|
||||
<!-- Main activity -->
|
||||
<activity
|
||||
@@ -22,12 +22,10 @@ var TransformOperation;
|
||||
TransformOperation["Decrypt"] = "decrypt";
|
||||
})(TransformOperation || (TransformOperation = {}));
|
||||
let activeCall;
|
||||
let answerTimeout = 30000;
|
||||
const processCommand = (function () {
|
||||
const defaultIceServers = [
|
||||
{ urls: ["stun:stun.simplex.im:443"] },
|
||||
{ urls: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
{ urls: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
{ urls: ["turn:turn.simplex.im:443"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
];
|
||||
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
|
||||
return {
|
||||
@@ -102,16 +100,9 @@ const processCommand = (function () {
|
||||
const iceCandidates = getIceCandidates(pc, config);
|
||||
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker };
|
||||
await setupMediaStreams(call);
|
||||
let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
|
||||
pc.addEventListener("connectionstatechange", connectionStateChange);
|
||||
return call;
|
||||
async function connectionStateChange() {
|
||||
// "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView)
|
||||
// See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70)
|
||||
if (pc.connectionState !== "failed")
|
||||
connectionHandler();
|
||||
}
|
||||
async function connectionHandler() {
|
||||
sendMessageToNative({
|
||||
resp: {
|
||||
type: "connection",
|
||||
@@ -124,7 +115,6 @@ const processCommand = (function () {
|
||||
},
|
||||
});
|
||||
if (pc.connectionState == "disconnected" || pc.connectionState == "failed") {
|
||||
clearConnectionTimeout();
|
||||
pc.removeEventListener("connectionstatechange", connectionStateChange);
|
||||
if (activeCall) {
|
||||
setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
|
||||
@@ -132,7 +122,6 @@ const processCommand = (function () {
|
||||
endCall();
|
||||
}
|
||||
else if (pc.connectionState == "connected") {
|
||||
clearConnectionTimeout();
|
||||
const stats = (await pc.getStats());
|
||||
for (const stat of stats.values()) {
|
||||
const { type, state } = stat;
|
||||
@@ -152,12 +141,6 @@ const processCommand = (function () {
|
||||
}
|
||||
}
|
||||
}
|
||||
function clearConnectionTimeout() {
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
function serialize(x) {
|
||||
return LZString.compressToBase64(JSON.stringify(x));
|
||||
@@ -53,6 +53,10 @@ add_library( support SHARED IMPORTED )
|
||||
set_target_properties( support PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
|
||||
|
||||
add_library( crypto SHARED IMPORTED )
|
||||
set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libcrypto.so)
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
# can link multiple libraries, such as libraries you define in this
|
||||
# build script, prebuilt third-party libraries, or system libraries.
|
||||
@@ -60,8 +64,8 @@ set_target_properties( support PROPERTIES IMPORTED_LOCATION
|
||||
target_link_libraries( # Specifies the target library.
|
||||
app-lib
|
||||
|
||||
simplex support
|
||||
simplex support crypto
|
||||
|
||||
# Links the target library to the log library
|
||||
# included in the NDK.
|
||||
${log-lib})
|
||||
${log-lib})
|
||||
@@ -1,7 +1,4 @@
|
||||
#include <jni.h>
|
||||
//#include <string.h>
|
||||
//#include <stdlib.h>
|
||||
//#include <android/log.h>
|
||||
|
||||
// from the RTS
|
||||
void hs_init(int * argc, char **argv[]);
|
||||
@@ -10,19 +7,8 @@ void hs_init(int * argc, char **argv[]);
|
||||
void setLineBuffering(void);
|
||||
int pipe_std_to_socket(const char * name);
|
||||
|
||||
extern void __svfscanf(void){};
|
||||
extern void __vfwscanf(void){};
|
||||
extern void __memset_chk_fail(void){};
|
||||
extern void __strcpy_chk_generic(void){};
|
||||
extern void __strcat_chk_generic(void){};
|
||||
extern void __libc_globals(void){};
|
||||
extern void __rel_iplt_start(void){};
|
||||
|
||||
// Android 9 only, not 13
|
||||
extern void reallocarray(void){};
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_chat_simplex_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);
|
||||
@@ -30,7 +16,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();
|
||||
}
|
||||
@@ -38,24 +24,21 @@ Java_chat_simplex_common_platform_CoreKt_initHS(__unused JNIEnv *env, __unused j
|
||||
// from simplex-chat
|
||||
typedef long* chat_ctrl;
|
||||
|
||||
extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
|
||||
extern char *chat_migrate_init(const char *path, const char *key, chat_ctrl *ctrl);
|
||||
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
||||
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
|
||||
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
||||
extern char *chat_parse_markdown(const char *str);
|
||||
extern char *chat_parse_server(const char *str);
|
||||
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_chat_simplex_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) {
|
||||
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
||||
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
||||
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
|
||||
jlong _ctrl = (jlong) 0;
|
||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
|
||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, &_ctrl));
|
||||
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
|
||||
|
||||
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
|
||||
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
|
||||
@@ -70,28 +53,25 @@ 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);
|
||||
//jint length = (jint) (*env)->GetStringUTFLength(env, msg);
|
||||
//for (int i = 0; i < length; ++i)
|
||||
// __android_log_print(ANDROID_LOG_ERROR, "simplex", "%d: %02x\n", i, _msg[i]);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
|
||||
(*env)->ReleaseStringUTFChars(env, msg, _msg);
|
||||
return res;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -99,19 +79,9 @@ 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);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
|
||||
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
|
||||
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
|
||||
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
|
||||
(*env)->ReleaseStringUTFChars(env, salt, _salt);
|
||||
return res;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,18 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.backup.BackupAgentHelper
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import android.content.Context
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
|
||||
|
||||
class BackupAgent: BackupAgentHelper() {
|
||||
override fun onFullBackup(data: FullBackupDataOutput?) {
|
||||
if (applicationContext
|
||||
.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
.getBoolean(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
|
||||
) {
|
||||
super.onFullBackup(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
512
apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
Normal file
512
apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
Normal file
@@ -0,0 +1,512 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.os.SystemClock.elapsedRealtime
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
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.graphicsLayer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.*
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
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.newchat.*
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
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
|
||||
// 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(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
MainPage(
|
||||
m,
|
||||
userAuthorized,
|
||||
laFailed,
|
||||
::runAuthenticate,
|
||||
::setPerformLA,
|
||||
showLANotice = { m.controller.showLANotice(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 onStart() {
|
||||
super.onStart()
|
||||
val enteredBackgroundVal = enteredBackground.value
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30_000) {
|
||||
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()
|
||||
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()
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_unlock),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success ->
|
||||
userAuthorized.value = true
|
||||
is LAResult.Error, LAResult.Failed ->
|
||||
laFailed.value = true
|
||||
LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPerformLA(on: Boolean) {
|
||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (on) {
|
||||
enableLA()
|
||||
} else {
|
||||
disableLA()
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableLA() {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_enable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Error, LAResult.Failed -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableLA() {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_disable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
}
|
||||
is LAResult.Error, LAResult.Failed -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
}
|
||||
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) -> Unit,
|
||||
showLANotice: () -> Unit
|
||||
) {
|
||||
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
|
||||
var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(userAuthorized.value) {
|
||||
if (chatModel.controller.appPrefs.performLA.get()) {
|
||||
delay(500L)
|
||||
}
|
||||
chatsAccessAuthorized = userAuthorized.value == true
|
||||
}
|
||||
var showChatDatabaseError by rememberSaveable {
|
||||
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
||||
}
|
||||
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(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.auth_unlock),
|
||||
icon = Icons.Outlined.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()
|
||||
!chatsAccessAuthorized -> {
|
||||
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()
|
||||
|
||||
// Deletes files that were not sent but already stored in files directory.
|
||||
// Currently, it's voice records only
|
||||
if (it == null && chatModel.filesToDelete.isNotEmpty()) {
|
||||
chatModel.filesToDelete.forEach { it.delete() }
|
||||
chatModel.filesToDelete.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
ModalManager.shared.showInView()
|
||||
val invitation = chatModel.activeCallInvitation.value
|
||||
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||
AlertManager.shared.showInView()
|
||||
}
|
||||
}
|
||||
|
||||
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
when (intent?.action) {
|
||||
NtfManager.OpenChatAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||
if (chatId != null) {
|
||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||
chatModel.clearOverlays.value = true
|
||||
if (cInfo != null) withApi { openChat(cInfo, chatModel) }
|
||||
}
|
||||
}
|
||||
NtfManager.ShowChatsAction -> {
|
||||
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||
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 {
|
||||
"text/plain" == intent.type -> intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
chatModel.sharedContent.value = SharedContent.Text(it)
|
||||
}
|
||||
intent.type?.startsWith("image/") == true -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
|
||||
chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(it))
|
||||
} // All other mime types
|
||||
else -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
|
||||
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
// Close active chat and show a list of chats
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
when {
|
||||
intent.type?.startsWith("image/") == true -> (intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>)?.let {
|
||||
chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
|
||||
} // All other mime types
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
// TODO open from chat list view
|
||||
chatModel.appOpenUrl.value = uri
|
||||
} else {
|
||||
withUriAction(uri) { linkType ->
|
||||
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.showAlertMsg(
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
//fun testJson() {
|
||||
// val str: String = """
|
||||
// """.trimIndent()
|
||||
//
|
||||
// println(json.decodeFromString<APIResponse>(str))
|
||||
//}
|
||||
211
apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
Normal file
211
apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
Normal file
@@ -0,0 +1,211 @@
|
||||
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.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.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
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): 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
|
||||
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
lateinit var chatController: ChatController
|
||||
|
||||
var isAppOnForeground: Boolean = false
|
||||
|
||||
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
|
||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
|
||||
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
||||
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey)
|
||||
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)
|
||||
}
|
||||
|
||||
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 chats = chatController.apiGetChats()
|
||||
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 && 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()
|
||||
}
|
||||
|
||||
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")
|
||||
val server = LocalServerSocket(socketName)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.*
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.*
|
||||
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/
|
||||
// https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt
|
||||
|
||||
class SimplexService: Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var isStartingService = false
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var serviceNotification: Notification? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand startId: $startId")
|
||||
if (intent != null) {
|
||||
val action = intent.action
|
||||
Log.d(TAG, "intent action $action")
|
||||
when (action) {
|
||||
Action.START.name -> startService()
|
||||
else -> Log.e(TAG, "No action in the intent")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "null intent. Probably restarted by the system.")
|
||||
}
|
||||
return START_STICKY // to restart if killed
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Simplex service created")
|
||||
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)
|
||||
/**
|
||||
* The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
|
||||
* we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
|
||||
* To prevent that, we can call [stopSelf] only when the service made [startForeground] call
|
||||
* */
|
||||
if (stopAfterStart) {
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
} else {
|
||||
isServiceStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Simplex service destroyed")
|
||||
try {
|
||||
wakeLock?.let {
|
||||
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||
}
|
||||
wakeLock = null
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
|
||||
}
|
||||
isServiceStarted = false
|
||||
stopAfterStart = false
|
||||
saveServiceState(this, ServiceState.STOPPED)
|
||||
|
||||
// If notification service is enabled and battery optimization is disabled, restart the service
|
||||
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
||||
sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startService() {
|
||||
Log.d(TAG, "SimplexService startService")
|
||||
if (wakeLock != null || isStartingService) return
|
||||
val self = this
|
||||
isStartingService = true
|
||||
withApi {
|
||||
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(self)
|
||||
return@withApi
|
||||
}
|
||||
saveServiceState(self, ServiceState.STARTED)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isStartingService = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(): NotificationManager? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).let {
|
||||
it.setShowBadge(false) // no long-press badge
|
||||
it
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
return notificationManager
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun createNotification(title: String, text: String): Notification {
|
||||
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
|
||||
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ntf_service_icon)
|
||||
.setColor(0x88FFFF)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setSilent(true)
|
||||
.setShowWhen(false) // no date/time
|
||||
|
||||
// Shows a button which opens notification channel settings
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val setupIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
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, getString(R.string.hide_notification), setup)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null // no binding
|
||||
}
|
||||
|
||||
// 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
|
||||
MainActivity.clearAuthState()
|
||||
|
||||
// If notification service isn't enabled or battery optimization isn't disabled, we shouldn't restart the service
|
||||
if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
|
||||
return
|
||||
}
|
||||
|
||||
val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
|
||||
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);
|
||||
}
|
||||
|
||||
// restart on reboot
|
||||
class StartReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "StartReceiver: onReceive called")
|
||||
scheduleStart(context)
|
||||
}
|
||||
companion object {
|
||||
fun toggleReceiver(enable: Boolean) {
|
||||
Log.d(TAG, "StartReceiver: toggleReceiver enabled: $enable")
|
||||
val component = ComponentName(BuildConfig.APPLICATION_ID, StartReceiver::class.java.name)
|
||||
SimplexApp.context.packageManager.setComponentEnabledSetting(
|
||||
component,
|
||||
if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// restart on destruction
|
||||
class AutoRestartReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "AutoRestartReceiver: onReceive called")
|
||||
scheduleStart(context)
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceStartWorker(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val id = this.id
|
||||
if (context.applicationContext !is Application) {
|
||||
Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: $id)")
|
||||
return Result.failure()
|
||||
}
|
||||
if (getServiceState(context) == ServiceState.STARTED) {
|
||||
Log.d(TAG, "ServiceStartWorker: Starting foreground service (work ID: $id)")
|
||||
start(context)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
enum class Action {
|
||||
START,
|
||||
}
|
||||
|
||||
enum class ServiceState {
|
||||
STARTED,
|
||||
STOPPED,
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "SIMPLEX_SERVICE"
|
||||
const val NOTIFICATION_CHANNEL_ID = "chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION"
|
||||
const val NOTIFICATION_CHANNEL_NAME = "SimpleX Chat service"
|
||||
const val SIMPLEX_SERVICE_ID = 6789
|
||||
const val SERVICE_START_WORKER_VERSION = BuildConfig.VERSION_CODE
|
||||
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
|
||||
const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change!
|
||||
|
||||
private const val PASSPHRASE_NOTIFICATION_ID = 1535
|
||||
|
||||
private const val WAKE_LOCK_TAG = "SimplexService::lock"
|
||||
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS"
|
||||
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
||||
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||
|
||||
private var isServiceStarted = false
|
||||
private var stopAfterStart = false
|
||||
|
||||
fun scheduleStart(context: Context) {
|
||||
Log.d(TAG, "Enqueuing work to start subscriber service")
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
|
||||
workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
|
||||
}
|
||||
|
||||
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(context: Context) {
|
||||
if (isServiceStarted) {
|
||||
context.stopService(Intent(context, SimplexService::class.java))
|
||||
} else {
|
||||
stopAfterStart = true
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun serviceAction(context: Context, action: Action) {
|
||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||
withContext(Dispatchers.IO) {
|
||||
Intent(context, SimplexService::class.java).also {
|
||||
it.action = action.name
|
||||
ContextCompat.startForegroundService(context, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restart(context: Context) {
|
||||
Intent(context, SimplexService::class.java).also { intent ->
|
||||
context.stopService(intent) // Service will auto-restart
|
||||
}
|
||||
}
|
||||
|
||||
fun saveServiceState(context: Context, state: ServiceState) {
|
||||
getPreferences(context).edit()
|
||||
.putString(SHARED_PREFS_SERVICE_STATE, state.name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getServiceState(context: Context): ServiceState {
|
||||
val value = getPreferences(context)
|
||||
.getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name)
|
||||
return ServiceState.valueOf(value!!)
|
||||
}
|
||||
|
||||
fun showPassphraseNotification(chatDbStatus: DBMigrationResult?) {
|
||||
val pendingIntent: PendingIntent = Intent(SimplexApp.context, MainActivity::class.java).let { notificationIntent ->
|
||||
PendingIntent.getActivity(SimplexApp.context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
val title = when(chatDbStatus) {
|
||||
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_title)
|
||||
is DBMigrationResult.OK -> return
|
||||
else -> generalGetString(R.string.database_initialization_error_title)
|
||||
}
|
||||
|
||||
val description = when(chatDbStatus) {
|
||||
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_desc)
|
||||
is DBMigrationResult.OK -> return
|
||||
else -> generalGetString(R.string.database_initialization_error_desc)
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(SimplexApp.context, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ntf_service_icon)
|
||||
.setColor(0x88FFFF)
|
||||
.setContentTitle(title)
|
||||
.setContentText(description)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setSilent(true)
|
||||
.setShowWhen(false)
|
||||
|
||||
val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.notify(PASSPHRASE_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
fun cancelPassphraseNotification() {
|
||||
val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.cancel(PASSPHRASE_NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,36 @@
|
||||
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.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 ChatIdKey: String = "chatId"
|
||||
}
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -50,7 +38,15 @@ object NtfManager {
|
||||
private val msgNtfTimeoutMs = 30000L
|
||||
|
||||
init {
|
||||
if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert()
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
|
||||
// Remove old channels since they can't be edited
|
||||
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
||||
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
||||
}
|
||||
|
||||
enum class NotificationAction {
|
||||
ACCEPT_CONTACT_REQUEST
|
||||
}
|
||||
|
||||
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
|
||||
@@ -60,7 +56,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
|
||||
@@ -72,8 +68,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
|
||||
@@ -81,19 +77,43 @@ object NtfManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
||||
if (!user.showNotifications) return
|
||||
fun notifyContactRequestReceived(cInfo: ChatInfo.ContactRequest) {
|
||||
notifyMessageReceived(
|
||||
chatId = cInfo.id,
|
||||
displayName = cInfo.displayName,
|
||||
msgText = generalGetString(R.string.notification_new_contact_request),
|
||||
image = cInfo.image,
|
||||
listOf(NotificationAction.ACCEPT_CONTACT_REQUEST)
|
||||
)
|
||||
}
|
||||
|
||||
fun notifyContactConnected(contact: Contact) {
|
||||
notifyMessageReceived(
|
||||
chatId = contact.id,
|
||||
displayName = contact.displayName,
|
||||
msgText = generalGetString(R.string.notification_contact_connected)
|
||||
)
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
if (!cInfo.ntfsEnabled) return
|
||||
|
||||
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
||||
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||
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)
|
||||
@@ -106,50 +126,46 @@ object NtfManager {
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
.setVibrate(if (actions.isEmpty()) null else longArrayOf(0, 250, 250, 250))
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, user.userId, chatId))
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
|
||||
.setSilent(if (actions.isEmpty()) recentNotification else false)
|
||||
|
||||
for (action in actions) {
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val actionIntent = Intent(SimplexApp.context, NtfActionReceiver::class.java)
|
||||
actionIntent.action = action.name
|
||||
actionIntent.putExtra(UserIdKey, user.userId)
|
||||
actionIntent.putExtra(ChatIdKey, chatId)
|
||||
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
|
||||
val actionButton = when (action) {
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(MR.strings.accept)
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO -> generalGetString(MR.strings.accept_contact_incognito_button)
|
||||
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)
|
||||
.setGroup(MessageGroup)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setGroupSummary(true)
|
||||
.setContentIntent(chatPendingIntent(ShowChatsAction, null))
|
||||
.setContentIntent(chatPendingIntent(ShowChatsAction))
|
||||
.build()
|
||||
|
||||
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
|
||||
@@ -166,28 +182,28 @@ object NtfManager {
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.user.userId, invitation.contact.id))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(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))
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, contactId, true))
|
||||
.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)
|
||||
@@ -202,9 +218,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,19 +226,27 @@ object NtfManager {
|
||||
manager.cancel(CallNotificationId)
|
||||
}
|
||||
|
||||
fun cancelAllNotifications() {
|
||||
manager.cancelAll()
|
||||
}
|
||||
|
||||
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
|
||||
|
||||
private fun chatPendingIntent(intentAction: String, userId: Long?, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
||||
private fun hideSecrets(cItem: ChatItem) : String {
|
||||
val md = cItem.formattedText
|
||||
return if (md != null) {
|
||||
var res = ""
|
||||
for (ft in md) {
|
||||
res += if (ft.format is Format.Secret) "..." else ft.text
|
||||
}
|
||||
res
|
||||
} else {
|
||||
cItem.text
|
||||
}
|
||||
}
|
||||
|
||||
private fun chatPendingIntent(intentAction: String, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
||||
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||
var intent = Intent(context, if (!broadcast) MainActivity::class.java else NtfActionReceiver::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.setAction(intentAction)
|
||||
.putExtra(UserIdKey, userId)
|
||||
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
|
||||
return if (!broadcast) {
|
||||
TaskStackBuilder.create(context).run {
|
||||
@@ -236,40 +258,26 @@ object NtfManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function creates notifications channels. On Android 13+ calling it for the first time will trigger system alert,
|
||||
* The alert asks a user to allow or disallow to show notifications for the app. That's why it should be called only when the user
|
||||
* already saw such alert or when you want to trigger showing the alert.
|
||||
* On the first app launch the channels will be created after user profile is created. Subsequent calls will create new channels and delete
|
||||
* old ones if needed
|
||||
* */
|
||||
fun createNtfChannelsMaybeShowAlert() {
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(MR.strings.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(MR.strings.ntf_channel_calls)))
|
||||
// Remove old channels since they can't be edited
|
||||
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
||||
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes every action specified by [NotificationCompat.Builder.addAction] that comes with [NotificationAction]
|
||||
* and [ChatInfo.id] as [ChatIdKey] in extra
|
||||
* */
|
||||
class NtfActionReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val userId = getUserIdFromIntent(intent)
|
||||
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
|
||||
val m = SimplexApp.context.chatModel
|
||||
val cInfo = SimplexApp.context.chatModel.getChat(chatId)?.chatInfo
|
||||
when (intent.action) {
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, incognito = false, chatId)
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO.name -> ntfManager.acceptContactRequestAction(userId, incognito = true, chatId)
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
|
||||
if (cInfo !is ChatInfo.ContactRequest) return
|
||||
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
|
||||
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
|
||||
}
|
||||
RejectCallAction -> {
|
||||
val invitation = m.callInvitations[chatId]
|
||||
val invitation = SimplexApp.context.chatModel.callInvitations[chatId]
|
||||
if (invitation != null) {
|
||||
m.callManager.endCall(invitation = invitation)
|
||||
SimplexApp.context.chatModel.callManager.endCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
||||
}
|
||||
2898
apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
Normal file
2898
apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
Normal file
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
|
||||
|
||||
@@ -18,11 +18,12 @@ val MessagePreviewDark = Color(179, 175, 174, 255)
|
||||
val MessagePreviewLight = Color(49, 45, 44, 255)
|
||||
val ToolbarLight = Color(220, 220, 220, 12)
|
||||
val ToolbarDark = Color(80, 80, 80, 12)
|
||||
val SettingsBackgroundLight = Color(220, 216, 215, 90)
|
||||
val SettingsSecondaryLight = Color(200, 196, 195, 90)
|
||||
val GroupDark = Color(80, 80, 80, 60)
|
||||
val IncomingCallLight = Color(239, 237, 236, 255)
|
||||
val IncomingCallDark = Color(34, 30, 29, 255)
|
||||
val WarningOrange = Color(255, 127, 0, 255)
|
||||
val WarningYellow = Color(255, 192, 0, 255)
|
||||
val FileLight = Color(183, 190, 199, 255)
|
||||
val FileDark = Color(101, 101, 106, 255)
|
||||
val MenuTextColorDark = Color.White.copy(alpha = 0.8f)
|
||||
@@ -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
|
||||
@@ -8,4 +8,4 @@ val Shapes = Shapes(
|
||||
small = RoundedCornerShape(4.dp),
|
||||
medium = RoundedCornerShape(4.dp),
|
||||
large = RoundedCornerShape(0.dp)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.SimplexApp
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
enum class DefaultTheme {
|
||||
SYSTEM, DARK, LIGHT
|
||||
}
|
||||
|
||||
val DEFAULT_PADDING = 16.dp
|
||||
val DEFAULT_SPACE_AFTER_ICON = 4.dp
|
||||
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
|
||||
|
||||
val DarkColorPalette = darkColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = DarkGray,
|
||||
// background = Color.Black,
|
||||
// surface = Color.Black,
|
||||
// background = Color(0xFF121212),
|
||||
// surface = Color(0xFF121212),
|
||||
// error = Color(0xFFCF6679),
|
||||
onBackground = Color(0xFFFFFBFA),
|
||||
onSurface = Color(0xFFFFFBFA),
|
||||
// onError: Color = Color.Black,
|
||||
)
|
||||
val LightColorPalette = lightColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = LightGray,
|
||||
// background = Color.White,
|
||||
// surface = Color.White
|
||||
// onPrimary = Color.White,
|
||||
// onSecondary = Color.Black,
|
||||
// onBackground = Color.Black,
|
||||
// onSurface = Color.Black,
|
||||
)
|
||||
|
||||
val CurrentColors: MutableStateFlow<Pair<Colors, DefaultTheme>> = 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.first.isLight
|
||||
|
||||
@Composable
|
||||
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
||||
LaunchedEffect(darkTheme) {
|
||||
// For preview
|
||||
if (darkTheme != null)
|
||||
CurrentColors.value = ThemeManager.currentColors(darkTheme)
|
||||
}
|
||||
val systemDark = isSystemInDarkTheme()
|
||||
LaunchedEffect(systemDark) {
|
||||
if (CurrentColors.value.second == DefaultTheme.SYSTEM && CurrentColors.value.first.isLight == systemDark) {
|
||||
// Change active colors from light to dark and back based on system theme
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
|
||||
}
|
||||
}
|
||||
val theme by CurrentColors.collectAsState()
|
||||
MaterialTheme(
|
||||
colors = theme.first,
|
||||
typography = Typography,
|
||||
shapes = Shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.material.Colors
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
object ThemeManager {
|
||||
private val appPrefs: AppPreferences by lazy {
|
||||
AppPreferences(SimplexApp.context)
|
||||
}
|
||||
|
||||
fun currentColors(darkForSystemTheme: Boolean): Pair<Colors, DefaultTheme> {
|
||||
val theme = appPrefs.currentTheme.get()!!
|
||||
val systemThemeColors = if (darkForSystemTheme) DarkColorPalette else LightColorPalette
|
||||
val res = when (theme) {
|
||||
DefaultTheme.SYSTEM.name -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
|
||||
DefaultTheme.DARK.name -> Pair(DarkColorPalette, DefaultTheme.DARK)
|
||||
DefaultTheme.LIGHT.name -> Pair(LightColorPalette, DefaultTheme.LIGHT)
|
||||
else -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
|
||||
}
|
||||
return res.copy(first = res.first.copy(primary = Color(appPrefs.primaryColor.get())))
|
||||
}
|
||||
|
||||
// colors, default theme enum, localized name of theme
|
||||
fun allThemes(darkForSystemTheme: Boolean): List<Triple<Colors, DefaultTheme, String>> {
|
||||
val allThemes = ArrayList<Triple<Colors, DefaultTheme, String>>()
|
||||
allThemes.add(
|
||||
Triple(
|
||||
if (darkForSystemTheme) DarkColorPalette else LightColorPalette,
|
||||
DefaultTheme.SYSTEM,
|
||||
generalGetString(R.string.theme_system)
|
||||
)
|
||||
)
|
||||
allThemes.add(
|
||||
Triple(
|
||||
LightColorPalette,
|
||||
DefaultTheme.LIGHT,
|
||||
generalGetString(R.string.theme_light)
|
||||
)
|
||||
)
|
||||
allThemes.add(
|
||||
Triple(
|
||||
DarkColorPalette,
|
||||
DefaultTheme.DARK,
|
||||
generalGetString(R.string.theme_dark)
|
||||
)
|
||||
)
|
||||
return allThemes
|
||||
}
|
||||
|
||||
fun applyTheme(name: String, darkForSystemTheme: Boolean) {
|
||||
appPrefs.currentTheme.set(name)
|
||||
CurrentColors.value = currentColors(darkForSystemTheme)
|
||||
}
|
||||
|
||||
fun saveAndApplyPrimaryColor(color: Color) {
|
||||
appPrefs.primaryColor.set(color.toArgb())
|
||||
CurrentColors.value = currentColors(!CurrentColors.value.first.isLight)
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
package chat.simplex.common.views
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
@@ -10,11 +11,11 @@ import androidx.compose.ui.Modifier
|
||||
fun SplashView() {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
// Image(
|
||||
// painter = painterResource(MR.images.logo),
|
||||
// painter = painterResource(R.drawable.logo),
|
||||
// contentDescription = "Simplex Icon",
|
||||
// modifier = Modifier
|
||||
// .height(230.dp)
|
||||
@@ -1,43 +1,99 @@
|
||||
package chat.simplex.common.views
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.SystemClock
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import 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 androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
|
||||
private val lastSuccessfulAuth: MutableState<Long?> = mutableStateOf(null)
|
||||
|
||||
@Composable
|
||||
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
||||
val close = {
|
||||
close()
|
||||
if (appPlatform.isDesktop) {
|
||||
ModalManager.center.closeModals()
|
||||
}
|
||||
}
|
||||
val lastSuccessfulAuth = remember { lastSuccessfulAuth }
|
||||
BackHandler(onBack = {
|
||||
lastSuccessfulAuth.value = null
|
||||
close()
|
||||
})
|
||||
TerminalLayout(
|
||||
remember { chatModel.terminalItems },
|
||||
composeState,
|
||||
sendCommand = { sendCommand(chatModel, composeState) },
|
||||
close
|
||||
val authorized = remember { !chatModel.controller.appPrefs.performLA.get() }
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(lastSuccessfulAuth.value) {
|
||||
if (!authorized && !authorizedPreviously(lastSuccessfulAuth)) {
|
||||
runAuth(lastSuccessfulAuth, context)
|
||||
}
|
||||
}
|
||||
if (authorized || authorizedPreviously(lastSuccessfulAuth)) {
|
||||
LaunchedEffect(Unit) {
|
||||
// Update auth each time user visits this screen in authenticated state just to prolong authorized time
|
||||
lastSuccessfulAuth.value = SystemClock.elapsedRealtime()
|
||||
}
|
||||
TerminalLayout(
|
||||
chatModel.terminalItems,
|
||||
composeState,
|
||||
sendCommand = { sendCommand(chatModel, composeState) },
|
||||
close
|
||||
)
|
||||
} else {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.background(MaterialTheme.colors.background)) {
|
||||
CloseSheetBar(close)
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.auth_unlock),
|
||||
icon = Icons.Outlined.Lock,
|
||||
click = {
|
||||
runAuth(lastSuccessfulAuth, context)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun authorizedPreviously(lastSuccessfulAuth: State<Long?>): Boolean =
|
||||
lastSuccessfulAuth.value?.let { SystemClock.elapsedRealtime() - it < 30_000 } ?: false
|
||||
|
||||
private fun runAuth(lastSuccessfulAuth: MutableState<Long?>, context: Context) {
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_open_chat_console),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
lastSuccessfulAuth.value = when (laResult) {
|
||||
LAResult.Success, LAResult.Unavailable -> SystemClock.elapsedRealtime()
|
||||
is LAResult.Error, LAResult.Failed -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,9 +102,9 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState<Compose
|
||||
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
|
||||
val s = composeState.value.message
|
||||
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
|
||||
val resp = CR.ChatCmdError(null, ChatError.ChatErrorChat(ChatErrorType.CommandError("Failed reading: empty")))
|
||||
chatModel.addTerminalItem(TerminalItem.cmd(CC.Console(s)))
|
||||
chatModel.addTerminalItem(TerminalItem.resp(resp))
|
||||
val resp = CR.ChatCmdError(ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
|
||||
chatModel.terminalItems.add(TerminalItem.cmd(CC.Console(s)))
|
||||
chatModel.terminalItems.add(TerminalItem.resp(resp))
|
||||
composeState.value = ComposeState(useLinkPreviews = false)
|
||||
} else {
|
||||
withApi {
|
||||
@@ -79,24 +135,7 @@ fun TerminalLayout(
|
||||
topBar = { CloseSheetBar(close) },
|
||||
bottomBar = {
|
||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||
SendMsgView(
|
||||
composeState = composeState,
|
||||
showVoiceRecordIcon = false,
|
||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||
isDirectChat = false,
|
||||
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = { sendCommand() },
|
||||
sendLiveMessage = null,
|
||||
updateLiveMessage = null,
|
||||
editPrevMessage = {},
|
||||
onMessageChange = ::onMessageChange,
|
||||
textStyle = textStyle
|
||||
)
|
||||
SendMsgView(composeState, false, false, false, sendCommand, ::onMessageChange, { _, _, _ -> }, {}, {}, textStyle)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
@@ -104,8 +143,8 @@ fun TerminalLayout(
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.fillMaxWidth(),
|
||||
color = MaterialTheme.colors.background
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
TerminalLog(terminalItems)
|
||||
}
|
||||
@@ -121,8 +160,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
}
|
||||
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
|
||||
LazyColumn(state = listState, reverseLayout = true) {
|
||||
items(reversedTerminalItems) { item ->
|
||||
Text(
|
||||
@@ -133,7 +171,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
ModalManager.start.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) {
|
||||
ModalManager.shared.showModal {
|
||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
|
||||
}
|
||||
@@ -144,11 +182,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 {
|
||||
@@ -0,0 +1,141 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBackIosNew
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexService
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Profile
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
fun isValidDisplayName(name: String) : Boolean {
|
||||
return (name.firstOrNull { it.isWhitespace() }) == null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateProfilePanel(chatModel: ChatModel) {
|
||||
val displayName = remember { mutableStateOf("") }
|
||||
val fullName = remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.create_profile), false)
|
||||
ReadableText(R.string.your_profile_is_stored_on_your_device)
|
||||
ReadableText(R.string.profile_is_only_shared_with_your_contacts)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
stringResource(R.string.display_name),
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
ProfileNameField(displayName, focusRequester)
|
||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
stringResource(R.string.full_name_optional__prompt),
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
SimpleButton(
|
||||
text = stringResource(R.string.about_simplex),
|
||||
icon = Icons.Outlined.ArrowBackIosNew
|
||||
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
|
||||
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
val createModifier: Modifier
|
||||
val createColor: Color
|
||||
if (enabled) {
|
||||
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value) }.padding(8.dp)
|
||||
createColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
createModifier = Modifier.padding(8.dp)
|
||||
createColor = HighOrLowlight
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
||||
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor)
|
||||
Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
|
||||
withApi {
|
||||
val user = chatModel.controller.apiCreateActiveUser(
|
||||
Profile(displayName, fullName, null)
|
||||
)
|
||||
chatModel.controller.startChat(user)
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
SimplexService.start(chatModel.controller.appContext)
|
||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileNameField(name: MutableState<String>, focusRequester: FocusRequester? = null) {
|
||||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.secondary)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.padding(8.dp)
|
||||
.navigationBarsWithImePadding()
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(HighOrLowlight)
|
||||
)
|
||||
}
|
||||
@@ -1,10 +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 chat.simplex.common.views.helpers.withBGApi
|
||||
import chat.simplex.common.views.usersettings.showInDevelopingAlert
|
||||
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
|
||||
|
||||
@@ -13,23 +13,18 @@ class CallManager(val chatModel: ChatModel) {
|
||||
Log.d(TAG, "CallManager.reportNewIncomingCall")
|
||||
with (chatModel) {
|
||||
callInvitations[invitation.contact.id] = invitation
|
||||
if (invitation.user.showNotifications) {
|
||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||
activeCallInvitation.value = invitation
|
||||
ntfManager.notifyCallInvitation(invitation)
|
||||
} else {
|
||||
val contact = invitation.contact
|
||||
ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||
}
|
||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||
activeCallInvitation.value = invitation
|
||||
controller.ntfManager.notifyCallInvitation(invitation)
|
||||
} else {
|
||||
val contact = invitation.contact
|
||||
controller.ntfManager.notifyMessageReceived(chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptIncomingCall(invitation: RcvCallInvitation) {
|
||||
if (appPlatform.isDesktop) {
|
||||
return showInDevelopingAlert()
|
||||
}
|
||||
|
||||
ModalManager.shared.closeModals()
|
||||
val call = chatModel.activeCall.value
|
||||
if (call == null) {
|
||||
justAcceptIncomingCall(invitation = invitation)
|
||||
@@ -52,7 +47,7 @@ class CallManager(val chatModel: ChatModel) {
|
||||
contact = invitation.contact,
|
||||
callState = CallState.InvitationAccepted,
|
||||
localMedia = invitation.callType.media,
|
||||
sharedKey = invitation.sharedKey,
|
||||
sharedKey = invitation.sharedKey
|
||||
)
|
||||
showCallView.value = true
|
||||
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
|
||||
@@ -67,7 +62,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +88,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)) {
|
||||
@@ -106,7 +101,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,109 +1,76 @@
|
||||
package chat.simplex.common.views.call
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.media.*
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
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.compose.foundation.*
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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 androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.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.model.ChatModel
|
||||
import chat.simplex.common.model.Contact
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
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.SimpleXTheme
|
||||
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
|
||||
var btDeviceCount = 0
|
||||
val audioCallback = object: AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||
Log.d(TAG, "Added audio devices: ${addedDevices.map { it.type }}")
|
||||
super.onAudioDevicesAdded(addedDevices)
|
||||
val addedCount = addedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||
btDeviceCount += addedCount
|
||||
audioViaBluetooth.value = btDeviceCount > 0
|
||||
if (addedCount > 0 && chatModel.activeCall.value?.callState == CallState.Connected) {
|
||||
// Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12
|
||||
setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth)
|
||||
}
|
||||
}
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||
Log.d(TAG, "Removed audio devices: ${removedDevices.map { it.type }}")
|
||||
super.onAudioDevicesRemoved(removedDevices)
|
||||
val removedCount = removedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||
btDeviceCount -= removedCount
|
||||
audioViaBluetooth.value = btDeviceCount > 0
|
||||
if (btDeviceCount == 0 && chatModel.activeCall.value?.callState == CallState.Connected) {
|
||||
// Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12
|
||||
setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth)
|
||||
}
|
||||
}
|
||||
}
|
||||
am.registerAudioDeviceCallback(audioCallback, null)
|
||||
val pm = (androidAppContext.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")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
proximityLock?.acquire()
|
||||
onDispose {
|
||||
// Stop it when call ended
|
||||
if (!ntfModeService) platform.androidServiceSafeStop()
|
||||
dropAudioManagerOverrides()
|
||||
am.unregisterAudioDeviceCallback(audioCallback)
|
||||
proximityLock?.release()
|
||||
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
||||
// Clear selected communication device to default value after we changed it in call
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.clearCommunicationDevice()
|
||||
}
|
||||
}
|
||||
}
|
||||
val cxt = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
WebRTCView(chatModel.callCommand) { apiMsg ->
|
||||
@@ -133,7 +100,6 @@ actual fun ActiveCallView() {
|
||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||
} catch (e: Error) {
|
||||
@@ -142,7 +108,8 @@ actual fun ActiveCallView() {
|
||||
is WCallResponse.Connected -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
||||
scope.launch {
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
delay(2000L)
|
||||
setCallSound(cxt, call)
|
||||
}
|
||||
}
|
||||
is WCallResponse.Ended -> {
|
||||
@@ -176,31 +143,26 @@ actual fun ActiveCallView() {
|
||||
}
|
||||
}
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth)
|
||||
if (call != null) ActiveCallOverlay(call, chatModel)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
val prevVolumeControlStream = activity.volumeControlStream
|
||||
activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
|
||||
// Lock orientation to portrait in order to have good experience with calls
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
chatModel.activeCallViewIsVisible.value = true
|
||||
onDispose {
|
||||
activity.volumeControlStream = prevVolumeControlStream
|
||||
// Unlock orientation
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
chatModel.activeCallViewIsVisible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetooth: MutableState<Boolean>) {
|
||||
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
|
||||
var cxt = LocalContext.current
|
||||
ActiveCallOverlayLayout(
|
||||
call = call,
|
||||
speakerCanBeEnabled = !audioViaBluetooth.value,
|
||||
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
||||
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
||||
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
||||
@@ -209,80 +171,61 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
|
||||
if (call != null) {
|
||||
call = call.copy(soundSpeaker = !call.soundSpeaker)
|
||||
chatModel.activeCall.value = call
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
setCallSound(cxt, call)
|
||||
}
|
||||
},
|
||||
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
|
||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val btDevice = am.availableCommunicationDevices.lastOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||
val preferredSecondaryDevice = if (speaker) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
|
||||
if (btDevice != null) {
|
||||
am.setCommunicationDevice(btDevice)
|
||||
} else if (am.communicationDevice?.type != preferredSecondaryDevice) {
|
||||
am.availableCommunicationDevices.firstOrNull { it.type == preferredSecondaryDevice }?.let {
|
||||
private fun setCallSound(cxt: Context, call: Call) {
|
||||
Log.d(TAG, "setCallSound: set audio mode")
|
||||
val am = cxt.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
if (call.soundSpeaker) {
|
||||
am.mode = AudioManager.MODE_NORMAL
|
||||
am.isSpeakerphoneOn = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }?.let {
|
||||
am.setCommunicationDevice(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (audioViaBluetooth.value) {
|
||||
am.isSpeakerphoneOn = false
|
||||
am.startBluetoothSco()
|
||||
} else {
|
||||
am.stopBluetoothSco()
|
||||
am.isSpeakerphoneOn = speaker
|
||||
}
|
||||
am.isBluetoothScoOn = am.isBluetoothScoAvailableOffCall && audioViaBluetooth.value
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropAudioManagerOverrides() {
|
||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.mode = AudioManager.MODE_NORMAL
|
||||
// Clear selected communication device to default value after we changed it in call
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
am.clearCommunicationDevice()
|
||||
} else {
|
||||
am.mode = AudioManager.MODE_IN_CALL
|
||||
am.isSpeakerphoneOn = false
|
||||
am.stopBluetoothSco()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE }?.let {
|
||||
am.setCommunicationDevice(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallOverlayLayout(
|
||||
call: Call,
|
||||
speakerCanBeEnabled: Boolean,
|
||||
dismiss: () -> Unit,
|
||||
toggleAudio: () -> Unit,
|
||||
toggleVideo: () -> Unit,
|
||||
toggleSound: () -> Unit,
|
||||
flipCamera: () -> Unit
|
||||
) {
|
||||
Column(Modifier.padding(DEFAULT_PADDING)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
when (call.peerMedia ?: call.localMedia) {
|
||||
CallMediaType.Video -> {
|
||||
CallInfoView(call, alignment = Alignment.Start)
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||
DisabledBackgroundCallsButton()
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
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(Icons.Filled.CallEnd, 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, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera)
|
||||
ControlButton(call, Icons.Filled.Videocam, 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, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,13 +239,11 @@ private fun ActiveCallOverlayLayout(
|
||||
ProfileImage(size = 192.dp, image = call.contact.profile.image)
|
||||
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||
DisabledBackgroundCallsButton()
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 48.dp), 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(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(start = 32.dp)) {
|
||||
@@ -310,7 +251,7 @@ private fun ActiveCallOverlayLayout(
|
||||
}
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.padding(end = 32.dp)) {
|
||||
ToggleSoundButton(call, speakerCanBeEnabled, toggleSound)
|
||||
ToggleSoundButton(call, toggleSound)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,10 +261,10 @@ private fun ActiveCallOverlayLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, action: () -> Unit, enabled: Boolean = true) {
|
||||
private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit) {
|
||||
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))
|
||||
IconButton(onClick = action) {
|
||||
Icon(icon, stringResource(iconText), tint = Color(0xFFFFFFD8), modifier = Modifier.size(40.dp))
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(40.dp))
|
||||
@@ -333,18 +274,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, Icons.Outlined.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, Icons.Outlined.MicOff, R.string.icon_descr_audio_on, toggleAudio)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
|
||||
private fun ToggleSoundButton(call: Call, toggleSound: () -> Unit) {
|
||||
if (call.soundSpeaker) {
|
||||
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, toggleSound, enabled)
|
||||
ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound)
|
||||
} else {
|
||||
ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, toggleSound, enabled)
|
||||
ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,35 +297,10 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
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})"
|
||||
InfoText(call.encryptionStatus + connInfoText)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisabledBackgroundCallsButton() {
|
||||
var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) }
|
||||
if (show) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(bottom = 24.dp)
|
||||
.clickable {
|
||||
withBGApi {
|
||||
show = !platform.androidAskToAllowBackgroundCalls()
|
||||
}
|
||||
}
|
||||
.background(WarningOrange.copy(0.3f), RoundedCornerShape(50))
|
||||
.padding(start = 14.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(MR.strings.system_restricted_background_in_call_title), color = WarningOrange)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(onClick = { show = false }, Modifier.size(24.dp)) {
|
||||
Icon(painterResource(MR.images.ic_close), null, tint = WarningOrange)
|
||||
}
|
||||
}
|
||||
val connInfo =
|
||||
if (call.connectionInfo == null) ""
|
||||
else " (${call.connectionInfo.text})"
|
||||
InfoText(call.encryptionStatus + connInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +315,7 @@ private fun DisabledBackgroundCallsButton() {
|
||||
// horizontalAlignment = Alignment.CenterHorizontally,
|
||||
// verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
// modifier = Modifier
|
||||
// .themedBackground()
|
||||
// .background(MaterialTheme.colors.background)
|
||||
// .fillMaxSize()
|
||||
// ) {
|
||||
// WebRTCView(callCommand) { apiMsg ->
|
||||
@@ -564,12 +480,8 @@ fun PreviewActiveCallOverlayVideo() {
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Video,
|
||||
peerMedia = CallMediaType.Video,
|
||||
connectionInfo = ConnectionInfo(
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null),
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null)
|
||||
)
|
||||
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
||||
),
|
||||
speakerCanBeEnabled = true,
|
||||
dismiss = {},
|
||||
toggleAudio = {},
|
||||
toggleVideo = {},
|
||||
@@ -589,12 +501,8 @@ fun PreviewActiveCallOverlayAudio() {
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Audio,
|
||||
peerMedia = CallMediaType.Audio,
|
||||
connectionInfo = ConnectionInfo(
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null),
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null)
|
||||
)
|
||||
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
||||
),
|
||||
speakerCanBeEnabled = true,
|
||||
dismiss = {},
|
||||
toggleAudio = {},
|
||||
toggleVideo = {},
|
||||
@@ -4,45 +4,47 @@ 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.compose.foundation.Image
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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 androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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 chat.simplex.app.views.onboarding.SimpleXLogo
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -96,12 +98,11 @@ fun IncomingCallActivityView(m: ChatModel) {
|
||||
SimpleXTheme {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background
|
||||
) {
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()) {
|
||||
if (showCallView) {
|
||||
Box {
|
||||
ActiveCallView()
|
||||
ActiveCallView(m)
|
||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||
}
|
||||
} else if (invitation != null) {
|
||||
@@ -119,23 +120,21 @@ 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(
|
||||
invitation,
|
||||
callOnLockScreen,
|
||||
chatModel,
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
ntfManager.cancelCallNotification()
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
},
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||
openApp = {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.setAction(OpenChatAction)
|
||||
.putExtra("userId", invitation.user.userId)
|
||||
.putExtra("chatId", invitation.contact.id)
|
||||
context.startActivity(intent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -150,7 +149,6 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
fun IncomingCallLockScreenAlertLayout(
|
||||
invitation: RcvCallInvitation,
|
||||
callOnLockScreen: CallOnLockScreen?,
|
||||
chatModel: ChatModel,
|
||||
rejectCall: () -> Unit,
|
||||
ignoreCall: () -> Unit,
|
||||
acceptCall: () -> Unit,
|
||||
@@ -162,42 +160,31 @@ fun IncomingCallLockScreenAlertLayout(
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
IncomingCallInfo(invitation, chatModel)
|
||||
IncomingCallInfo(invitation)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
|
||||
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
|
||||
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), Icons.Filled.CallEnd, 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), Icons.Filled.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), Icons.Filled.Check, 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 = Icons.Filled.Check, click = openApp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimpleXLogo() {
|
||||
Image(
|
||||
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
||||
contentDescription = stringResource(MR.strings.image_descr_simplex_logo),
|
||||
modifier = Modifier
|
||||
.padding(vertical = DEFAULT_PADDING)
|
||||
.fillMaxWidth(0.80f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockScreenCallButton(text: String, icon: Painter, color: Color, action: () -> Unit) {
|
||||
private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color.Transparent
|
||||
@@ -211,34 +198,31 @@ private fun LockScreenCallButton(text: String, icon: Painter, color: Color, acti
|
||||
IconButton(action) {
|
||||
Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f))
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Text(text, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewIncomingCallLockScreenAlert() {
|
||||
SimpleXTheme(true) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background
|
||||
) {
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()) {
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
invitation = RcvCallInvitation(
|
||||
user = User.sampleData,
|
||||
contact = Contact.sampleData,
|
||||
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||
sharedKey = null,
|
||||
callTs = Clock.System.now()
|
||||
),
|
||||
callOnLockScreen = null,
|
||||
chatModel = SimplexApp.context.chatModel,
|
||||
rejectCall = {},
|
||||
ignoreCall = {},
|
||||
acceptCall = {},
|
||||
@@ -1,41 +1,41 @@
|
||||
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
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.model.ChatModel
|
||||
import chat.simplex.app.model.Contact
|
||||
import chat.simplex.app.ui.theme.*
|
||||
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) }
|
||||
)
|
||||
@@ -44,45 +44,40 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
@Composable
|
||||
fun IncomingCallAlertLayout(
|
||||
invitation: RcvCallInvitation,
|
||||
chatModel: ChatModel,
|
||||
rejectCall: () -> Unit,
|
||||
ignoreCall: () -> Unit,
|
||||
acceptCall: () -> Unit
|
||||
) {
|
||||
val color = if (isInDarkTheme()) MaterialTheme.colors.surface else IncomingCallLight
|
||||
Column(Modifier.fillMaxWidth().background(color).padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
|
||||
IncomingCallInfo(invitation, chatModel)
|
||||
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||
Column(Modifier.fillMaxWidth().background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
|
||||
IncomingCallInfo(invitation)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
ProfilePreview(profileOf = invitation.contact, size = 64.dp)
|
||||
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CallButton(stringResource(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), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
||||
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
||||
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallInfo(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
@Composable fun CallIcon(icon: Painter, descr: String) = Icon(icon, descr, tint = SimplexGreen)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (chatModel.users.size > 1) {
|
||||
ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.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))
|
||||
fun IncomingCallInfo(invitation: RcvCallInvitation) {
|
||||
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
|
||||
Row {
|
||||
if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
|
||||
else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(invitation.callTypeText, color = MaterialTheme.colors.onBackground)
|
||||
Text(invitation.callTypeText)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallButton(text: String, icon: Painter, color: Color, action: () -> Unit) {
|
||||
private fun CallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color.Transparent
|
||||
@@ -95,7 +90,7 @@ private fun CallButton(text: String, icon: Painter, color: Color, action: () ->
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
|
||||
Text(text, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary)
|
||||
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,13 +101,11 @@ fun PreviewIncomingCallAlertLayout() {
|
||||
SimpleXTheme {
|
||||
IncomingCallAlertLayout(
|
||||
invitation = RcvCallInvitation(
|
||||
user = User.sampleData,
|
||||
contact = Contact.sampleData,
|
||||
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||
sharedKey = null,
|
||||
callTs = Clock.System.now()
|
||||
),
|
||||
chatModel = 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.Contact
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
data class Call(
|
||||
val contact: Contact,
|
||||
val callState: CallState,
|
||||
val localMedia: CallMediaType,
|
||||
val localCapabilities: CallCapabilities? = null,
|
||||
val peerMedia: CallMediaType? = null,
|
||||
val sharedKey: String? = null,
|
||||
val audioEnabled: Boolean = true,
|
||||
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
|
||||
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
|
||||
var localCamera: VideoCamera = VideoCamera.User,
|
||||
val connectionInfo: ConnectionInfo? = null
|
||||
) {
|
||||
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
||||
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
||||
|
||||
val encryptionStatus: String @Composable get() = when(callState) {
|
||||
CallState.WaitCapabilities -> ""
|
||||
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
|
||||
}
|
||||
|
||||
enum class CallState {
|
||||
WaitCapabilities,
|
||||
InvitationSent,
|
||||
InvitationAccepted,
|
||||
OfferSent,
|
||||
OfferReceived,
|
||||
AnswerReceived,
|
||||
Negotiated,
|
||||
Connected,
|
||||
Ended;
|
||||
|
||||
val text: String @Composable get() = when(this) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
|
||||
@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
|
||||
|
||||
@Serializable
|
||||
sealed class WCallCommand {
|
||||
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
|
||||
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||
@Serializable @SerialName("camera") class Camera(val camera: VideoCamera): WCallCommand()
|
||||
@Serializable @SerialName("end") object End: WCallCommand()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class WCallResponse {
|
||||
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
|
||||
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
|
||||
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
|
||||
@Serializable @SerialName("connected") class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
||||
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
||||
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
|
||||
}
|
||||
|
||||
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
|
||||
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
||||
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
|
||||
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
||||
@Serializable class RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
|
||||
val callTypeText: String get() = generalGetString(when(callType.media) {
|
||||
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||
})
|
||||
val callTitle: String get() = generalGetString(when(callType.media) {
|
||||
CallMediaType.Video -> R.string.incoming_video_call
|
||||
CallMediaType.Audio -> R.string.incoming_audio_call
|
||||
})
|
||||
}
|
||||
@Serializable class CallCapabilities(val encryption: Boolean)
|
||||
@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
||||
val text: String @Composable get() = when {
|
||||
localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
|
||||
stringResource(R.string.call_connection_peer_to_peer)
|
||||
localCandidate?.candidateType == RTCIceCandidateType.Relay && remoteCandidate?.candidateType == RTCIceCandidateType.Relay ->
|
||||
stringResource(R.string.call_connection_via_relay)
|
||||
else ->
|
||||
"${localCandidate?.candidateType?.value ?: "unknown"} / ${remoteCandidate?.candidateType?.value ?: "unknown"}"
|
||||
}
|
||||
}
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||
@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
|
||||
@Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type
|
||||
@Serializable
|
||||
enum class RTCIceCandidateType(val value: String) {
|
||||
@SerialName("host") Host("host"),
|
||||
@SerialName("srflx") ServerReflexive("srflx"),
|
||||
@SerialName("prflx") PeerReflexive("prflx"),
|
||||
@SerialName("relay") Relay("relay")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class WebRTCCallStatus(val value: String) {
|
||||
@SerialName("connected") Connected("connected"),
|
||||
@SerialName("connecting") Connecting("connecting"),
|
||||
@SerialName("disconnected") Disconnected("disconnected"),
|
||||
@SerialName("failed") Failed("failed")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CallMediaType {
|
||||
@SerialName("video") Video,
|
||||
@SerialName("audio") Audio
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class VideoCamera {
|
||||
@SerialName("user") User,
|
||||
@SerialName("environment") Environment;
|
||||
val flipped: VideoCamera get() = if (this == User) Environment else User
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ConnectionState(
|
||||
val connectionState: String,
|
||||
val iceConnectionState: String,
|
||||
val iceGatheringState: String,
|
||||
val signalingState: String
|
||||
)
|
||||
|
||||
// the servers are expected in this format:
|
||||
// stun:stun.simplex.im:443
|
||||
// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443
|
||||
fun parseRTCIceServer(str: String): RTCIceServer? {
|
||||
var s = replaceScheme(str, "stun:")
|
||||
s = replaceScheme(s, "turn:")
|
||||
val u = runCatching { URI(s) }.getOrNull()
|
||||
if (u != null) {
|
||||
val scheme = u.scheme
|
||||
val host = u.host
|
||||
val port = u.port
|
||||
if (u.path == "" && (scheme == "stun" || scheme == "turn")) {
|
||||
val userInfo = u.userInfo?.split(":")
|
||||
return RTCIceServer(
|
||||
urls = listOf("$scheme:$host:$port"),
|
||||
username = userInfo?.getOrNull(0),
|
||||
credential = userInfo?.getOrNull(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun replaceScheme(s: String, scheme: String): String = if (s.startsWith(scheme)) s.replace(scheme, "$scheme//") else s
|
||||
|
||||
fun parseRTCIceServers(servers: List<String>): List<RTCIceServer>? {
|
||||
val iceServers: ArrayList<RTCIceServer> = ArrayList()
|
||||
for (s in servers) {
|
||||
val server = parseRTCIceServer(s)
|
||||
if (server != null) {
|
||||
iceServers.add(server)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return if (iceServers.isEmpty()) null else iceServers
|
||||
}
|
||||
|
||||
fun getIceServers(): List<RTCIceServer>? {
|
||||
val value = SimplexApp.context.chatController.appPrefs.webrtcIceServers.get() ?: return null
|
||||
val servers: List<String> = value.split("\n")
|
||||
return parseRTCIceServers(servers)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import InfoRow
|
||||
import InfoRowEllipsis
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
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.usersettings.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
@Composable
|
||||
fun ChatInfoView(
|
||||
chatModel: ChatModel,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: 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) {
|
||||
ChatInfoLayout(
|
||||
chat,
|
||||
contact,
|
||||
connStats,
|
||||
customUserProfile,
|
||||
localAlias,
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoLayout(
|
||||
chat: Chat,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
openPreferences: () -> Unit,
|
||||
deleteContact: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
switchContactAddress: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
ChatInfoHeader(chat.chatInfo, contact)
|
||||
}
|
||||
|
||||
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
|
||||
|
||||
if (customUserProfile != null) {
|
||||
SectionSpacer()
|
||||
SectionView(generalGetString(R.string.incognito).uppercase()) {
|
||||
InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
|
||||
}
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
ContactPreferencesButton(openPreferences)
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
SwitchAddressButton(switchContactAddress)
|
||||
SectionDivider()
|
||||
if (connStats != null) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.network_status),
|
||||
chat.serverInfo.networkStatus.statusExplanation
|
||||
)}) {
|
||||
NetworkStatusRow(chat.serverInfo.networkStatus)
|
||||
}
|
||||
val rcvServers = connStats.rcvServers
|
||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||
}
|
||||
val sndServers = connStats.sndServers
|
||||
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
ClearChatButton(clearChat)
|
||||
SectionDivider()
|
||||
DeleteContactButton(deleteContact)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (developerTools) {
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
|
||||
SectionDivider()
|
||||
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
Text(
|
||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
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 = HighOrLowlight
|
||||
)
|
||||
},
|
||||
leadingIcon = if (leadingIcon) {
|
||||
{ Icon(Icons.Default.Edit, null, Modifier.padding(start = 7.dp)) }
|
||||
} else null,
|
||||
color = HighOrLowlight,
|
||||
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
|
||||
fun NetworkStatusRow(networkStatus: Chat.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(
|
||||
Icons.Outlined.Info,
|
||||
stringResource(R.string.network_status),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
networkStatus.statusString,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
ServerImage(networkStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerImage(networkStatus: Chat.NetworkStatus) {
|
||||
Box(Modifier.size(18.dp)) {
|
||||
when (networkStatus) {
|
||||
is Chat.NetworkStatus.Connected ->
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
||||
is Chat.NetworkStatus.Disconnected ->
|
||||
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
|
||||
is Chat.NetworkStatus.Error ->
|
||||
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
|
||||
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.contact_preferences),
|
||||
click = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearChatButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Restore,
|
||||
stringResource(R.string.clear_chat_button),
|
||||
click = onClick,
|
||||
textColor = WarningOrange,
|
||||
iconColor = WarningOrange,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteContactButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_delete_contact),
|
||||
click = onClick,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
}
|
||||
|
||||
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.showAlertMsg(
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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(),
|
||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
||||
),
|
||||
Contact.sampleData,
|
||||
localAlias = "",
|
||||
developerTools = false,
|
||||
connStats = null,
|
||||
onLocalAliasChanged = {},
|
||||
customUserProfile = null,
|
||||
openPreferences = {},
|
||||
deleteContact = {},
|
||||
clearChat = {},
|
||||
switchContactAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,32 @@
|
||||
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.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
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.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.*
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
|
||||
@Composable
|
||||
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
Row(
|
||||
Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(sentColor),
|
||||
.background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_draft_filled),
|
||||
stringResource(MR.strings.icon_descr_file),
|
||||
Icons.Filled.InsertDriveFile,
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
@@ -38,8 +37,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),
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING_HALF
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
|
||||
@Composable
|
||||
fun ComposeImageView(images: List<String>, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LazyRow(
|
||||
Modifier.weight(1f).padding(start = DEFAULT_PADDING_HALF, end = if (cancelEnabled) 0.dp else DEFAULT_PADDING_HALF),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF),
|
||||
) {
|
||||
items(images.size) { index ->
|
||||
val imageBitmap = base64ToBitmap(images[index]).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"preview image",
|
||||
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (cancelEnabled) {
|
||||
IconButton(onClick = cancelImages) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import ComposeVoiceView
|
||||
import ComposeFileView
|
||||
import android.Manifest
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.ImageDecoder.DecodeException
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
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.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.Reply
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
sealed class ComposePreview {
|
||||
@Serializable object NoPreview: ComposePreview()
|
||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
||||
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ComposeContextItem {
|
||||
@Serializable object NoContextItem: ComposeContextItem()
|
||||
@Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
|
||||
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ComposeState(
|
||||
val message: String = "",
|
||||
val preview: ComposePreview = ComposePreview.NoPreview,
|
||||
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
|
||||
val inProgress: Boolean = false,
|
||||
val useLinkPreviews: Boolean
|
||||
) {
|
||||
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this(
|
||||
editingItem.content.text,
|
||||
chatItemPreview(editingItem),
|
||||
ComposeContextItem.EditingItem(editingItem),
|
||||
useLinkPreviews = useLinkPreviews
|
||||
)
|
||||
|
||||
val editing: Boolean
|
||||
get() =
|
||||
when (contextItem) {
|
||||
is ComposeContextItem.EditingItem -> true
|
||||
else -> false
|
||||
}
|
||||
val sendEnabled: () -> Boolean
|
||||
get() = {
|
||||
val hasContent = when (preview) {
|
||||
is ComposePreview.ImagePreview -> true
|
||||
is ComposePreview.VoicePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty()
|
||||
}
|
||||
hasContent && !inProgress
|
||||
}
|
||||
val linkPreviewAllowed: Boolean
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.ImagePreview -> false
|
||||
is ComposePreview.VoicePreview -> false
|
||||
is ComposePreview.FilePreview -> false
|
||||
else -> useLinkPreviews
|
||||
}
|
||||
val linkPreview: LinkPreview?
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.CLinkPreview -> preview.linkPreview
|
||||
else -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
|
||||
save = { json.encodeToString(serializer(), it.value) },
|
||||
restore = {
|
||||
mutableStateOf(json.decodeFromString(it))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
return when (val mc = chatItem.content.msgContent) {
|
||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
||||
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true)
|
||||
is MsgContent.MCFile -> {
|
||||
val fileName = chatItem.file?.fileName ?: ""
|
||||
ComposePreview.FilePreview(fileName)
|
||||
}
|
||||
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ComposeView(
|
||||
chatModel: ChatModel,
|
||||
chat: Chat,
|
||||
composeState: MutableState<ComposeState>,
|
||||
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) }
|
||||
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
// attachments
|
||||
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
||||
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>>(
|
||||
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
|
||||
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
|
||||
)
|
||||
val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) }
|
||||
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
chosenContent.value = listOf(UploadContent.SimpleImage(uri))
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview)))
|
||||
}
|
||||
}
|
||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
cameraLauncher.launchWithFallback()
|
||||
} else {
|
||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val processPickedImage = { uris: List<Uri>, text: String? ->
|
||||
val content = ArrayList<UploadContent>()
|
||||
val imagesPreview = ArrayList<String>()
|
||||
uris.forEach { uri ->
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val drawable = try {
|
||||
ImageDecoder.decodeDrawable(source)
|
||||
} catch (e: DecodeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.image_decoding_exception_title),
|
||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||
)
|
||||
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
||||
null
|
||||
}
|
||||
var bitmap: Bitmap? = if (drawable != null) ImageDecoder.decodeBitmap(source) else null
|
||||
if (drawable is AnimatedImageDrawable) {
|
||||
// It's a gif or webp
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
||||
content.add(UploadContent.AnimatedImage(uri))
|
||||
} else {
|
||||
bitmap = null
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
||||
)
|
||||
}
|
||||
} else {
|
||||
content.add(UploadContent.SimpleImage(uri))
|
||||
}
|
||||
if (bitmap != null) {
|
||||
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
|
||||
}
|
||||
}
|
||||
|
||||
if (imagesPreview.isNotEmpty()) {
|
||||
chosenContent.value = content
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview))
|
||||
}
|
||||
}
|
||||
val processPickedFile = { uri: Uri?, text: String? ->
|
||||
if (uri != null) {
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
||||
val fileName = getFileName(SimplexApp.context, uri)
|
||||
if (fileName != null) {
|
||||
chosenFile.value = uri
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName))
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
|
||||
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||
|
||||
LaunchedEffect(attachmentOption.value) {
|
||||
when (attachmentOption.value) {
|
||||
AttachmentOption.TakePhoto -> {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||
cameraLauncher.launchWithFallback()
|
||||
}
|
||||
else -> {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.PickImage -> {
|
||||
try {
|
||||
galleryLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryLauncherFallback.launch("image/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.PickFile -> {
|
||||
filesLauncher.launch("*/*")
|
||||
attachmentOption.value = null
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
fun isSimplexLink(link: String): Boolean =
|
||||
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
|
||||
|
||||
fun parseMessage(msg: String): String? {
|
||||
val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) }
|
||||
val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
|
||||
return link?.text
|
||||
}
|
||||
|
||||
fun loadLinkPreview(url: String, wait: Long? = null) {
|
||||
if (pendingLinkUrl.value == url) {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
|
||||
withApi {
|
||||
if (wait != null) delay(wait)
|
||||
val lp = getLinkPreview(url)
|
||||
if (lp != null && pendingLinkUrl.value == url) {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp))
|
||||
pendingLinkUrl.value = null
|
||||
} else if (pendingLinkUrl.value == url) {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
pendingLinkUrl.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showLinkPreview(s: String) {
|
||||
prevLinkUrl.value = linkUrl.value
|
||||
linkUrl.value = parseMessage(s)
|
||||
val url = linkUrl.value
|
||||
if (url != null) {
|
||||
if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) {
|
||||
pendingLinkUrl.value = url
|
||||
loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L)
|
||||
}
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetLinkPreview() {
|
||||
linkUrl.value = null
|
||||
prevLinkUrl.value = null
|
||||
pendingLinkUrl.value = null
|
||||
cancelledLinks.clear()
|
||||
}
|
||||
|
||||
fun checkLinkPreview(): MsgContent {
|
||||
val cs = composeState.value
|
||||
return when (val composePreview = cs.preview) {
|
||||
is ComposePreview.CLinkPreview -> {
|
||||
val url = parseMessage(cs.message)
|
||||
val lp = composePreview.linkPreview
|
||||
if (lp != null && url == lp.uri) {
|
||||
MsgContent.MCLink(cs.message, preview = lp)
|
||||
} else {
|
||||
MsgContent.MCText(cs.message)
|
||||
}
|
||||
}
|
||||
else -> MsgContent.MCText(cs.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMsgContent(msgContent: MsgContent): MsgContent {
|
||||
val cs = composeState.value
|
||||
return when (msgContent) {
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
||||
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
|
||||
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearState() {
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
textStyle.value = smallFont
|
||||
chosenContent.value = emptyList()
|
||||
chosenAudio.value = null
|
||||
chosenFile.value = null
|
||||
linkUrl.value = null
|
||||
prevLinkUrl.value = null
|
||||
pendingLinkUrl.value = null
|
||||
cancelledLinks.clear()
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
composeState.value = composeState.value.copy(inProgress = true)
|
||||
val cInfo = chat.chatInfo
|
||||
val cs = composeState.value
|
||||
when (val contextItem = cs.contextItem) {
|
||||
is ComposeContextItem.EditingItem -> {
|
||||
val ei = contextItem.chatItem
|
||||
val oldMsgContent = ei.content.msgContent
|
||||
if (oldMsgContent != null) {
|
||||
withApi {
|
||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = ei.meta.itemId,
|
||||
mc = updateMsgContent(oldMsgContent)
|
||||
)
|
||||
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val msgs: ArrayList<MsgContent> = ArrayList()
|
||||
val files: ArrayList<String> = ArrayList()
|
||||
when (val preview = cs.preview) {
|
||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message))
|
||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||
is ComposePreview.ImagePreview -> {
|
||||
chosenContent.value.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.VoicePreview -> {
|
||||
val chosenAudioVal = chosenAudio.value
|
||||
if (chosenAudioVal != null) {
|
||||
val file = chosenAudioVal.first.toFile().name
|
||||
files.add((file))
|
||||
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
|
||||
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
|
||||
}
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
val file = saveFileFromUri(context, chosenFileVal)
|
||||
if (file != null) {
|
||||
files.add((file))
|
||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val quotedItemId: Long? = when (contextItem) {
|
||||
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
|
||||
else -> null
|
||||
}
|
||||
if (msgs.isNotEmpty()) {
|
||||
withApi {
|
||||
msgs.forEachIndexed { index, content ->
|
||||
if (index > 0) delay(100)
|
||||
val aChatItem = chatModel.controller.apiSendMessage(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
file = files.getOrNull(index),
|
||||
quotedItemId = if (index == 0) quotedItemId else null,
|
||||
mc = content
|
||||
)
|
||||
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
||||
}
|
||||
clearState()
|
||||
}
|
||||
} else {
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onMessageChange(s: String) {
|
||||
composeState.value = composeState.value.copy(message = s)
|
||||
if (isShortEmoji(s)) {
|
||||
textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
|
||||
} else {
|
||||
textStyle.value = smallFont
|
||||
if (composeState.value.linkPreviewAllowed) {
|
||||
if (s.isNotEmpty()) showLinkPreview(s)
|
||||
else resetLinkPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
|
||||
val file = File(filePath)
|
||||
chosenAudio.value = file.toUri() to durationMs
|
||||
chatModel.filesToDelete.add(file)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
|
||||
}
|
||||
|
||||
fun allowVoiceToContact() {
|
||||
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
|
||||
val prefs = contact.mergedPreferences.toPreferences().copy(voice = ChatPreference(allow = FeatureAllowed.YES))
|
||||
withApi {
|
||||
val toContact = chatModel.controller.apiSetContactPrefs(contact.contactId, prefs)
|
||||
if (toContact != null) {
|
||||
chatModel.updateContact(toContact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showDisabledVoiceAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.voice_messages_prohibited),
|
||||
text = generalGetString(
|
||||
if (chat.chatInfo is ChatInfo.Direct)
|
||||
R.string.ask_your_contact_to_enable_voice
|
||||
else
|
||||
R.string.only_group_owners_can_enable_voice
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelLinkPreview() {
|
||||
val uri = composeState.value.linkPreview?.uri
|
||||
if (uri != null) {
|
||||
cancelledLinks.add(uri)
|
||||
}
|
||||
pendingLinkUrl.value = null
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
}
|
||||
|
||||
fun cancelImages() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenContent.value = emptyList()
|
||||
}
|
||||
|
||||
fun cancelVoice() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenAudio.value = null
|
||||
}
|
||||
|
||||
fun cancelFile() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenFile.value = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun previewView() {
|
||||
when (val preview = composeState.value.preview) {
|
||||
ComposePreview.NoPreview -> {}
|
||||
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
|
||||
is ComposePreview.ImagePreview -> ComposeImageView(
|
||||
preview.images,
|
||||
::cancelImages,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
||||
preview.voice,
|
||||
preview.durationMs,
|
||||
preview.finished,
|
||||
cancelEnabled = !composeState.value.editing,
|
||||
::cancelVoice
|
||||
)
|
||||
is ComposePreview.FilePreview -> ComposeFileView(
|
||||
preview.fileName,
|
||||
::cancelFile,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun contextItemView() {
|
||||
when (val contextItem = composeState.value.contextItem) {
|
||||
ComposeContextItem.NoContextItem -> {}
|
||||
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||
}
|
||||
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) {
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(chatModel.sharedContent.value) {
|
||||
// Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
|
||||
if (chatModel.chatId.value == null) return@LaunchedEffect
|
||||
|
||||
when (val shared = chatModel.sharedContent.value) {
|
||||
is SharedContent.Text -> onMessageChange(shared.text)
|
||||
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
|
||||
is SharedContent.File -> processPickedFile(shared.uri, shared.text)
|
||||
null -> {}
|
||||
}
|
||||
chatModel.sharedContent.value = null
|
||||
}
|
||||
|
||||
Column {
|
||||
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 attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
|
||||
IconButton(showChooseAttachment, enabled = attachEnabled) {
|
||||
Icon(
|
||||
Icons.Filled.AttachFile,
|
||||
contentDescription = stringResource(R.string.attach),
|
||||
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
}
|
||||
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.voiceMessageAllowed }
|
||||
val needToAllowVoiceToContact = remember(chat.chatInfo) {
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct -> with(chat.chatInfo.contact.mergedPreferences.voice) {
|
||||
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
|
||||
contactPreference.allow == FeatureAllowed.YES
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(allowedVoiceByPrefs) {
|
||||
if (!allowedVoiceByPrefs && chosenAudio.value != null) {
|
||||
// Voice was disabled right when this user records it, just cancel it
|
||||
cancelVoice()
|
||||
}
|
||||
}
|
||||
SendMsgView(
|
||||
composeState,
|
||||
showVoiceRecordIcon = true,
|
||||
allowedVoiceByPrefs = allowedVoiceByPrefs,
|
||||
needToAllowVoiceToContact = needToAllowVoiceToContact,
|
||||
sendMessage = {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
},
|
||||
::onMessageChange,
|
||||
::onAudioAdded,
|
||||
::allowVoiceToContact,
|
||||
::showDisabledVoiceAlert,
|
||||
textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 PickMultipleFromGallery: 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()
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.durationText
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun ComposeVoiceView(
|
||||
filePath: String,
|
||||
recordedDurationMs: Int,
|
||||
finishedRecording: Boolean,
|
||||
cancelEnabled: Boolean,
|
||||
cancelVoice: () -> Unit
|
||||
) {
|
||||
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.toInt()
|
||||
}
|
||||
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)
|
||||
)
|
||||
Row(
|
||||
Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(SentColorLight),
|
||||
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) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
tint = if (finishedRecording) MaterialTheme.colors.primary else HighOrLowlight
|
||||
)
|
||||
}
|
||||
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 = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (cancelEnabled) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
AudioPlayer.stop(filePath)
|
||||
cancelVoice()
|
||||
},
|
||||
modifier = Modifier.padding(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import InfoRow
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ContactPreferencesView(
|
||||
m: ChatModel,
|
||||
user: User,
|
||||
contactId: Long,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
|
||||
val ct = contact.value ?: return
|
||||
var featuresAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
|
||||
var currentFeaturesAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(featuresAllowed) }
|
||||
|
||||
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
|
||||
if (toContact != null) {
|
||||
m.updateContact(toContact)
|
||||
currentFeaturesAllowed = featuresAllowed
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
ModalView(
|
||||
close = {
|
||||
if (featuresAllowed == currentFeaturesAllowed) close()
|
||||
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||
},
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
ContactPreferencesLayout(
|
||||
featuresAllowed,
|
||||
currentFeaturesAllowed,
|
||||
user,
|
||||
ct,
|
||||
applyPrefs = { prefs ->
|
||||
featuresAllowed = prefs
|
||||
},
|
||||
reset = {
|
||||
featuresAllowed = currentFeaturesAllowed
|
||||
},
|
||||
savePrefs = ::savePrefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactPreferencesLayout(
|
||||
featuresAllowed: ContactFeaturesAllowed,
|
||||
currentFeaturesAllowed: ContactFeaturesAllowed,
|
||||
user: User,
|
||||
contact: Contact,
|
||||
applyPrefs: (ContactFeaturesAllowed) -> Unit,
|
||||
reset: () -> Unit,
|
||||
savePrefs: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.contact_preferences))
|
||||
val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
|
||||
FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
|
||||
applyPrefs(featuresAllowed.copy(fullDelete = it))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
|
||||
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
|
||||
applyPrefs(featuresAllowed.copy(voice = it))
|
||||
}
|
||||
SectionSpacer()
|
||||
ResetSaveButtons(
|
||||
reset = reset,
|
||||
save = savePrefs,
|
||||
disabled = featuresAllowed == currentFeaturesAllowed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureSection(
|
||||
feature: ChatFeature,
|
||||
userDefault: FeatureAllowed,
|
||||
pref: ContactUserPreference,
|
||||
allowFeature: State<ContactFeatureAllowed>,
|
||||
onSelected: (ContactFeatureAllowed) -> Unit
|
||||
) {
|
||||
val enabled = FeatureEnabled.enabled(
|
||||
user = ChatPreference(allow = allowFeature.value.allowed),
|
||||
contact = pref.contactPreference
|
||||
)
|
||||
|
||||
SectionView(
|
||||
feature.text.uppercase(),
|
||||
icon = feature.iconFilled,
|
||||
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
||||
leadingIcon = true,
|
||||
) {
|
||||
SectionItemView {
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.chat_preferences_you_allow),
|
||||
ContactFeatureAllowed.values(userDefault).map { it to it.text },
|
||||
allowFeature,
|
||||
icon = null,
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
SectionDivider()
|
||||
InfoRow(
|
||||
generalGetString(R.string.chat_preferences_contact_allows),
|
||||
pref.contactPreference.allow.text
|
||||
)
|
||||
}
|
||||
SectionTextFooter(feature.enabledDescription(enabled))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
SectionItemView(reset, disabled = disabled) {
|
||||
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView(save, disabled = disabled) {
|
||||
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_contact),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ContextItemView(
|
||||
contextItem: ChatItem,
|
||||
contextIcon: ImageVector,
|
||||
cancelContextItem: () -> Unit
|
||||
) {
|
||||
val sent = contextItem.chatDir.sent
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.background(if (sent) SentColorLight else ReceivedColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(vertical = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1F),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
contextIcon,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.height(20.dp)
|
||||
.width(20.dp),
|
||||
contentDescription = stringResource(R.string.icon_descr_context),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
MarkdownText(
|
||||
contextItem.text, contextItem.formattedText,
|
||||
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = cancelContextItem) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.cancel_verb),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewContextItemView() {
|
||||
SimpleXTheme {
|
||||
ContextItemView(
|
||||
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
|
||||
contextIcon = Icons.Filled.Edit,
|
||||
cancelContextItem = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
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.text.InputType
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.*
|
||||
import android.widget.EditText
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.dp
|
||||
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.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
composeState: MutableState<ComposeState>,
|
||||
showVoiceRecordIcon: Boolean,
|
||||
allowedVoiceByPrefs: Boolean,
|
||||
needToAllowVoiceToContact: Boolean,
|
||||
sendMessage: () -> Unit,
|
||||
onMessageChange: (String) -> Unit,
|
||||
onAudioAdded: (String, Int, Boolean) -> Unit,
|
||||
allowVoiceToContact: () -> Unit,
|
||||
showDisabledVoiceAlert: () -> Unit,
|
||||
textStyle: MutableState<TextStyle>
|
||||
) {
|
||||
Column(Modifier.padding(vertical = 8.dp)) {
|
||||
Box {
|
||||
val cs = composeState.value
|
||||
val attachEnabled = !composeState.value.editing
|
||||
val filePath = rememberSaveable { mutableStateOf(null as String?) }
|
||||
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
|
||||
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && showVoiceRecordIcon && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
|
||||
Box(if (recordingTimeRange.first == 0L)
|
||||
Modifier
|
||||
else
|
||||
Modifier.clickable(false, onClick = {})
|
||||
) {
|
||||
NativeKeyboard(composeState, textStyle, onMessageChange)
|
||||
}
|
||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
|
||||
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
|
||||
} else if (!showVoiceButton) {
|
||||
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
)
|
||||
)
|
||||
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
|
||||
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (isActive) {
|
||||
now = System.currentTimeMillis()
|
||||
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
val stopRecordingAndAddAudio: () -> Unit = {
|
||||
rec.stop()
|
||||
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
|
||||
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
|
||||
}
|
||||
val startStopRecording: () -> Unit = {
|
||||
when {
|
||||
needToAllowVoiceToContact -> {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.allow_voice_messages_question),
|
||||
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
|
||||
confirmText = generalGetString(R.string.allow_verb),
|
||||
dismissText = generalGetString(R.string.cancel_verb),
|
||||
onConfirm = allowVoiceToContact,
|
||||
)
|
||||
}
|
||||
!allowedVoiceByPrefs -> showDisabledVoiceAlert()
|
||||
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
|
||||
recordingInProgress.value -> stopRecordingAndAddAudio()
|
||||
filePath.value == null -> {
|
||||
recordingTimeRange = System.currentTimeMillis()..0L
|
||||
filePath.value = rec.start(stopRecordingAndAddAudio)
|
||||
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
var stopRecOnNextClick by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(stopRecOnNextClick) {
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
if (stopRecOnNextClick) {
|
||||
// Lock orientation to current orientation because screen rotation will break the recording
|
||||
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
else
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
// Unlock orientation
|
||||
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||
}
|
||||
val cleanUp = { remove: Boolean ->
|
||||
rec.stop()
|
||||
AudioPlayer.stop(filePath.value)
|
||||
if (remove) filePath.value?.let { File(it).delete() }
|
||||
filePath.value = null
|
||||
stopRecOnNextClick = false
|
||||
recordingTimeRange = 0L..0L
|
||||
}
|
||||
LaunchedEffect(cs.preview) {
|
||||
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
|
||||
// Pressed on X icon in preview
|
||||
cleanUp(true)
|
||||
}
|
||||
}
|
||||
val interactionSource = interactionSourceWithTapDetection(
|
||||
// It's just a key for triggering dropping a state in the compose function. Without it
|
||||
// nothing will react on changed params like needToAllowVoiceToContact or allowedVoiceByPrefs
|
||||
needToAllowVoiceToContact.toString() + allowedVoiceByPrefs.toString(),
|
||||
onPress = {
|
||||
if (filePath.value == null) startStopRecording()
|
||||
},
|
||||
onClick = {
|
||||
// Voice not allowed or not granted voice record permission for the app
|
||||
if (!allowedVoiceByPrefs || !permissionsState.allPermissionsGranted) return@interactionSourceWithTapDetection
|
||||
if (!recordingInProgress.value && filePath.value != null) {
|
||||
sendMessage()
|
||||
cleanUp(false)
|
||||
} else if (stopRecOnNextClick) {
|
||||
stopRecordingAndAddAudio()
|
||||
stopRecOnNextClick = false
|
||||
} else {
|
||||
// tapped and didn't hold a finger
|
||||
stopRecOnNextClick = true
|
||||
}
|
||||
},
|
||||
onCancel = startStopRecording,
|
||||
onRelease = startStopRecording
|
||||
)
|
||||
val sendButtonModifier = if (recordingTimeRange.last != 0L)
|
||||
Modifier.clip(CircleShape).background(color)
|
||||
else
|
||||
Modifier
|
||||
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
|
||||
Icon(
|
||||
when {
|
||||
recordingTimeRange.last != 0L -> Icons.Outlined.ArrowUpward
|
||||
stopRecOnNextClick -> Icons.Filled.Stop
|
||||
allowedVoiceByPrefs -> Icons.Filled.KeyboardVoice
|
||||
else -> Icons.Outlined.KeyboardVoice
|
||||
},
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = when {
|
||||
recordingTimeRange.last != 0L -> Color.White
|
||||
stopRecOnNextClick -> MaterialTheme.colors.primary
|
||||
allowedVoiceByPrefs -> MaterialTheme.colors.primary
|
||||
else -> HighOrLowlight
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.then(sendButtonModifier)
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
rec.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NativeKeyboard(
|
||||
composeState: MutableState<ComposeState>,
|
||||
textStyle: MutableState<TextStyle>,
|
||||
onMessageChange: (String) -> Unit
|
||||
) {
|
||||
val cs = composeState.value
|
||||
val textColor = MaterialTheme.colors.onBackground
|
||||
val tintColor = MaterialTheme.colors.secondary
|
||||
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.Images("", 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)
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
Text(
|
||||
generalGetString(R.string.voice_message_send_text),
|
||||
Modifier.padding(padding),
|
||||
color = HighOrLowlight,
|
||||
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
showVoiceRecordIcon = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
needToAllowVoiceToContact = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
allowVoiceToContact = {},
|
||||
showDisabledVoiceAlert = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData(), useLinkPreviews = true)
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||
showVoiceRecordIcon = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
needToAllowVoiceToContact = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
allowVoiceToContact = {},
|
||||
showDisabledVoiceAlert = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true, useLinkPreviews = true)
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||
showVoiceRecordIcon = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
needToAllowVoiceToContact = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
allowVoiceToContact = {},
|
||||
showDisabledVoiceAlert = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,50 @@
|
||||
package chat.simplex.common.views.chat.group
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionCustomFooter
|
||||
import SectionDividerSpaced
|
||||
import SectionDivider
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.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.graphics.vector.ImageVector
|
||||
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.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.ui.theme.*
|
||||
import chat.simplex.common.views.chat.ChatInfoToolbarTitle
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.common.model.GroupInfo
|
||||
import chat.simplex.common.platform.*
|
||||
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.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(
|
||||
groupInfo = groupInfo,
|
||||
creatingGroup = creatingGroup,
|
||||
contactsToAdd = getContactsToAdd(chatModel, searchText.value.text),
|
||||
contactsToAdd = getContactsToAdd(chatModel),
|
||||
selectedContacts = selectedContacts,
|
||||
selectedRole = selectedRole,
|
||||
allowModifyMembers = allowModifyMembers,
|
||||
searchText,
|
||||
openPreferences = {
|
||||
ModalManager.end.showCustomModal { close ->
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
GroupPreferencesView(chatModel, groupInfo.id, close)
|
||||
}
|
||||
},
|
||||
@@ -71,13 +67,9 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
|
||||
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
|
||||
close = close,
|
||||
)
|
||||
KeyChangeEffect(chatModel.chatId.value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -86,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 +91,6 @@ fun AddGroupMembersLayout(
|
||||
selectedContacts: List<Long>,
|
||||
selectedRole: MutableState<GroupMemberRole>,
|
||||
allowModifyMembers: Boolean,
|
||||
searchText: MutableState<TextFieldValue>,
|
||||
openPreferences: () -> Unit,
|
||||
inviteMembers: () -> Unit,
|
||||
clearSelection: () -> Unit,
|
||||
@@ -107,32 +98,13 @@ fun AddGroupMembersLayout(
|
||||
removeContact: (Long) -> Unit,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
@Composable fun profileText() {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_info),
|
||||
null,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.padding(end = 10.dp).size(20.dp)
|
||||
)
|
||||
Text(generalGetString(MR.strings.group_main_profile_sent), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.button_add_members))
|
||||
profileText()
|
||||
Spacer(Modifier.size(DEFAULT_PADDING))
|
||||
AppBarTitle(stringResource(R.string.button_add_members))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
@@ -145,25 +117,29 @@ 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
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SectionView {
|
||||
if (creatingGroup) {
|
||||
SectionItemView(openPreferences) {
|
||||
Text(stringResource(MR.strings.set_group_preferences))
|
||||
Text(stringResource(R.string.set_group_preferences))
|
||||
}
|
||||
SectionDivider()
|
||||
}
|
||||
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
||||
SectionItemView {
|
||||
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
||||
}
|
||||
SectionDivider()
|
||||
if (creatingGroup && selectedContacts.isEmpty()) {
|
||||
SkipInvitingButton(close)
|
||||
} else {
|
||||
@@ -173,28 +149,13 @@ fun AddGroupMembersLayout(
|
||||
SectionCustomFooter {
|
||||
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)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.select_contacts)) {
|
||||
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@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(DEFAULT_SPACE_AFTER_ICON))
|
||||
SearchTextField(Modifier.fillMaxWidth(), searchText = searchText, alwaysVisible = true) {
|
||||
searchText.value = searchText.value.copy(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,20 +168,21 @@ 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,
|
||||
enabled = rememberUpdatedState(enabled)
|
||||
) { selectedRole.value = it }
|
||||
enabled = rememberUpdatedState(enabled),
|
||||
onSelected = { selectedRole.value = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_check),
|
||||
stringResource(MR.strings.invite_to_group_button),
|
||||
Icons.Outlined.Check,
|
||||
stringResource(R.string.invite_to_group_button),
|
||||
click = onClick,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
@@ -231,8 +193,8 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
||||
@Composable
|
||||
fun SkipInvitingButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_check),
|
||||
stringResource(MR.strings.skip_inviting_button),
|
||||
Icons.Outlined.Check,
|
||||
stringResource(R.string.skip_inviting_button),
|
||||
click = onClick,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
@@ -248,23 +210,23 @@ fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelec
|
||||
) {
|
||||
if (selectedContactsCount >= 1) {
|
||||
Text(
|
||||
String.format(generalGetString(MR.strings.num_contacts_selected), selectedContactsCount),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Box(
|
||||
Modifier.clickable { if (enabled) clearSelection() }
|
||||
) {
|
||||
Text(
|
||||
stringResource(MR.strings.clear_contacts_selection_button),
|
||||
color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
stringResource(R.string.clear_contacts_selection_button),
|
||||
color = if (enabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
stringResource(MR.strings.no_contacts_selected),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
stringResource(R.string.no_contacts_selected),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
@@ -287,6 +249,9 @@ fun ContactList(
|
||||
checked = selectedContacts.contains(contact.apiId),
|
||||
enabled = enabled,
|
||||
)
|
||||
if (index < contacts.lastIndex) {
|
||||
SectionDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,17 +266,17 @@ fun ContactCheckRow(
|
||||
enabled: Boolean,
|
||||
) {
|
||||
val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
|
||||
val icon: Painter
|
||||
val icon: ImageVector
|
||||
val iconColor: Color
|
||||
if (prohibitedToInviteIncognito) {
|
||||
icon = painterResource(MR.images.ic_theater_comedy_filled)
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
icon = Icons.Filled.TheaterComedy
|
||||
iconColor = HighOrLowlight
|
||||
} else if (checked) {
|
||||
icon = painterResource(MR.images.ic_check_circle_filled)
|
||||
iconColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
icon = Icons.Filled.CheckCircle
|
||||
iconColor = if (enabled) MaterialTheme.colors.primary else HighOrLowlight
|
||||
} else {
|
||||
icon = painterResource(MR.images.ic_circle)
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
icon = Icons.Outlined.Circle
|
||||
iconColor = HighOrLowlight
|
||||
}
|
||||
SectionItemView(
|
||||
click = if (enabled) {
|
||||
@@ -328,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,
|
||||
color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified
|
||||
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (prohibitedToInviteIncognito) HighOrLowlight 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
|
||||
)
|
||||
}
|
||||
@@ -345,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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -362,7 +324,6 @@ fun PreviewAddGroupMembersLayout() {
|
||||
selectedContacts = remember { mutableStateListOf() },
|
||||
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
||||
allowModifyMembers = true,
|
||||
searchText = remember { mutableStateOf(TextFieldValue("")) },
|
||||
openPreferences = {},
|
||||
inviteMembers = {},
|
||||
clearSelection = {},
|
||||
@@ -0,0 +1,388 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import InfoRow
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.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, 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 && chat.chatInfo is ChatInfo.Group) {
|
||||
val groupInfo = chat.chatInfo.groupInfo
|
||||
GroupChatInfoLayout(
|
||||
chat,
|
||||
groupInfo,
|
||||
members = chatModel.groupMembers
|
||||
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
|
||||
.sortedBy { it.displayName.lowercase() },
|
||||
developerTools,
|
||||
addMembers = {
|
||||
withApi {
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
AddGroupMembersView(groupInfo, false, chatModel, close)
|
||||
}
|
||||
}
|
||||
},
|
||||
showMemberInfo = { member ->
|
||||
withApi {
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
|
||||
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
|
||||
}
|
||||
}
|
||||
},
|
||||
editGroupProfile = {
|
||||
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
||||
},
|
||||
openPreferences = {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
GroupPreferencesView(
|
||||
chatModel,
|
||||
chat.id,
|
||||
close
|
||||
)
|
||||
}
|
||||
},
|
||||
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||
manageGroupLink = {
|
||||
withApi {
|
||||
val groupLink = chatModel.controller.apiGetGroupLink(groupInfo.groupId)
|
||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
val alertTextKey =
|
||||
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.showAlertMsg(
|
||||
title = generalGetString(R.string.delete_group_question),
|
||||
text = generalGetString(alertTextKey),
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
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)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupChatInfoLayout(
|
||||
chat: Chat,
|
||||
groupInfo: GroupInfo,
|
||||
members: List<GroupMember>,
|
||||
developerTools: Boolean,
|
||||
addMembers: () -> Unit,
|
||||
showMemberInfo: (GroupMember) -> Unit,
|
||||
editGroupProfile: () -> Unit,
|
||||
openPreferences: () -> Unit,
|
||||
deleteGroup: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
leaveGroup: () -> Unit,
|
||||
manageGroupLink: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
GroupChatInfoHeader(chat.chatInfo)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
|
||||
SectionDivider()
|
||||
}
|
||||
GroupPreferencesButton(openPreferences)
|
||||
}
|
||||
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
||||
if (groupInfo.canAddMembers) {
|
||||
SectionItemView(manageGroupLink) { GroupLinkButton() }
|
||||
SectionDivider()
|
||||
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
|
||||
SectionItemView(onAddMembersClick) {
|
||||
val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary
|
||||
AddMembersButton(tint)
|
||||
}
|
||||
SectionDivider()
|
||||
}
|
||||
SectionItemView(minHeight = 50.dp) {
|
||||
MemberRow(groupInfo.membership, user = true)
|
||||
}
|
||||
if (members.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
}
|
||||
MembersList(members, showMemberInfo)
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
ClearChatButton(clearChat)
|
||||
if (groupInfo.canDelete) {
|
||||
SectionDivider()
|
||||
SectionItemView(deleteGroup) { DeleteGroupButton() }
|
||||
}
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
SectionDivider()
|
||||
SectionItemView(leaveGroup) { LeaveGroupButton() }
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (developerTools) {
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
|
||||
SectionDivider()
|
||||
InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Text(
|
||||
cInfo.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) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupPreferencesButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.group_preferences),
|
||||
click = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Add,
|
||||
stringResource(R.string.button_add_members),
|
||||
tint = tint
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_add_members), color = tint)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
||||
Column {
|
||||
members.forEachIndexed { index, member ->
|
||||
SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
|
||||
MemberRow(member)
|
||||
}
|
||||
if (index < members.lastIndex) {
|
||||
SectionDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
ProfileImage(size = 46.dp, member.image)
|
||||
Column {
|
||||
Text(
|
||||
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
||||
)
|
||||
val s = member.memberStatus.shortText
|
||||
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
|
||||
Text(
|
||||
statusDescr,
|
||||
color = HighOrLowlight,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
val role = member.memberRole
|
||||
if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) {
|
||||
Text(role.text, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupLinkButton() {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Link,
|
||||
stringResource(R.string.group_link),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.group_link))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditGroupProfileButton() {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Edit,
|
||||
stringResource(R.string.button_edit_group_profile),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_edit_group_profile))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LeaveGroupButton() {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Logout,
|
||||
stringResource(R.string.button_leave_group),
|
||||
tint = Color.Red
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_leave_group), color = Color.Red)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteGroupButton() {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_delete_group),
|
||||
tint = Color.Red
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_delete_group), color = Color.Red)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewGroupChatInfoLayout() {
|
||||
SimpleXTheme {
|
||||
GroupChatInfoLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = arrayListOf(),
|
||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
||||
),
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.GroupInfo
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@Composable
|
||||
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?) {
|
||||
var groupLink by remember { mutableStateOf(connReqContact) }
|
||||
val cxt = LocalContext.current
|
||||
GroupLinkLayout(
|
||||
groupLink = groupLink,
|
||||
createLink = {
|
||||
withApi {
|
||||
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||
}
|
||||
},
|
||||
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
|
||||
deleteLink = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
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)
|
||||
if (r) {
|
||||
groupLink = null
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupLinkLayout(
|
||||
groupLink: String?,
|
||||
createLink: () -> Unit,
|
||||
share: () -> Unit,
|
||||
deleteLink: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.group_link), false)
|
||||
Text(
|
||||
stringResource(R.string.you_can_share_group_link_anybody_will_be_able_to_connect),
|
||||
Modifier.padding(bottom = 12.dp),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
if (groupLink == null) {
|
||||
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, click = createLink)
|
||||
} else {
|
||||
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 10.dp)
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.share_link),
|
||||
icon = Icons.Outlined.Share,
|
||||
click = share
|
||||
)
|
||||
SimpleButton(
|
||||
stringResource(R.string.delete_link),
|
||||
icon = Icons.Outlined.Delete,
|
||||
color = Color.Red,
|
||||
click = deleteLink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import InfoRow
|
||||
import SectionDivider
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.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.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.SimplexServers
|
||||
import chat.simplex.app.views.chat.SwitchAddressButton
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoView(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
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,
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
knownDirectChat = {
|
||||
withApi {
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(it.chatItems)
|
||||
chatModel.chatId.value = it.chatInfo.id
|
||||
closeAll()
|
||||
}
|
||||
},
|
||||
newDirectChat = {
|
||||
withApi {
|
||||
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||
if (c != null) {
|
||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
||||
val newChat = c.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
|
||||
chatModel.addChat(newChat)
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatId.value = newChat.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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoLayout(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
newRole: MutableState<GroupMemberRole>,
|
||||
developerTools: Boolean,
|
||||
getContactChat: (Long) -> Chat?,
|
||||
knownDirectChat: (Chat) -> Unit,
|
||||
newDirectChat: (Long) -> Unit,
|
||||
removeMember: () -> Unit,
|
||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||
switchMemberAddress: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
GroupMemberInfoHeader(member)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
val contactId = member.memberContactId
|
||||
if (contactId != null) {
|
||||
val chat = getContactChat(contactId)
|
||||
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directContact) {
|
||||
SectionView {
|
||||
OpenChatButton(onClick = { knownDirectChat(chat) })
|
||||
}
|
||||
SectionSpacer()
|
||||
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
|
||||
SectionView {
|
||||
OpenChatButton(onClick = { newDirectChat(contactId) })
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
||||
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
||||
SectionDivider()
|
||||
val roles = remember { member.canChangeRoleTo(groupInfo) }
|
||||
if (roles != null) {
|
||||
SectionItemView {
|
||||
RoleSelectionRow(roles, newRole, onRoleSelected)
|
||||
}
|
||||
} else {
|
||||
InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
|
||||
}
|
||||
val conn = member.activeConn
|
||||
if (conn != null) {
|
||||
SectionDivider()
|
||||
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)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
SwitchAddressButton(switchMemberAddress)
|
||||
SectionDivider()
|
||||
if (connStats != null) {
|
||||
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()) {
|
||||
SectionDivider()
|
||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||
}
|
||||
} else if (sndServers != null && sndServers.isNotEmpty()) {
|
||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (member.canBeRemoved(groupInfo)) {
|
||||
SectionView {
|
||||
RemoveMemberButton(removeMember)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
if (developerTools) {
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
|
||||
SectionDivider()
|
||||
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
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(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_remove_member),
|
||||
click = onClick,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpenChatButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Message,
|
||||
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,
|
||||
getContactChat = { Chat.sampleData },
|
||||
knownDirectChat = {},
|
||||
newDirectChat = {},
|
||||
removeMember = {},
|
||||
onRoleSelected = {},
|
||||
switchMemberAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import InfoRow
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
|
||||
val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } }
|
||||
val gInfo = groupInfo.value ?: return
|
||||
var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
|
||||
var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) }
|
||||
|
||||
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
|
||||
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
|
||||
if (gInfo != null) {
|
||||
m.updateGroup(gInfo)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
ModalView(
|
||||
close = {
|
||||
if (preferences == currentPreferences) close()
|
||||
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||
},
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
GroupPreferencesLayout(
|
||||
preferences,
|
||||
currentPreferences,
|
||||
gInfo,
|
||||
applyPrefs = { prefs ->
|
||||
preferences = prefs
|
||||
},
|
||||
reset = {
|
||||
preferences = currentPreferences
|
||||
},
|
||||
savePrefs = ::savePrefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupPreferencesLayout(
|
||||
preferences: FullGroupPreferences,
|
||||
currentPreferences: FullGroupPreferences,
|
||||
groupInfo: GroupInfo,
|
||||
applyPrefs: (FullGroupPreferences) -> Unit,
|
||||
reset: () -> Unit,
|
||||
savePrefs: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.group_preferences))
|
||||
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
|
||||
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo) {
|
||||
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
|
||||
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo) {
|
||||
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
|
||||
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo) {
|
||||
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
|
||||
}
|
||||
if (groupInfo.canEdit) {
|
||||
SectionSpacer()
|
||||
ResetSaveButtons(
|
||||
reset = reset,
|
||||
save = savePrefs,
|
||||
disabled = preferences == currentPreferences
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureSection(feature: GroupFeature, enableFeature: State<GroupFeatureEnabled>, groupInfo: GroupInfo, onSelected: (GroupFeatureEnabled) -> Unit) {
|
||||
SectionView {
|
||||
val on = enableFeature.value == GroupFeatureEnabled.ON
|
||||
val icon = if (on) feature.iconFilled else feature.icon
|
||||
val iconTint = if (on) SimplexGreen else HighOrLowlight
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView {
|
||||
ExposedDropDownSettingRow(
|
||||
feature.text,
|
||||
GroupFeatureEnabled.values().map { it to it.text },
|
||||
enableFeature,
|
||||
icon = icon,
|
||||
iconTint = iconTint,
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
} else {
|
||||
InfoRow(
|
||||
feature.text,
|
||||
enableFeature.value.text,
|
||||
icon = icon,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
SectionItemView(reset, disabled = disabled) {
|
||||
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView(save, disabled = disabled) {
|
||||
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.ProfileNameField
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
|
||||
GroupProfileLayout(
|
||||
close = close,
|
||||
groupProfile = groupInfo.groupProfile,
|
||||
saveProfile = { p ->
|
||||
withApi {
|
||||
val gInfo = chatModel.controller.apiUpdateGroup(groupInfo.groupId, p)
|
||||
if (gInfo != null) {
|
||||
chatModel.updateGroup(gInfo)
|
||||
close.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupProfileLayout(
|
||||
close: () -> Unit,
|
||||
groupProfile: GroupProfile,
|
||||
saveProfile: (GroupProfile) -> Unit,
|
||||
) {
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val displayName = remember { mutableStateOf(groupProfile.displayName) }
|
||||
val fullName = remember { mutableStateOf(groupProfile.fullName) }
|
||||
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetContent = {
|
||||
GetImageBottomSheet(
|
||||
chosenImage,
|
||||
onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
|
||||
hideBottomSheet = {
|
||||
scope.launch { bottomSheetModalState.hide() }
|
||||
})
|
||||
},
|
||||
sheetState = bottomSheetModalState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
ModalView(close = close) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.group_profile_is_stored_on_members_devices),
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(192.dp, profileImage.value)
|
||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||
}
|
||||
if (profileImage.value != null) {
|
||||
DeleteImageButton { profileImage.value = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.group_display_name_field),
|
||||
Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
ProfileNameField(displayName, focusRequester)
|
||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
stringResource(R.string.group_full_name_field),
|
||||
Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row {
|
||||
TextButton(stringResource(R.string.cancel_verb)) {
|
||||
close.invoke()
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
if (enabled) {
|
||||
Text(
|
||||
stringResource(R.string.save_group_profile),
|
||||
modifier = Modifier.clickable { saveProfile(GroupProfile(displayName.value, fullName.value, profileImage.value)) },
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.save_group_profile),
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewGroupProfileLayout() {
|
||||
SimpleXTheme {
|
||||
GroupProfileLayout(
|
||||
close = {},
|
||||
groupProfile = GroupProfile.sampleData,
|
||||
saveProfile = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
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.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PhoneInTalk
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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.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,28 +24,28 @@ 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(Icons.Outlined.SettingsPhone, 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(Icons.Outlined.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(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
|
||||
CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, 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(Icons.Filled.PhoneInTalk, 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))
|
||||
Text(durationText(duration), color = MaterialTheme.colors.secondary)
|
||||
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(durationText(duration), color = HighOrLowlight)
|
||||
}
|
||||
CICallStatus.Error -> {}
|
||||
}
|
||||
|
||||
Text(
|
||||
cItem.timestampText,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
color = HighOrLowlight,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
)
|
||||
@@ -53,9 +55,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), Icons.Outlined.RingVolume) { acceptCall(cInfo.contact) }
|
||||
} else {
|
||||
Icon(painterResource(MR.images.ic_ring_volume), stringResource(MR.strings.answer_call), tint = MaterialTheme.colors.secondary)
|
||||
Icon(Icons.Outlined.RingVolume, stringResource(R.string.answer_call), tint = HighOrLowlight)
|
||||
}
|
||||
// if case let .direct(contact) = chatInfo {
|
||||
// Button {
|
||||
@@ -152,4 +154,4 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
||||
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//}
|
||||
@@ -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,25 +6,22 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.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(
|
||||
chatItem: ChatItem,
|
||||
feature: Feature,
|
||||
iconColor: Color,
|
||||
icon: Painter? = null
|
||||
iconColor: Color
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(18.dp), tint = iconColor)
|
||||
Icon(feature.iconFilled, feature.text, Modifier.size(15.dp), tint = iconColor)
|
||||
Text(
|
||||
chatEventText(chatItem),
|
||||
Modifier,
|
||||
@@ -0,0 +1,69 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun CIEventView(ci: ChatItem) {
|
||||
@Composable
|
||||
fun chatEventTextView(text: AnnotatedString) {
|
||||
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
|
||||
}
|
||||
|
||||
Surface {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val memberDisplayName = ci.memberDisplayName
|
||||
if (memberDisplayName != null) {
|
||||
chatEventTextView(
|
||||
buildAnnotatedString {
|
||||
withChatEventStyle(this, memberDisplayName)
|
||||
append(" ")
|
||||
}.plus(chatEventText(ci))
|
||||
)
|
||||
} else {
|
||||
chatEventTextView(chatEventText(ci))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun withChatEventStyle(builder: AnnotatedString.Builder, text: String) {
|
||||
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
|
||||
}
|
||||
|
||||
fun chatEventText(ci: ChatItem): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
withChatEventStyle(this, ci.content.text)
|
||||
append(" ")
|
||||
withChatEventStyle(this, ci.timestampText)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun CIEventViewPreview() {
|
||||
SimpleXTheme {
|
||||
CIEventView(
|
||||
ChatItem.getGroupEventSample()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,30 @@
|
||||
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
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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.platform.LocalDensity
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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,26 +32,27 @@ 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(
|
||||
innerIcon: Painter? = null,
|
||||
innerIcon: ImageVector? = null,
|
||||
color: Color = if (isInDarkTheme()) FileDark else FileLight
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_draft_filled),
|
||||
stringResource(MR.strings.icon_descr_file),
|
||||
Icons.Filled.InsertDriveFile,
|
||||
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),
|
||||
@@ -61,7 +64,7 @@ fun CIFileView(
|
||||
|
||||
fun fileSizeValid(): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
return file.fileSize <= MAX_FILE_SIZE
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -69,37 +72,27 @@ fun CIFileView(
|
||||
fun fileAction() {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
is CIFileStatus.RcvInvitation -> {
|
||||
CIFileStatus.RcvInvitation -> {
|
||||
if (fileSizeValid()) {
|
||||
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(MAX_FILE_SIZE))
|
||||
)
|
||||
}
|
||||
}
|
||||
is CIFileStatus.RcvAccepted ->
|
||||
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)
|
||||
)
|
||||
FileProtocol.SMP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.waiting_for_file),
|
||||
generalGetString(MR.strings.file_will_be_received_when_contact_is_online)
|
||||
)
|
||||
}
|
||||
is CIFileStatus.RcvComplete -> {
|
||||
val filePath = getLoadedFilePath(file)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_file),
|
||||
String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
|
||||
)
|
||||
CIFileStatus.RcvComplete -> {
|
||||
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 -> {}
|
||||
@@ -112,24 +105,10 @@ fun CIFileView(
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(32.dp),
|
||||
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||
strokeWidth = 3.dp
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun progressCircle(progress: Long, total: Long) {
|
||||
val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat()
|
||||
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
|
||||
val strokeColor = if (isInDarkTheme()) FileDark else FileLight
|
||||
Surface(
|
||||
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||
color = Color.Transparent,
|
||||
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||
) {
|
||||
Box(Modifier.size(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun fileIndicator() {
|
||||
Box(
|
||||
@@ -141,35 +120,19 @@ fun CIFileView(
|
||||
) {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
is CIFileStatus.SndStored ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressIndicator()
|
||||
FileProtocol.SMP -> fileIcon()
|
||||
}
|
||||
is CIFileStatus.SndTransfer ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||
FileProtocol.SMP -> progressIndicator()
|
||||
}
|
||||
is CIFileStatus.SndComplete -> fileIcon(innerIcon = 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.RcvInvitation ->
|
||||
CIFileStatus.SndStored -> fileIcon()
|
||||
CIFileStatus.SndTransfer -> progressIndicator()
|
||||
CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
|
||||
CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
CIFileStatus.RcvInvitation ->
|
||||
if (fileSizeValid())
|
||||
fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary)
|
||||
fileIcon(innerIcon = Icons.Outlined.ArrowDownward, 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))
|
||||
is CIFileStatus.RcvTransfer ->
|
||||
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||
} else {
|
||||
progressIndicator()
|
||||
}
|
||||
is CIFileStatus.RcvComplete -> fileIcon()
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
||||
is CIFileStatus.RcvError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
||||
is CIFileStatus.Invalid -> fileIcon(innerIcon = painterResource(MR.images.ic_question_mark))
|
||||
fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
|
||||
CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
|
||||
CIFileStatus.RcvTransfer -> progressIndicator()
|
||||
CIFileStatus.RcvComplete -> fileIcon()
|
||||
CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
}
|
||||
} else {
|
||||
fileIcon()
|
||||
@@ -188,14 +151,16 @@ fun CIFileView(
|
||||
else
|
||||
" "
|
||||
if (file != null) {
|
||||
Column {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
file.fileName,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
formatBytes(file.fileSize) + metaReserve,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
color = HighOrLowlight,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
@@ -206,31 +171,19 @@ fun CIFileView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
|
||||
rememberFileChooserLauncher(false, ciFile) { 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),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false),
|
||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
|
||||
quotedItem = null,
|
||||
reactions = listOf(),
|
||||
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
|
||||
)
|
||||
private val fileChatItemWtFile = ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), ),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
|
||||
quotedItem = null,
|
||||
reactions = listOf(),
|
||||
file = null
|
||||
)
|
||||
override val values = listOf(
|
||||
@@ -238,7 +191,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||
ChatItem.getFileMsgContentSample(),
|
||||
ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10)),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
|
||||
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
|
||||
@@ -254,4 +207,4 @@ fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatI
|
||||
SimpleXTheme {
|
||||
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
|
||||
}
|
||||
}*/
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
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 android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SupervisedUserCircle
|
||||
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 +46,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 = Icons.Filled.SupervisedUserCircle, color = iconColor)
|
||||
Spacer(Modifier.padding(horizontal = 3.dp))
|
||||
Column(
|
||||
Modifier.defaultMinSize(minHeight = 60.dp),
|
||||
@@ -59,22 +63,20 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||
Surface(
|
||||
modifier = if (action) Modifier.clickable(onClick = {
|
||||
joinGroup(groupInvitation.groupId)
|
||||
}) else Modifier,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (sent) sentColor else receivedColor,
|
||||
color = if (sent) SentColorLight else ReceivedColorLight,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -87,6 +89,7 @@ fun CIGroupInvitationView(
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 4.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
groupInfoView()
|
||||
Column(Modifier.padding(top = 2.dp, start = 5.dp)) {
|
||||
@@ -94,7 +97,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)) {
|
||||
@@ -105,7 +108,7 @@ fun CIGroupInvitationView(
|
||||
}
|
||||
Text(
|
||||
ci.timestampText,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
color = HighOrLowlight,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
)
|
||||
@@ -113,10 +116,11 @@ fun CIGroupInvitationView(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PendingCIGroupInvitationViewPreview() {
|
||||
SimpleXTheme {
|
||||
@@ -129,10 +133,11 @@ fun PendingCIGroupInvitationViewPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun CIGroupInvitationViewAcceptedPreview() {
|
||||
SimpleXTheme {
|
||||
@@ -145,7 +150,7 @@ fun CIGroupInvitationViewAcceptedPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun CIGroupInvitationViewLongNamePreview() {
|
||||
SimpleXTheme {
|
||||
@@ -0,0 +1,210 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ArrowDownward
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.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 androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIFile
|
||||
import chat.simplex.app.model.CIFileStatus
|
||||
import chat.simplex.app.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
|
||||
|
||||
@Composable
|
||||
fun CIImageView(
|
||||
image: String,
|
||||
file: CIFile?,
|
||||
imageProvider: () -> ImageGalleryProvider,
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
@Composable
|
||||
fun loadingIndicator() {
|
||||
if (file != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.size(20.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.SndTransfer ->
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
CIFileStatus.SndComplete ->
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
stringResource(R.string.icon_descr_image_snd_complete),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
Icon(
|
||||
Icons.Outlined.MoreHoriz,
|
||||
stringResource(R.string.icon_descr_waiting_for_image),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
CIFileStatus.RcvTransfer ->
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
CIFileStatus.RcvInvitation ->
|
||||
Icon(
|
||||
Icons.Outlined.ArrowDownward,
|
||||
stringResource(R.string.icon_descr_asked_to_receive),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun imageViewFullWidth(): Dp {
|
||||
val approximatePadding = 100.dp
|
||||
return with(LocalDensity.current) { minOf(1000.dp, LocalView.current.width.toDp() - approximatePadding) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
|
||||
Image(
|
||||
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
|
||||
.width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else 1000.dp)
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = onClick
|
||||
),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun imageView(painter: Painter, onClick: () -> Unit) {
|
||||
Image(
|
||||
painter,
|
||||
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
|
||||
.width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else 1000.dp)
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = onClick
|
||||
),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
fun fileSizeValid(): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= MAX_FILE_SIZE
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun imageAndFilePath(file: CIFile?): Pair<Bitmap?, String?> {
|
||||
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, file)
|
||||
return imageBitmap to filePath
|
||||
}
|
||||
|
||||
Box(
|
||||
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) { 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) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation ->
|
||||
if (fileSizeValid()) {
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
||||
)
|
||||
}
|
||||
CIFileStatus.RcvAccepted ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||
)
|
||||
CIFileStatus.RcvTransfer -> {} // ?
|
||||
CIFileStatus.RcvComplete -> {} // ?
|
||||
CIFileStatus.RcvCancelled -> {} // TODO
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
loadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
private val imageLoader = ImageLoader.Builder(SimplexApp.context)
|
||||
.components {
|
||||
if (SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
@@ -0,0 +1,157 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.WarningYellow
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!chatItem.isDeletedContent) {
|
||||
if (chatItem.meta.itemEdited) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
modifier = Modifier.height(12.dp).padding(end = 1.dp),
|
||||
contentDescription = stringResource(R.string.icon_descr_edited),
|
||||
tint = metaColor,
|
||||
)
|
||||
}
|
||||
CIStatusView(chatItem.meta.itemStatus, metaColor)
|
||||
}
|
||||
Text(
|
||||
chatItem.timestampText,
|
||||
color = metaColor,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
|
||||
when (status) {
|
||||
is CIStatus.SndSent -> {
|
||||
Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
|
||||
}
|
||||
is CIStatus.SndErrorAuth -> {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
|
||||
}
|
||||
is CIStatus.SndError -> {
|
||||
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = WarningYellow)
|
||||
}
|
||||
is CIStatus.RcvNew -> {
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaView() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewUnread() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
status = CIStatus.RcvNew()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewSendFailed() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
status = CIStatus.SndError("CMD SYNTAX")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewSendNoAuth() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewSendSent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewEdited() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
itemEdited = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewEditedUnread() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
|
||||
itemEdited = true,
|
||||
status=CIStatus.RcvNew()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewEditedSent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
itemEdited = true,
|
||||
status=CIStatus.SndSent()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewDeletedContent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,28 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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.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.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
|
||||
|
||||
@@ -35,16 +34,15 @@ fun CIVoiceView(
|
||||
sent: Boolean,
|
||||
hasText: Boolean,
|
||||
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 +63,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, 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
|
||||
@@ -90,92 +86,41 @@ private fun VoiceLayout(
|
||||
brokenAudio: Boolean,
|
||||
sent: Boolean,
|
||||
hasText: Boolean,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(top = 6.dp)) {
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DurationText(text, PaddingValues(start = 12.dp))
|
||||
Spacer(Modifier.height(56.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +135,7 @@ private fun DurationText(text: State<String>, padding: PaddingValues) {
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.widthIn(min = minWidth),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
color = HighOrLowlight,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
@@ -209,11 +154,9 @@ private fun PlayPauseButton(
|
||||
pause: () -> Unit,
|
||||
longClick: () -> Unit
|
||||
) {
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||
Surface(
|
||||
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||
color = if (sent) sentColor else receivedColor,
|
||||
color = if (sent) SentColorLight else ReceivedColorLight,
|
||||
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||
) {
|
||||
Box(
|
||||
@@ -222,15 +165,14 @@ private fun PlayPauseButton(
|
||||
.combinedClickable(
|
||||
onClick = { if (!audioPlaying) play() else pause() },
|
||||
onLongClick = longClick
|
||||
)
|
||||
.onRightClick { longClick() },
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
if (audioPlaying) painterResource(MR.images.ic_pause_filled) else painterResource(MR.images.ic_play_arrow_filled),
|
||||
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
Modifier.size(36.dp),
|
||||
tint = if (error) WarningOrange else if (!enabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
tint = if (error) WarningOrange else if (!enabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -247,8 +189,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
|
||||
@@ -257,7 +198,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),
|
||||
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
Modifier.size(36.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
@@ -267,10 +208,9 @@ 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
|
||||
|| file?.fileStatus is CIFileStatus.RcvAccepted
|
||||
if (file?.fileStatus == CIFileStatus.RcvInvitation
|
||||
|| file?.fileStatus == CIFileStatus.RcvTransfer
|
||||
|| file?.fileStatus == CIFileStatus.RcvAccepted
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -286,7 +226,7 @@ private fun VoiceMsgIndicator(
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||
private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||
val brush = Brush.linearGradient(
|
||||
0f to Color.Transparent,
|
||||
0f to color,
|
||||
@@ -0,0 +1,343 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.*
|
||||
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.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.ComposeContextItem
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||
|
||||
@Composable
|
||||
fun ChatItemView(
|
||||
cInfo: ChatInfo,
|
||||
cItem: ChatItem,
|
||||
composeState: MutableState<ComposeState>,
|
||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||
showMember: Boolean = false,
|
||||
useLinkPreviews: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
scrollToItem: (Long) -> Unit,
|
||||
) {
|
||||
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.fullDeletionAllowed }
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MsgContentItemDropdownMenu() {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
if (!cItem.meta.itemDeleted) {
|
||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
||||
when {
|
||||
filePath != null -> shareFile(context, cItem.text, filePath)
|
||||
else -> shareText(context, cItem.content.text)
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
|
||||
copyText(context, cItem.content.text)
|
||||
showMenu.value = false
|
||||
})
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||
val filePath = getLoadedFilePath(context, cItem.file)
|
||||
if (filePath != null) {
|
||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
||||
when (cItem.content.msgContent) {
|
||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
else -> {}
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
|
||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
if (cItem.meta.itemDeleted && revealed.value) {
|
||||
ItemAction(
|
||||
stringResource(R.string.hide_verb),
|
||||
Icons.Outlined.VisibilityOff,
|
||||
onClick = {
|
||||
revealed.value = false
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkedDeletedItemDropdownMenu() {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.reveal_verb),
|
||||
Icons.Outlined.Visibility,
|
||||
onClick = {
|
||||
revealed.value = true
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContentItem() {
|
||||
val mc = cItem.content.msgContent
|
||||
if (cItem.meta.itemDeleted && !revealed.value) {
|
||||
MarkedDeletedItemView(cItem, showMember = showMember)
|
||||
MarkedDeletedItemDropdownMenu()
|
||||
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted) {
|
||||
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem)
|
||||
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, longClick = { onLinkLongClick("") })
|
||||
MsgContentItemDropdownMenu()
|
||||
} else {
|
||||
framedItemView()
|
||||
MsgContentItemDropdownMenu()
|
||||
}
|
||||
} else {
|
||||
framedItemView()
|
||||
MsgContentItemDropdownMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun DeletedItem() {
|
||||
DeletedItemView(cItem, showMember = showMember)
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
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(cItem, showMember = showMember)
|
||||
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
|
||||
is CIContent.SndGroupEventContent -> CIEventView(cItem)
|
||||
is CIContent.RcvConnEventContent -> CIEventView(cItem)
|
||||
is CIContent.SndConnEventContent -> CIEventView(cItem)
|
||||
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||
is CIContent.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItemAction(
|
||||
cItem: ChatItem,
|
||||
showMenu: MutableState<Boolean>,
|
||||
questionText: String,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
|
||||
DropdownMenuItem(onClick) {
|
||||
Row {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = color
|
||||
)
|
||||
Icon(icon, text, tint = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.End,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_me_only)) }
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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 = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemViewDeletedContent() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,27 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
|
||||
val sent = ci.chatDir.sent
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||
fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (sent) sentColor else receivedColor,
|
||||
color = ReceivedColorLight,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
@@ -30,26 +29,27 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) }
|
||||
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(ci.content.text) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
CIMetaView(ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
null
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,26 +9,27 @@ 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.common.model.MREmojiChar
|
||||
import chat.simplex.common.ui.theme.EmojiFont
|
||||
import chat.simplex.app.model.ChatItem
|
||||
|
||||
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont)
|
||||
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiFont)
|
||||
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
|
||||
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
|
||||
|
||||
@Composable
|
||||
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
|
||||
fun EmojiItemView(chatItem: ChatItem) {
|
||||
Column(
|
||||
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EmojiText(chatItem.content.text)
|
||||
CIMetaView(chatItem, timedMessagesTTL)
|
||||
CIMetaView(chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun EmojiText(text: String)
|
||||
fun EmojiText(text: String) {
|
||||
val s = text.trim()
|
||||
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/46279500
|
||||
private const val emojiStr = "^(" +
|
||||
@@ -1,32 +1,37 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
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.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.platform.appPlatform
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.platform.base64ToBitmap
|
||||
import chat.simplex.common.views.chat.MEMBER_IMAGE_SIZE
|
||||
import chat.simplex.res.MR
|
||||
import kotlin.math.min
|
||||
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
|
||||
|
||||
val SentColorLight = Color(0x1E45B8FF)
|
||||
val ReceivedColorLight = Color(0x20B1B0B5)
|
||||
val SentQuoteColorLight = Color(0x2545B8FF)
|
||||
val ReceivedQuoteColorLight = Color(0x25B1B0B5)
|
||||
|
||||
@Composable
|
||||
fun FramedItemView(
|
||||
@@ -34,6 +39,7 @@ fun FramedItemView(
|
||||
ci: ChatItem,
|
||||
uriHandler: UriHandler? = null,
|
||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||
showMember: Boolean = false,
|
||||
linkMode: SimplexLinkMode,
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit,
|
||||
@@ -41,118 +47,72 @@ fun FramedItemView(
|
||||
scrollToItem: (Long) -> Unit = {},
|
||||
) {
|
||||
val sent = ci.chatDir.sent
|
||||
val chatTTL = chatInfo.timedMessagesTTL
|
||||
|
||||
fun membership(): GroupMember? {
|
||||
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Color.toQuote(): Color = if (isInDarkTheme()) lighter(0.12f) else darker(0.12f)
|
||||
|
||||
@Composable
|
||||
fun ciQuotedMsgTextView(qi: CIQuote, lines: Int) {
|
||||
MarkdownText(
|
||||
qi.text,
|
||||
qi.formattedText,
|
||||
maxLines = lines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
|
||||
linkMode = linkMode,
|
||||
uriHandler = if (appPlatform.isDesktop) uriHandler else null
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ciQuotedMsgView(qi: CIQuote) {
|
||||
Box(
|
||||
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
|
||||
contentAlignment = Alignment.TopStart
|
||||
) {
|
||||
val sender = qi.sender(membership())
|
||||
if (sender != null) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
sender,
|
||||
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
|
||||
)
|
||||
ciQuotedMsgTextView(qi, lines = 2)
|
||||
}
|
||||
} else {
|
||||
ciQuotedMsgTextView(qi, lines = 3)
|
||||
}
|
||||
MarkdownText(
|
||||
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
|
||||
linkMode = linkMode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null) {
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||
fun ciDeletedView() {
|
||||
Row(
|
||||
Modifier
|
||||
.background(if (sent) sentColor.toQuote() else receivedColor.toQuote())
|
||||
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
|
||||
.padding(start = 8.dp)
|
||||
.padding(end = 12.dp)
|
||||
.padding(top = 6.dp)
|
||||
.padding(bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
icon,
|
||||
caption,
|
||||
Modifier.size(18.dp),
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.marked_deleted_description),
|
||||
Modifier.size(18.dp),
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) {
|
||||
append(caption)
|
||||
}
|
||||
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ciQuoteView(qi: CIQuote) {
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||
Row(
|
||||
Modifier
|
||||
.background(if (sent) sentColor.toQuote() else receivedColor.toQuote())
|
||||
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
|
||||
)
|
||||
.onRightClick { showMenu.value = true }
|
||||
) {
|
||||
when (qi.content) {
|
||||
is MsgContent.MCImage -> {
|
||||
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),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
)
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
val imageBitmap = base64ToBitmap(qi.content.image)
|
||||
Image(
|
||||
imageBitmap,
|
||||
contentDescription = stringResource(MR.strings.video_descr),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
)
|
||||
@@ -162,8 +122,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) Icons.Filled.InsertDriveFile else Icons.Filled.Mic,
|
||||
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),
|
||||
@@ -178,40 +138,29 @@ fun FramedItemView(
|
||||
@Composable
|
||||
fun ciFileView(ci: ChatItem, text: String) {
|
||||
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
||||
if (text != "" || ci.meta.isLive) {
|
||||
CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler)
|
||||
if (text != "") {
|
||||
CIMarkdownText(ci, showMember, linkMode = linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
|
||||
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) &&
|
||||
!ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||
Box(Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(
|
||||
when {
|
||||
transparentBackground -> Color.Transparent
|
||||
sent -> sentColor
|
||||
else -> receivedColor
|
||||
sent -> SentColorLight
|
||||
else -> ReceivedColorLight
|
||||
}
|
||||
)) {
|
||||
var metaColor = MaterialTheme.colors.secondary
|
||||
var metaColor = HighOrLowlight
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||
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))
|
||||
} else {
|
||||
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
|
||||
}
|
||||
} else if (ci.meta.isLive) {
|
||||
FramedItemHeader(stringResource(MR.strings.live), false)
|
||||
}
|
||||
if (ci.meta.itemDeleted) { ciDeletedView() }
|
||||
ci.quotedItem?.let { ciQuoteView(it) }
|
||||
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
|
||||
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -227,46 +176,36 @@ fun FramedItemView(
|
||||
when (val mc = ci.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
if (mc.text == "") {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
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, longClick = { onLinkLongClick("") })
|
||||
if (mc.text != "") {
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
||||
is MsgContent.MCUnknown ->
|
||||
if (ci.file == null) {
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
} else {
|
||||
ciFileView(ci, mc.text)
|
||||
}
|
||||
is MsgContent.MCLink -> {
|
||||
ChatItemLinkView(mc.preview)
|
||||
Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) {
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
|
||||
}
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
}
|
||||
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
|
||||
else -> CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||
CIMetaView(ci, chatTTL, metaColor)
|
||||
CIMetaView(ci, metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,28 +214,21 @@ fun FramedItemView(
|
||||
@Composable
|
||||
fun CIMarkdownText(
|
||||
ci: ChatItem,
|
||||
chatTTL: Int?,
|
||||
showMember: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
uriHandler: UriHandler?,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||
MarkdownText(
|
||||
text, if (text.isEmpty()) emptyList() else ci.formattedText,
|
||||
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
|
||||
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||
metaText = ci.timestampText, edited = ci.meta.itemEdited, linkMode = linkMode,
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
|
||||
/**
|
||||
* Equal to [androidx.compose.ui.unit.Constraints.MaxFocusMask], which is 0x3FFFF - 1
|
||||
* Other values make a crash `java.lang.IllegalArgumentException: Can't represent a width of 123456 and height of 9909 in Constraints`
|
||||
* See [androidx.compose.ui.unit.Constraints.createConstraints]
|
||||
* */
|
||||
const val MAX_SAFE_WIDTH = 0x3FFFF - 1
|
||||
|
||||
@Composable
|
||||
fun PriorityLayout(
|
||||
@@ -304,17 +236,6 @@ fun PriorityLayout(
|
||||
priorityLayoutId: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
/**
|
||||
* Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints]
|
||||
* */
|
||||
fun maxSafeHeight(width: Int) = when { // width bits + height bits should be <= 31
|
||||
width < 0x1FFF /*MaxNonFocusMask*/ -> 0x3FFFF - 1 /* MaxFocusMask */ // 13 bits width + 18 bits height
|
||||
width < 0x7FFF /*MinNonFocusMask*/ -> 0xFFFF - 1 /* MinFocusMask */ // 15 bits width + 16 bits height
|
||||
width < 0xFFFF /*MinFocusMask*/ -> 0x7FFF - 1 /* MinFocusMask */ // 16 bits width + 15 bits height
|
||||
width < 0x3FFFF /*MaxFocusMask*/ -> 0x1FFF - 1 /* MaxNonFocusMask */ // 18 bits width + 13 bits height
|
||||
else -> 0x1FFF // shouldn't happen since width is limited already
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
@@ -322,15 +243,13 @@ 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
|
||||
it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: min(MAX_SAFE_WIDTH, constraints.maxWidth))) }
|
||||
// Limit width for every other element to width of important element and height for a sum of all elements.
|
||||
val width = imagePlaceable?.measuredWidth ?: min(MAX_SAFE_WIDTH, placeables.maxOf { it.width })
|
||||
val height = minOf(maxSafeHeight(width), placeables.sumOf { it.height })
|
||||
layout(width, height) {
|
||||
it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: constraints.maxWidth)) }
|
||||
// Limit width for every other element to width of important element and height for a sum of all elements
|
||||
layout(imagePlaceable?.measuredWidth ?: placeables.maxOf { it.width }, placeables.sumOf { it.height }) {
|
||||
var y = 0
|
||||
placeables.forEach {
|
||||
it.place(0, y)
|
||||
@@ -339,7 +258,6 @@ fun PriorityLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
|
||||
class EditedProvider: PreviewParameterProvider<Boolean> {
|
||||
override val values = listOf(false, true).asSequence()
|
||||
@@ -529,4 +447,3 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
|
||||
)
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,153 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
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 kotlinx.coroutines.launch
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
interface ImageGalleryProvider {
|
||||
val initialIndex: Int
|
||||
val totalImagesSize: MutableState<Int>
|
||||
fun getImage(index: Int): Pair<Bitmap, Uri>?
|
||||
fun currentPageChanged(index: Int)
|
||||
fun scrollToStart()
|
||||
fun onDismiss(index: Int)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) {
|
||||
val provider = remember { imageProvider() }
|
||||
val pagerState = rememberPagerState(provider.initialIndex)
|
||||
val goBack = { provider.onDismiss(pagerState.currentPage); close() }
|
||||
BackHandler(onBack = goBack)
|
||||
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
|
||||
// which makes this blank page visible for a moment. Prevent it by doing the check ourselves
|
||||
LaunchedEffect(Unit) {
|
||||
if (provider.getImage(provider.initialIndex - 1) == null) {
|
||||
provider.scrollToStart()
|
||||
pagerState.scrollToPage(0)
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
HorizontalPager(count = remember { provider.totalImagesSize }.value, state = pagerState) { index ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = goBack)
|
||||
) {
|
||||
var settledCurrentPage by remember { mutableStateOf(pagerState.currentPage) }
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow {
|
||||
if (!pagerState.isScrollInProgress) pagerState.currentPage else settledCurrentPage
|
||||
}.collect {
|
||||
settledCurrentPage = it
|
||||
}
|
||||
}
|
||||
LaunchedEffect(settledCurrentPage) {
|
||||
// Make this pager with infinity scrolling with only 3 pages at a time when left and right pages constructs in real time
|
||||
if (settledCurrentPage != provider.initialIndex)
|
||||
provider.currentPageChanged(index)
|
||||
}
|
||||
val image = provider.getImage(index)
|
||||
if (image == null) {
|
||||
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
|
||||
SideEffect {
|
||||
scope.launch {
|
||||
when (settledCurrentPage) {
|
||||
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
|
||||
index + 1 -> {
|
||||
provider.scrollToStart()
|
||||
pagerState.scrollToPage(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val (imageBitmap: Bitmap, uri: Uri) = image
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var translationX by remember { mutableStateOf(0f) }
|
||||
var translationY by remember { mutableStateOf(0f) }
|
||||
var viewWidth by remember { mutableStateOf(0) }
|
||||
var allowTranslate by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(settledCurrentPage) {
|
||||
scale = 1f
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
Image(
|
||||
rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
viewWidth = it.size.width
|
||||
}
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = translationX,
|
||||
translationY = translationY,
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures(
|
||||
{ allowTranslate },
|
||||
onGesture = { _, pan, gestureZoom, _ ->
|
||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||
allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0
|
||||
if (scale > 1 && allowTranslate) {
|
||||
translationX += pan.x * scale
|
||||
translationY += pan.y * scale
|
||||
} else if (allowTranslate) {
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
Surface(
|
||||
Modifier.clickable(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.alert_title_skipped_messages),
|
||||
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
||||
)
|
||||
}),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = ReceivedColorLight,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun IntegrityErrorItemViewPreview() {
|
||||
SimpleXTheme {
|
||||
IntegrityErrorItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (ci.chatDir.sent) SentColorLight else ReceivedColorLight,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
|
||||
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewMarkedDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getSampleData(itemDeleted = true)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,37 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import kotlinx.coroutines.*
|
||||
import java.awt.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.core.text.BidiFormatter
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.detectGesture
|
||||
|
||||
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
||||
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
||||
|
||||
fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
|
||||
if (chatItem.chatDir is CIDirection.GroupRcv) {
|
||||
val name = chatItem.chatDir.groupMember.memberProfile.displayName
|
||||
if (groupMemberBold) b.withStyle(boldFont) { append(name) }
|
||||
else b.append(name)
|
||||
b.append(": ")
|
||||
}
|
||||
}
|
||||
|
||||
fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) {
|
||||
if (sender != null) {
|
||||
if (senderBold) b.withStyle(boldFont) { append(sender) }
|
||||
@@ -35,31 +40,13 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea
|
||||
}
|
||||
}
|
||||
|
||||
private val noTyping: AnnotatedString = AnnotatedString(" ")
|
||||
|
||||
private val typingIndicators: List<AnnotatedString> = listOf(
|
||||
typing(FontWeight.Black) + typing() + typing(),
|
||||
typing(FontWeight.Bold) + typing(FontWeight.Black) + typing(),
|
||||
typing() + typing(FontWeight.Bold) + typing(FontWeight.Black),
|
||||
typing() + typing() + typing(FontWeight.Bold)
|
||||
)
|
||||
|
||||
|
||||
private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = buildAnnotatedString {
|
||||
pushStyle(SpanStyle(color = CurrentColors.value.colors.secondary, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
|
||||
append(if (recent) typingIndicators[typingIdx] else noTyping)
|
||||
}
|
||||
|
||||
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
|
||||
AnnotatedString(".", SpanStyle(fontWeight = w))
|
||||
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
text: CharSequence,
|
||||
text: String,
|
||||
formattedText: List<FormattedText>? = null,
|
||||
sender: String? = null,
|
||||
meta: CIMeta? = null,
|
||||
chatTTL: Int? = null,
|
||||
metaText: String? = null,
|
||||
edited: Boolean = false,
|
||||
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
@@ -67,65 +54,29 @@ fun MarkdownText (
|
||||
senderBold: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
linkMode: SimplexLinkMode,
|
||||
inlineContent: Map<String, InlineTextContent>? = null,
|
||||
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"
|
||||
} else if (meta != null) {
|
||||
reserveSpaceForMeta(meta, chatTTL)
|
||||
} else {
|
||||
" "
|
||||
val reserve = when {
|
||||
textLayoutDirection != LocalLayoutDirection.current && metaText != null -> "\n"
|
||||
edited -> " "
|
||||
else -> " "
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current)
|
||||
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
else
|
||||
LocalLayoutDirection.current
|
||||
) {
|
||||
var timer: Job? by remember { mutableStateOf(null) }
|
||||
var typingIdx by rememberSaveable { mutableStateOf(0) }
|
||||
fun stopTyping() {
|
||||
timer?.cancel()
|
||||
timer = null
|
||||
}
|
||||
fun switchTyping() {
|
||||
if (meta != null && meta.isLive && meta.recent) {
|
||||
timer = timer ?: scope.launch {
|
||||
while (isActive) {
|
||||
typingIdx = (typingIdx + 1) % typingIndicators.size
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
if (meta?.isLive == true) {
|
||||
LaunchedEffect(meta.recent, meta.isLive) {
|
||||
switchTyping()
|
||||
}
|
||||
DisposableEffectOnGone(
|
||||
whenGone = {
|
||||
stopTyping()
|
||||
}
|
||||
)
|
||||
}
|
||||
if (formattedText == null) {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
if (text is String) append(text)
|
||||
else if (text is AnnotatedString) append(text)
|
||||
if (meta?.isLive == true) {
|
||||
append(typingIndicator(meta.recent, typingIdx))
|
||||
}
|
||||
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
append(text)
|
||||
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
}
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent ?: mapOf())
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
} else {
|
||||
var hasLinks = false
|
||||
val annotatedText = buildAnnotatedString {
|
||||
@@ -141,7 +92,7 @@ fun MarkdownText (
|
||||
} else {
|
||||
ft.format.style
|
||||
}
|
||||
withAnnotation(tag = if (ft.format is Format.SimplexLink && linkMode != SimplexLinkMode.BROWSER) "SIMPLEX_URL" else "URL", annotation = link) {
|
||||
withAnnotation(tag = "URL", annotation = link) {
|
||||
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
|
||||
}
|
||||
} else {
|
||||
@@ -149,51 +100,31 @@ fun MarkdownText (
|
||||
}
|
||||
}
|
||||
}
|
||||
if (meta?.isLive == true) {
|
||||
append(typingIndicator(meta.recent, typingIdx))
|
||||
}
|
||||
// With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good
|
||||
/*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl)
|
||||
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
|
||||
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
else */if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
}
|
||||
if (hasLinks && uriHandler != null) {
|
||||
val icon = remember { mutableStateOf(PointerIcon.Default) }
|
||||
ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
|
||||
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||
onLongClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
|
||||
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
|
||||
},
|
||||
onClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.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()}")
|
||||
}
|
||||
}
|
||||
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation ->
|
||||
uriHandler.openVerifiedSimplexUri(annotation.item)
|
||||
}
|
||||
},
|
||||
onHover = { offset ->
|
||||
icon.value = annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let {
|
||||
PointerIcon.Hand
|
||||
} ?: annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let {
|
||||
PointerIcon.Hand
|
||||
} ?: PointerIcon.Default
|
||||
},
|
||||
shouldConsumeEvent = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
||||
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset).any()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
@@ -214,7 +145,6 @@ fun ClickableText(
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
onClick: (Int) -> Unit,
|
||||
onLongClick: (Int) -> Unit = {},
|
||||
onHover: (Int) -> Unit = {},
|
||||
shouldConsumeEvent: (Int) -> Boolean
|
||||
) {
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
@@ -232,20 +162,12 @@ fun ClickableText(
|
||||
}
|
||||
}, shouldConsumeEvent = { pos ->
|
||||
var consume = false
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
consume
|
||||
}
|
||||
)
|
||||
}.pointerInput(onHover) {
|
||||
if (appPlatform.isDesktop) {
|
||||
detectCursorMove { pos ->
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
onHover(layoutResult.getOffsetForPosition(pos))
|
||||
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
consume
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
BasicText(
|
||||
@@ -261,13 +183,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,93 @@
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.usersettings.simplexTeamUri
|
||||
|
||||
val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
||||
|
||||
@Composable
|
||||
fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp)
|
||||
Text(
|
||||
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
uriHandler.openUri(simplexTeamUri)
|
||||
}),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
|
||||
Column(
|
||||
Modifier.padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
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(
|
||||
Icons.Outlined.PersonAdd,
|
||||
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),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatHelpLayout() {
|
||||
SimpleXTheme {
|
||||
ChatHelpView {}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,28 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
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.*
|
||||
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.ItemAction
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.ContactConnectionInfoView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@@ -44,21 +38,18 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
showMenu.value = false
|
||||
delay(500L)
|
||||
}
|
||||
val showChatPreviews = chatModel.showChatPreviews.value
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct -> {
|
||||
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
|
||||
is ChatInfo.Direct ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
|
||||
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
stopped
|
||||
)
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
|
||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
@@ -66,7 +57,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
)
|
||||
is ChatInfo.ContactRequest ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ContactRequestView(chat.chatInfo) },
|
||||
chatLinkPreview = { ContactRequestView(chatModel.incognito.value, chat.chatInfo) },
|
||||
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||
showMenu,
|
||||
@@ -76,9 +67,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
|
||||
click = {
|
||||
ModalManager.center.closeModals()
|
||||
ModalManager.end.closeModals()
|
||||
ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close ->
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ContactConnectionInfoView(chatModel, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close)
|
||||
}
|
||||
},
|
||||
@@ -86,25 +75,12 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
showMenu,
|
||||
stopped
|
||||
)
|
||||
is ChatInfo.InvalidJSON ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = {
|
||||
InvalidDataView()
|
||||
},
|
||||
click = {
|
||||
ModalManager.end.closeModals()
|
||||
ModalManager.center.showModal(true) { InvalidJSONView(chat.chatInfo.json) }
|
||||
},
|
||||
dropdownMenuItems = null,
|
||||
showMenu,
|
||||
stopped
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
if (chatInfo.ready) {
|
||||
withBGApi { openChat(chatInfo, chatModel) }
|
||||
withApi { openChat(chatInfo, chatModel) }
|
||||
} else {
|
||||
pendingContactAlertDialog(chatInfo, chatModel)
|
||||
}
|
||||
@@ -114,7 +90,7 @@ fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
when (groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel)
|
||||
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert()
|
||||
else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
|
||||
else -> withApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,19 +117,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
|
||||
@@ -163,7 +128,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)
|
||||
@@ -178,21 +142,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) {
|
||||
@@ -208,11 +163,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),
|
||||
Icons.Outlined.Check,
|
||||
onClick = {
|
||||
markChatRead(chat, chatModel)
|
||||
ntfManager.cancelNotificationsForChat(chat.id)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
@@ -220,35 +175,35 @@ 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),
|
||||
onClick = {
|
||||
markChatUnread(chat, chatModel)
|
||||
showMenu.value = false
|
||||
DropdownMenuItem({
|
||||
markChatUnread(chat, chatModel)
|
||||
showMenu.value = false
|
||||
}) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(R.string.mark_unread),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
Icons.Outlined.MarkChatUnread,
|
||||
stringResource(R.string.mark_unread),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@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) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
|
||||
onClick = {
|
||||
toggleNotifications(chat, !ntfsEnabled, chatModel)
|
||||
changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
@@ -257,8 +212,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),
|
||||
Icons.Outlined.Restore,
|
||||
onClick = {
|
||||
clearChatDialog(chat.chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -270,8 +225,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),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
deleteContactDialog(chat.chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -283,8 +238,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),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -297,8 +252,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) Icons.Filled.TheaterComedy else Icons.Outlined.Login,
|
||||
color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground,
|
||||
onClick = {
|
||||
joinGroup()
|
||||
@@ -310,8 +265,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),
|
||||
Icons.Outlined.Logout,
|
||||
onClick = {
|
||||
leaveGroupDialog(groupInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -323,26 +278,17 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab
|
||||
@Composable
|
||||
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.accept_contact_button),
|
||||
painterResource(MR.images.ic_check),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button),
|
||||
if (chatModel.incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.Check,
|
||||
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
|
||||
onClick = {
|
||||
acceptContactRequest(incognito = false, chatInfo.apiId, chatInfo, true, chatModel)
|
||||
acceptContactRequest(chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
ItemAction(
|
||||
stringResource(MR.strings.accept_contact_incognito_button),
|
||||
painterResource(MR.images.ic_theater_comedy),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
onClick = {
|
||||
acceptContactRequest(incognito = true, chatInfo.apiId, chatInfo, true, chatModel)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
ItemAction(
|
||||
stringResource(MR.strings.reject_contact_button),
|
||||
painterResource(MR.images.ic_close),
|
||||
stringResource(R.string.reject_contact_button),
|
||||
Icons.Outlined.Close,
|
||||
onClick = {
|
||||
rejectContactRequest(chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -354,56 +300,26 @@ 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),
|
||||
Icons.Outlined.Edit,
|
||||
onClick = {
|
||||
ModalManager.center.closeModals()
|
||||
ModalManager.end.closeModals()
|
||||
ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close ->
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
|
||||
}
|
||||
showMenu.value = false
|
||||
},
|
||||
)
|
||||
ItemAction(
|
||||
stringResource(MR.strings.delete_verb),
|
||||
painterResource(MR.images.ic_delete),
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {
|
||||
if (chatModel.chatId.value == null) {
|
||||
ModalManager.center.closeModals()
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
}
|
||||
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InvalidDataView() {
|
||||
Row {
|
||||
ProfileImage(72.dp, null, MR.images.ic_account_circle_filled, MaterialTheme.colors.secondary)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1F)
|
||||
) {
|
||||
Text(
|
||||
stringResource(MR.strings.invalid_data),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Red
|
||||
)
|
||||
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||
Spacer(Modifier.height(height))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markChatRead(c: Chat, chatModel: ChatModel) {
|
||||
var chat = c
|
||||
withApi {
|
||||
@@ -447,38 +363,20 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.accept_connection_request__question),
|
||||
text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
acceptContactRequest(incognito = false, contactRequest.apiId, contactRequest, true, chatModel)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
acceptContactRequest(incognito = true, contactRequest.apiId, contactRequest, true, chatModel)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
rejectContactRequest(contactRequest, chatModel)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.reject_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
}
|
||||
}
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.accept_connection_request__question),
|
||||
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
||||
confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button),
|
||||
onConfirm = { acceptContactRequest(contactRequest, chatModel) },
|
||||
dismissText = generalGetString(R.string.reject_contact_button),
|
||||
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
|
||||
)
|
||||
}
|
||||
|
||||
fun acceptContactRequest(incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
|
||||
fun acceptContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||
withApi {
|
||||
val contact = chatModel.controller.apiAcceptContactRequest(incognito, apiId)
|
||||
if (contact != null && isCurrentUser && contactRequest != null) {
|
||||
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
|
||||
if (contact != null) {
|
||||
val chat = Chat(ChatInfo.Direct(contact), listOf())
|
||||
chatModel.replaceChat(contactRequest.id, chat)
|
||||
}
|
||||
@@ -492,14 +390,46 @@ fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: Cha
|
||||
}
|
||||
}
|
||||
|
||||
fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(
|
||||
if (connection.initiated) R.string.you_invited_your_contact
|
||||
else R.string.you_accepted_connection
|
||||
),
|
||||
text = generalGetString(
|
||||
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(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
AlertManager.shared.hideAlert()
|
||||
deleteContactConnectionAlert(connection, chatModel) {}
|
||||
}) {
|
||||
Text(stringResource(R.string.delete_verb))
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -508,49 +438,44 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
},
|
||||
destructive = true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
if (r) {
|
||||
chatModel.removeChat(chatInfo.id)
|
||||
if (chatModel.chatId.value == chatInfo.id) {
|
||||
chatModel.chatId.value = null
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
chatModel.chatId.value = null
|
||||
}
|
||||
}
|
||||
},
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -559,39 +484,26 @@ fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId)
|
||||
if (r) {
|
||||
chatModel.removeChat(groupInfo.id)
|
||||
if (chatModel.chatId.value == groupInfo.id) {
|
||||
chatModel.chatId.value = null
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
ntfManager.cancelNotificationsForChat(groupInfo.id)
|
||||
chatModel.chatId.value = null
|
||||
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
|
||||
}
|
||||
@@ -607,13 +519,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -627,9 +536,7 @@ fun ChatListNavLinkLayout(
|
||||
stopped: Boolean
|
||||
) {
|
||||
var modifier = Modifier.fillMaxWidth()
|
||||
if (!stopped) modifier = modifier
|
||||
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||
.onRightClick { showMenu.value = true }
|
||||
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||
Box(modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -640,17 +547,26 @@ fun ChatListNavLinkLayout(
|
||||
chatLinkPreview()
|
||||
}
|
||||
if (dropdownMenuItems != null) {
|
||||
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
|
||||
Box(Modifier.padding(horizontal = 16.dp)) {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
dropdownMenuItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkDirect() {
|
||||
SimpleXTheme {
|
||||
@@ -669,10 +585,7 @@ fun PreviewChatListNavLinkDirect() {
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
stopped = false,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION
|
||||
@@ -686,11 +599,12 @@ fun PreviewChatListNavLinkDirect() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkGroup() {
|
||||
SimpleXTheme {
|
||||
@@ -709,10 +623,7 @@ fun PreviewChatListNavLinkGroup() {
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
stopped = false,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION
|
||||
@@ -726,17 +637,18 @@ fun PreviewChatListNavLinkGroup() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkContactRequest() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = {
|
||||
ContactRequestView(ChatInfo.ContactRequest.sampleData)
|
||||
ContactRequestView(false, ChatInfo.ContactRequest.sampleData)
|
||||
},
|
||||
click = {},
|
||||
dropdownMenuItems = null,
|
||||
@@ -0,0 +1,225 @@
|
||||
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.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.NewChatSheet
|
||||
import chat.simplex.app.views.usersettings.SettingsView
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
||||
val newChatSheetState by rememberSaveable(stateSaver = NewChatSheetState.saver()) { mutableStateOf(MutableStateFlow(NewChatSheetState.GONE)) }
|
||||
val showNewChatSheet = {
|
||||
newChatSheetState.value = NewChatSheetState.VISIBLE
|
||||
}
|
||||
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
|
||||
if (animated) newChatSheetState.value = NewChatSheetState.HIDING
|
||||
else newChatSheetState.value = NewChatSheetState.GONE
|
||||
}
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
|
||||
}
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
Scaffold(
|
||||
topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, stopped) { searchInList = it.trim() } },
|
||||
scaffoldState = scaffoldState,
|
||||
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
||||
floatingActionButton = {
|
||||
if (searchInList.isEmpty()) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (!stopped) {
|
||||
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
|
||||
}
|
||||
},
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 0.dp,
|
||||
pressedElevation = 0.dp,
|
||||
hoveredElevation = 0.dp,
|
||||
focusedElevation = 0.dp,
|
||||
),
|
||||
backgroundColor = if (!stopped) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) Icons.Default.Edit else Icons.Default.Close, stringResource(R.string.add_contact_or_create_group))
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(Modifier.padding(it)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
if (chatModel.chats.isNotEmpty()) {
|
||||
ChatList(chatModel, search = searchInList)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||
OnboardingButtons(showNewChatSheet)
|
||||
}
|
||||
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (searchInList.isEmpty()) {
|
||||
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
ConnectButton(generalGetString(R.string.chat_with_developers)) {
|
||||
uriHandler.openUri(simplexTeamUri)
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatSheet)
|
||||
val color = MaterialTheme.colors.primary
|
||||
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
|
||||
val trianglePath = Path().apply {
|
||||
moveTo(0.dp.toPx(), 0f)
|
||||
lineTo(16.dp.toPx(), 0.dp.toPx())
|
||||
lineTo(8.dp.toPx(), 10.dp.toPx())
|
||||
lineTo(0.dp.toPx(), 0.dp.toPx())
|
||||
}
|
||||
drawPath(
|
||||
color = color,
|
||||
path = trianglePath
|
||||
)
|
||||
})
|
||||
Spacer(Modifier.height(62.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectButton(text: String, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick,
|
||||
shape = RoundedCornerShape(21.dp),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
elevation = null,
|
||||
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
|
||||
modifier = Modifier.height(42.dp)
|
||||
) {
|
||||
Text(text, color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
||||
if (showSearch) {
|
||||
BackHandler(onBack = hideSearchOnBack)
|
||||
}
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
if (chatModel.chats.size >= 8) {
|
||||
barButtons.add {
|
||||
IconButton({ showSearch = true }) {
|
||||
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stopped) {
|
||||
barButtons.add {
|
||||
IconButton(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
||||
)
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Filled.Report,
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
tint = Color.Red,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
DefaultTopAppBar(
|
||||
navigationButton = {
|
||||
if (showSearch)
|
||||
NavigationButtonBack(hideSearchOnBack)
|
||||
else
|
||||
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(R.string.your_chats),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (chatModel.incognito.value) {
|
||||
Icon(
|
||||
Icons.Filled.TheaterComedy,
|
||||
stringResource(R.string.incognito),
|
||||
tint = Indigo,
|
||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTitleClick = null,
|
||||
showSearch = showSearch,
|
||||
onSearchValueChanged = onSearchValueChanged,
|
||||
buttons = barButtons
|
||||
)
|
||||
Divider(Modifier.padding(top = AppBarHeight))
|
||||
}
|
||||
|
||||
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 chats by remember(search) { derivedStateOf { if (search.isEmpty()) chatModel.chats else chatModel.chats.filter(filter) } }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
listState
|
||||
) {
|
||||
items(chats) { chat ->
|
||||
ChatListNavLinkView(chat, chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.MarkdownText
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean, linkMode: SimplexLinkMode) {
|
||||
val cInfo = chat.chatInfo
|
||||
|
||||
@Composable
|
||||
fun groupInactiveIcon() {
|
||||
Icon(
|
||||
Icons.Filled.Cancel,
|
||||
stringResource(R.string.icon_descr_group_inactive),
|
||||
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewImageOverlayIcon() {
|
||||
if (cInfo is ChatInfo.Group) {
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemLeft -> groupInactiveIcon()
|
||||
GroupMemberStatus.MemRemoved -> groupInactiveIcon()
|
||||
GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewTitleText(color: Color = Color.Unspecified) {
|
||||
Text(
|
||||
cInfo.chatViewName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewTitle() {
|
||||
when (cInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
|
||||
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight)
|
||||
else -> chatPreviewTitleText()
|
||||
}
|
||||
else -> chatPreviewTitleText()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewText(chatModelIncognito: Boolean) {
|
||||
val ci = chat.chatItems.lastOrNull()
|
||||
if (ci != null) {
|
||||
MarkdownText(
|
||||
if (!ci.meta.itemDeleted) ci.text else generalGetString(R.string.marked_deleted_description),
|
||||
if (!ci.meta.itemDeleted) ci.formattedText else null,
|
||||
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
|
||||
linkMode = linkMode,
|
||||
senderBold = true,
|
||||
metaText = null,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
when (cInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
if (!cInfo.ready) {
|
||||
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
|
||||
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight)
|
||||
else -> {}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
ChatInfoImage(cInfo, size = 72.dp)
|
||||
Box(Modifier.padding(end = 6.dp, bottom = 6.dp)) {
|
||||
chatPreviewImageOverlayIcon()
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1F)
|
||||
) {
|
||||
chatPreviewTitle()
|
||||
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||
Row(Modifier.heightIn(min = height)) {
|
||||
chatPreviewText(chatModelIncognito)
|
||||
}
|
||||
}
|
||||
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
Text(
|
||||
ts,
|
||||
color = HighOrLowlight,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
val n = chat.chatStats.unreadCount
|
||||
val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group)
|
||||
if (n > 0 || chat.chatStats.unreadChat) {
|
||||
Box(
|
||||
Modifier.padding(top = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
if (n > 0) unreadCountStr(n) else "",
|
||||
color = MaterialTheme.colors.onPrimary,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.background(if (stopped || showNtfsIcon) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.badgeLayout()
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
} else if (showNtfsIcon) {
|
||||
Box(
|
||||
Modifier.padding(top = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.NotificationsOff,
|
||||
contentDescription = generalGetString(R.string.notifications),
|
||||
tint = HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp)
|
||||
.size(17.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
Box(
|
||||
Modifier.padding(top = 52.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ChatStatusImage(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
|
||||
return if (groupInfo.membership.memberIncognito)
|
||||
String.format(stringResource(R.string.group_preview_join_as), groupInfo.membership.memberProfile.displayName)
|
||||
else if (chatModelIncognito)
|
||||
String.format(stringResource(R.string.group_preview_join_as), currentUserProfileDisplayName ?: "")
|
||||
else
|
||||
stringResource(R.string.group_preview_you_are_invited)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun unreadCountStr(n: Int): String {
|
||||
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatStatusImage(chat: Chat) {
|
||||
val s = chat.serverInfo.networkStatus
|
||||
val descr = s.statusString
|
||||
if (s is Chat.NetworkStatus.Error) {
|
||||
Icon(
|
||||
Icons.Outlined.ErrorOutline,
|
||||
contentDescription = descr,
|
||||
tint = HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(19.dp)
|
||||
)
|
||||
} else if (s !is Chat.NetworkStatus.Connected) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(15.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 1.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
ChatPreviewView(Chat.sampleData, false, "", stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -11,17 +13,15 @@ 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.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) Icons.Outlined.AddLink else Icons.Outlined.Link)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -34,27 +34,22 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.secondary
|
||||
color = HighOrLowlight
|
||||
)
|
||||
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||
Text(contactConnection.description, Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.TopEnd
|
||||
val ts = getTimestampText(contactConnection.updatedAt)
|
||||
Column(
|
||||
Modifier.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
val ts = getTimestampText(contactConnection.updatedAt)
|
||||
Text(
|
||||
ts,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
color = HighOrLowlight,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
Box(
|
||||
Modifier.padding(top = 50.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
IncognitoIcon(contactConnection.incognito)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,19 +6,18 @@ 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(contactRequest: ChatInfo.ContactRequest) {
|
||||
fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) {
|
||||
Row {
|
||||
ChatInfoImage(contactRequest, size = 72.dp)
|
||||
Column(
|
||||
@@ -32,18 +31,19 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.primary
|
||||
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(
|
||||
Modifier.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
ts,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
color = HighOrLowlight,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
@@ -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) {
|
||||
@@ -29,7 +29,7 @@ fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||
stopped
|
||||
)
|
||||
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection, is ChatInfo.InvalidJSON -> {}
|
||||
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.Indigo
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
Scaffold(
|
||||
topBar = { Column { ShareListToolbar(chatModel, stopped) { searchInList = it.trim() } } },
|
||||
) {
|
||||
Box(Modifier.padding(it)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
if (chatModel.chats.isNotEmpty()) {
|
||||
ShareList(chatModel, search = searchInList)
|
||||
} else {
|
||||
EmptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyList() {
|
||||
Box {
|
||||
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
||||
if (showSearch) {
|
||||
BackHandler(onBack = hideSearchOnBack)
|
||||
}
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
if (chatModel.chats.size >= 8) {
|
||||
barButtons.add {
|
||||
IconButton({ showSearch = true }) {
|
||||
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stopped) {
|
||||
barButtons.add {
|
||||
IconButton(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
||||
)
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Filled.Report,
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
tint = Color.Red,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DefaultTopAppBar(
|
||||
navigationButton = { if (showSearch) NavigationButtonBack(hideSearchOnBack) else NavigationButtonBack { chatModel.sharedContent.value = null } },
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
when (chatModel.sharedContent.value) {
|
||||
is SharedContent.Text -> stringResource(R.string.share_message)
|
||||
is SharedContent.Images -> stringResource(R.string.share_image)
|
||||
is SharedContent.File -> stringResource(R.string.share_file)
|
||||
else -> stringResource(R.string.share_message)
|
||||
},
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (chatModel.incognito.value) {
|
||||
Icon(
|
||||
Icons.Filled.TheaterComedy,
|
||||
stringResource(R.string.incognito),
|
||||
tint = Indigo,
|
||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTitleClick = null,
|
||||
showSearch = showSearch,
|
||||
onSearchValueChanged = onSearchValueChanged,
|
||||
buttons = barButtons
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareList(chatModel: ChatModel, search: String) {
|
||||
val filter: (Chat) -> Boolean = { chat: Chat ->
|
||||
chat.chatInfo.chatViewName.lowercase().contains(search.lowercase())
|
||||
}
|
||||
val chats by remember(search) {
|
||||
derivedStateOf {
|
||||
if (search.isEmpty()) chatModel.chats.filter { it.chatInfo.ready } else chatModel.chats.filter { it.chatInfo.ready }.filter(filter)
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(chats) { chat ->
|
||||
ShareListNavLinkView(chat, chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionDivider
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.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(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(title)
|
||||
SectionView(stringResource(R.string.chat_archive_section)) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.IosShare,
|
||||
stringResource(R.string.save_archive),
|
||||
saveArchive,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
SectionDivider()
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@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,6 +1,5 @@
|
||||
package chat.simplex.common.views.database
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionTextFooter
|
||||
@@ -12,26 +11,27 @@ import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.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.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.platform.LocalDensity
|
||||
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 kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.log2
|
||||
|
||||
@@ -41,9 +41,9 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
||||
val prefs = m.controller.appPrefs
|
||||
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
||||
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
|
||||
val storedKey = remember { val key = DatabaseUtils.ksDatabasePassword.get(); mutableStateOf(key != null && key != "") }
|
||||
val storedKey = remember { val key = DatabaseUtils.getDatabaseKey(); mutableStateOf(key != null && key != "") }
|
||||
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
|
||||
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") }
|
||||
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.getDatabaseKey() ?: "" else "") }
|
||||
val newKey = rememberSaveable { mutableStateOf("") }
|
||||
val confirmNewKey = rememberSaveable { mutableStateOf("") }
|
||||
|
||||
@@ -72,14 +72,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}"
|
||||
)
|
||||
}
|
||||
@@ -88,17 +88,17 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
||||
prefs.initialRandomDBPassphrase.set(false)
|
||||
initialRandomDBPassphrase.value = false
|
||||
if (useKeychain.value) {
|
||||
DatabaseUtils.ksDatabasePassword.set(newKey.value)
|
||||
DatabaseUtils.setDatabaseKey(newKey.value)
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
@@ -136,19 +136,20 @@ fun DatabaseEncryptionLayout(
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
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()
|
||||
DatabaseUtils.removeDatabaseKey()
|
||||
setUseKeychain(false, useKeychain, prefs)
|
||||
storedKey.value = false
|
||||
},
|
||||
@@ -160,18 +161,18 @@ fun DatabaseEncryptionLayout(
|
||||
}
|
||||
|
||||
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
|
||||
PassphraseField(
|
||||
DatabaseKeyField(
|
||||
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) }),
|
||||
)
|
||||
}
|
||||
|
||||
PassphraseField(
|
||||
DatabaseKeyField(
|
||||
newKey,
|
||||
generalGetString(MR.strings.new_passphrase),
|
||||
generalGetString(R.string.new_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
showStrength = true,
|
||||
isValid = ::validKey,
|
||||
@@ -200,9 +201,9 @@ fun DatabaseEncryptionLayout(
|
||||
!validKey(newKey.value) ||
|
||||
progressIndicator.value
|
||||
|
||||
PassphraseField(
|
||||
DatabaseKeyField(
|
||||
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,49 +212,48 @@ 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)
|
||||
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled) {
|
||||
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight 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(annotatedStringResource(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(annotatedStringResource(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()
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
destructive = false,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -261,9 +261,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,
|
||||
)
|
||||
@@ -271,9 +271,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,
|
||||
)
|
||||
@@ -285,26 +285,29 @@ fun SavePassphraseSetting(
|
||||
initialRandomDBPassphrase: Boolean,
|
||||
storedKey: Boolean,
|
||||
progressIndicator: Boolean,
|
||||
minHeight: Dp = TextFieldDefaults.MinHeight,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
SectionItemView(minHeight = minHeight) {
|
||||
SectionItemView {
|
||||
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),
|
||||
tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary
|
||||
if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff,
|
||||
stringResource(R.string.save_passphrase_in_keychain),
|
||||
tint = if (storedKey) SimplexGreen else HighOrLowlight
|
||||
)
|
||||
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
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
DefaultSwitch(
|
||||
Switch(
|
||||
checked = useKeychain,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
enabled = !initialRandomDBPassphrase && !progressIndicator
|
||||
)
|
||||
}
|
||||
@@ -334,9 +337,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
|
||||
@@ -346,22 +349,21 @@ private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun PassphraseField(
|
||||
fun DatabaseKeyField(
|
||||
key: MutableState<String>,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showStrength: Boolean = false,
|
||||
isValid: (String) -> Boolean,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
dependsOn: State<Any?>? = null,
|
||||
) {
|
||||
var valid by remember { mutableStateOf(validKey(key.value)) }
|
||||
var showKey by remember { mutableStateOf(false) }
|
||||
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) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
|
||||
} else Icons.Outlined.Error
|
||||
val iconColor = if (valid) {
|
||||
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else MaterialTheme.colors.secondary
|
||||
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else HighOrLowlight
|
||||
} else Color.Red
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val keyboardOptions = KeyboardOptions(
|
||||
@@ -418,7 +420,7 @@ fun PassphraseField(
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = state.value.text,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary) },
|
||||
placeholder = { Text(placeholder, color = HighOrLowlight) },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
isError = !valid,
|
||||
@@ -434,13 +436,6 @@ fun PassphraseField(
|
||||
)
|
||||
}
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { dependsOn?.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
valid = isValid(state.value.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// based on https://generatepasswords.org/how-to-calculate-entropy/
|
||||
@@ -466,7 +461,7 @@ private fun passphraseEntropy(s: String): Double {
|
||||
return s.length * log2(poolSize.toDouble())
|
||||
}
|
||||
|
||||
enum class PassphraseStrength(val color: Color) {
|
||||
private enum class PassphraseStrength(val color: Color) {
|
||||
VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
|
||||
|
||||
companion object {
|
||||
@@ -498,7 +493,7 @@ fun PreviewDatabaseEncryptionLayout() {
|
||||
SimpleXTheme {
|
||||
DatabaseEncryptionLayout(
|
||||
useKeychain = remember { mutableStateOf(true) },
|
||||
prefs = AppPreferences(),
|
||||
prefs = AppPreferences(SimplexApp.context),
|
||||
chatDbEncrypted = true,
|
||||
currentKey = remember { mutableStateOf("") },
|
||||
newKey = remember { mutableStateOf("") },
|
||||
@@ -509,4 +504,4 @@ fun PreviewDatabaseEncryptionLayout() {
|
||||
onConfirmEncrypt = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.NotificationsMode
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import kotlin.io.path.Path
|
||||
|
||||
@Composable
|
||||
fun DatabaseErrorView(
|
||||
chatDbStatus: State<DBMigrationResult?>,
|
||||
appPreferences: AppPreferences,
|
||||
) {
|
||||
val progressIndicator = remember { mutableStateOf(false) }
|
||||
val dbKey = remember { mutableStateOf("") }
|
||||
var storedDBKey by remember { mutableStateOf(DatabaseUtils.getDatabaseKey()) }
|
||||
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
|
||||
val context = LocalContext.current
|
||||
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
|
||||
val saveAndRunChatOnClick: () -> Unit = {
|
||||
DatabaseUtils.setDatabaseKey(dbKey.value)
|
||||
storedDBKey = dbKey.value
|
||||
appPreferences.storeDBPassphrase.set(true)
|
||||
useKeychain = true
|
||||
appPreferences.initialRandomDBPassphrase.set(false)
|
||||
runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
|
||||
}
|
||||
val title = when (chatDbStatus.value) {
|
||||
is DBMigrationResult.OK -> ""
|
||||
is DBMigrationResult.ErrorNotADatabase -> if (useKeychain && !storedDBKey.isNullOrEmpty())
|
||||
generalGetString(R.string.wrong_passphrase)
|
||||
else
|
||||
generalGetString(R.string.encrypted_database)
|
||||
is DBMigrationResult.Error -> generalGetString(R.string.database_error)
|
||||
is DBMigrationResult.ErrorKeychain -> generalGetString(R.string.keychain_error)
|
||||
is DBMigrationResult.Unknown -> generalGetString(R.string.database_error)
|
||||
null -> "" // should never be here
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
Modifier.padding(start = 16.dp, top = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
|
||||
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
|
||||
when (val status = chatDbStatus.value) {
|
||||
is DBMigrationResult.ErrorNotADatabase -> {
|
||||
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
|
||||
Text(generalGetString(R.string.passphrase_is_different))
|
||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
||||
saveAndRunChatOnClick()
|
||||
}
|
||||
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
|
||||
SectionSpacer()
|
||||
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
|
||||
} else {
|
||||
Text(generalGetString(R.string.database_passphrase_is_required))
|
||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
||||
if (useKeychain) saveAndRunChatOnClick() else runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
|
||||
}
|
||||
if (useKeychain) {
|
||||
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
|
||||
} else {
|
||||
OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) }
|
||||
}
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.Error -> {
|
||||
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
|
||||
Text(String.format(generalGetString(R.string.error_with_info), status.migrationError))
|
||||
}
|
||||
is DBMigrationResult.ErrorKeychain -> {
|
||||
Text(generalGetString(R.string.cannot_access_keychain))
|
||||
}
|
||||
is DBMigrationResult.Unknown -> {
|
||||
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
|
||||
}
|
||||
is DBMigrationResult.OK -> {
|
||||
}
|
||||
null -> {
|
||||
}
|
||||
}
|
||||
if (restoreDbFromBackup.value) {
|
||||
SectionSpacer()
|
||||
Text(generalGetString(R.string.database_backup_can_be_restored))
|
||||
Spacer(Modifier.size(16.dp))
|
||||
RestoreDbButton {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.restore_database_alert_title),
|
||||
text = generalGetString(R.string.restore_database_alert_desc),
|
||||
confirmText = generalGetString(R.string.restore_database_alert_confirm),
|
||||
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (progressIndicator.value) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runChat(
|
||||
dbKey: String,
|
||||
chatDbStatus: State<DBMigrationResult?>,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
prefs: AppPreferences
|
||||
) = CoroutineScope(Dispatchers.Default).launch {
|
||||
// Don't do things concurrently. Shouldn't be here concurrently, just in case
|
||||
if (progressIndicator.value) return@launch
|
||||
progressIndicator.value = true
|
||||
try {
|
||||
SimplexApp.context.initChatController(dbKey)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
|
||||
}
|
||||
progressIndicator.value = false
|
||||
when (val status = chatDbStatus.value) {
|
||||
is DBMigrationResult.OK -> {
|
||||
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(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
|
||||
}
|
||||
is DBMigrationResult.Error -> {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationError)
|
||||
}
|
||||
is DBMigrationResult.ErrorKeychain -> {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.keychain_error))
|
||||
}
|
||||
is DBMigrationResult.Unknown -> {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), status.json)
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
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(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, 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(R.string.database_restore_error), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
|
||||
DatabaseKeyField(
|
||||
text,
|
||||
generalGetString(R.string.enter_passphrase),
|
||||
isValid = ::validKey,
|
||||
keyboardActions = KeyboardActions(onDone = if (enabled) {
|
||||
{ onClick?.invoke() }
|
||||
} else null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.SaveAndOpenButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
|
||||
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(R.string.open_chat))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.RestoreDbButton(onClick: () -> Unit) {
|
||||
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally)) {
|
||||
Text(generalGetString(R.string.restore_database), color = MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatInfoLayout() {
|
||||
SimpleXTheme {
|
||||
DatabaseErrorView(
|
||||
remember { mutableStateOf(DBMigrationResult.ErrorNotADatabase("simplex_v1_chat.db")) },
|
||||
AppPreferences(SimplexApp.context)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,44 @@
|
||||
package chat.simplex.common.views.database
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionDivider
|
||||
import SectionTextFooter
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.FileUtils
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.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.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.platform.*
|
||||
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.usersettings.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.*
|
||||
import java.io.*
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
@@ -38,28 +48,23 @@ fun DatabaseView(
|
||||
m: ChatModel,
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val progressIndicator = remember { mutableStateOf(false) }
|
||||
val runChat = remember { m.chatRunning }
|
||||
val runChat = remember { mutableStateOf(m.chatRunning.value ?: true) }
|
||||
val prefs = m.controller.appPrefs
|
||||
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
||||
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
|
||||
val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) }
|
||||
val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) }
|
||||
val chatArchiveFile = remember { mutableStateOf<String?>(null) }
|
||||
val saveArchiveLauncher = rememberFileChooserLauncher(false) { to: URI? ->
|
||||
val file = chatArchiveFile.value
|
||||
if (file != null && to != null) {
|
||||
copyFileToFile(File(file), to) {
|
||||
chatArchiveFile.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) }
|
||||
val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? ->
|
||||
if (to != null) {
|
||||
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator)
|
||||
val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, chatArchiveFile)
|
||||
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory(context))) }
|
||||
val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
importArchiveAlert(m, context, uri, appFilesCountAndSize, progressIndicator)
|
||||
}
|
||||
}
|
||||
val chatDbDeleted = remember { m.chatDbDeleted }
|
||||
LaunchedEffect(m.chatRunning) {
|
||||
runChat.value = m.chatRunning.value ?: true
|
||||
}
|
||||
@@ -69,7 +74,7 @@ fun DatabaseView(
|
||||
) {
|
||||
DatabaseLayout(
|
||||
progressIndicator.value,
|
||||
runChat.value != false,
|
||||
runChat.value,
|
||||
m.chatDbChanged.value,
|
||||
useKeychain.value,
|
||||
m.chatDbEncrypted.value,
|
||||
@@ -78,22 +83,22 @@ fun DatabaseView(
|
||||
chatArchiveName,
|
||||
chatArchiveTime,
|
||||
chatLastStart,
|
||||
chatDbDeleted.value,
|
||||
m.controller.appPrefs.privacyFullBackup,
|
||||
appFilesCountAndSize,
|
||||
chatItemTTL,
|
||||
m.currentUser.value,
|
||||
m.users,
|
||||
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
|
||||
stopChatAlert = { stopChatAlert(m, runChat) },
|
||||
exportArchive = { exportArchive(m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
|
||||
stopChatAlert = { stopChatAlert(m, runChat, context) },
|
||||
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
|
||||
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
|
||||
deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(appFilesCountAndSize) },
|
||||
deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(context, appFilesCountAndSize) },
|
||||
onChatItemTTLSelected = {
|
||||
val oldValue = chatItemTTL.value
|
||||
chatItemTTL.value = it
|
||||
if (it < oldValue) {
|
||||
setChatItemTTLAlert(m, chatItemTTL, progressIndicator, appFilesCountAndSize)
|
||||
setChatItemTTLAlert(m, chatItemTTL, progressIndicator, appFilesCountAndSize, context)
|
||||
} else if (it != oldValue) {
|
||||
setCiTTL(m, chatItemTTL, progressIndicator, appFilesCountAndSize)
|
||||
setCiTTL(m, chatItemTTL, progressIndicator, appFilesCountAndSize, context)
|
||||
}
|
||||
},
|
||||
showSettingsModal
|
||||
@@ -107,7 +112,7 @@ fun DatabaseView(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
@@ -123,14 +128,14 @@ fun DatabaseLayout(
|
||||
useKeyChain: Boolean,
|
||||
chatDbEncrypted: Boolean?,
|
||||
initialRandomDBPassphrase: SharedPreference<Boolean>,
|
||||
importArchiveLauncher: FileChooserLauncher,
|
||||
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
|
||||
chatArchiveName: MutableState<String?>,
|
||||
chatArchiveTime: MutableState<Instant?>,
|
||||
chatLastStart: MutableState<Instant?>,
|
||||
chatDbDeleted: Boolean,
|
||||
privacyFullBackup: SharedPreference<Boolean>,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
chatItemTTL: MutableState<ChatItemTTL>,
|
||||
currentUser: User?,
|
||||
users: List<UserInfo>,
|
||||
startChat: () -> Unit,
|
||||
stopChatAlert: () -> Unit,
|
||||
exportArchive: () -> Unit,
|
||||
@@ -144,50 +149,29 @@ fun DatabaseLayout(
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.your_chat_database))
|
||||
|
||||
SectionView(stringResource(MR.strings.messages_section_title).uppercase()) {
|
||||
TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected)
|
||||
AppBarTitle(stringResource(R.string.your_chat_database))
|
||||
SectionView(stringResource(R.string.run_chat_section)) {
|
||||
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
|
||||
}
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.messages_section_description) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(currentUser?.displayName ?: "")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
}
|
||||
)
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(MR.strings.run_chat_section)) {
|
||||
RunChatSetting(runChat, stopped, startChat, stopChatAlert)
|
||||
}
|
||||
SectionTextFooter(
|
||||
if (stopped) {
|
||||
stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)
|
||||
} else {
|
||||
stringResource(MR.strings.stop_chat_to_enable_database_actions)
|
||||
}
|
||||
)
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.chat_database_section)) {
|
||||
SectionView(stringResource(R.string.chat_database_section)) {
|
||||
val unencrypted = chatDbEncrypted == false
|
||||
SettingsActionItem(
|
||||
if (unencrypted) painterResource(MR.images.ic_lock_open) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
|
||||
else painterResource(MR.images.ic_lock),
|
||||
stringResource(MR.strings.database_passphrase),
|
||||
if (unencrypted) Icons.Outlined.LockOpen else if (useKeyChain) Icons.Filled.VpnKey else Icons.Outlined.Lock,
|
||||
stringResource(R.string.database_passphrase),
|
||||
click = showSettingsModal() { DatabaseEncryptionView(it) },
|
||||
iconColor = if (unencrypted) WarningOrange else MaterialTheme.colors.secondary,
|
||||
iconColor = if (unencrypted) WarningOrange else HighOrLowlight,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.Backup, stringResource(R.string.full_backup), privacyFullBackup)
|
||||
SectionDivider()
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_ios_share),
|
||||
stringResource(MR.strings.export_database),
|
||||
Icons.Outlined.IosShare,
|
||||
stringResource(R.string.export_database),
|
||||
click = {
|
||||
if (initialRandomDBPassphrase.get()) {
|
||||
exportProhibitedAlert()
|
||||
@@ -199,58 +183,69 @@ fun DatabaseLayout(
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SectionDivider()
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_download),
|
||||
stringResource(MR.strings.import_database),
|
||||
{ withApi { importArchiveLauncher.launch("application/zip") }},
|
||||
Icons.Outlined.FileDownload,
|
||||
stringResource(R.string.import_database),
|
||||
{ importArchiveLauncher.launch("application/zip") },
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SectionDivider()
|
||||
val chatArchiveNameVal = chatArchiveName.value
|
||||
val chatArchiveTimeVal = chatArchiveTime.value
|
||||
val chatLastStartVal = chatLastStart.value
|
||||
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
|
||||
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_inventory_2),
|
||||
Icons.Outlined.Inventory2,
|
||||
title,
|
||||
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SectionDivider()
|
||||
}
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_delete_forever),
|
||||
stringResource(MR.strings.delete_database),
|
||||
Icons.Outlined.DeleteForever,
|
||||
stringResource(R.string.delete_database),
|
||||
deleteChatAlert,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
}
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionTextFooter(
|
||||
if (stopped) {
|
||||
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
|
||||
} else {
|
||||
stringResource(R.string.stop_chat_to_enable_database_actions)
|
||||
}
|
||||
)
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) {
|
||||
SectionView(stringResource(R.string.data_section)) {
|
||||
SectionItemView { TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!progressIndicator && !chatDbChanged), onChatItemTTLSelected) }
|
||||
SectionDivider()
|
||||
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
|
||||
SectionItemView(
|
||||
deleteAppFilesAndMedia,
|
||||
disabled = deleteFilesDisabled
|
||||
) {
|
||||
Text(
|
||||
stringResource(if (users.size > 1) MR.strings.delete_files_and_media_for_all_users else MR.strings.delete_files_and_media_all),
|
||||
color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red
|
||||
stringResource(R.string.delete_files_and_media),
|
||||
color = if (deleteFilesDisabled) HighOrLowlight else Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
val (count, size) = appFilesCountAndSize.value
|
||||
SectionTextFooter(
|
||||
if (count == 0) {
|
||||
stringResource(MR.strings.no_received_app_files)
|
||||
stringResource(R.string.no_received_app_files)
|
||||
} else {
|
||||
String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size))
|
||||
String.format(stringResource(R.string.total_files_count_and_size), count, formatBytes(size))
|
||||
}
|
||||
)
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,14 +253,14 @@ private fun setChatItemTTLAlert(
|
||||
m: ChatModel, selectedChatItemTTL: MutableState<ChatItemTTL>,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
context: Context
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.enable_automatic_deletion_question),
|
||||
text = generalGetString(MR.strings.enable_automatic_deletion_message),
|
||||
confirmText = generalGetString(MR.strings.delete_messages),
|
||||
onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize) },
|
||||
onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value },
|
||||
destructive = true,
|
||||
title = generalGetString(R.string.enable_automatic_deletion_question),
|
||||
text = generalGetString(R.string.enable_automatic_deletion_message),
|
||||
confirmText = generalGetString(R.string.delete_messages),
|
||||
onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize, context) },
|
||||
onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -278,16 +273,16 @@ private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onS
|
||||
}
|
||||
all.map {
|
||||
when (it) {
|
||||
is ChatItemTTL.None -> it to generalGetString(MR.strings.chat_item_ttl_none)
|
||||
is ChatItemTTL.Day -> it to generalGetString(MR.strings.chat_item_ttl_day)
|
||||
is ChatItemTTL.Week -> it to generalGetString(MR.strings.chat_item_ttl_week)
|
||||
is ChatItemTTL.Month -> it to generalGetString(MR.strings.chat_item_ttl_month)
|
||||
is ChatItemTTL.Seconds -> it to String.format(generalGetString(MR.strings.chat_item_ttl_seconds), it.secs)
|
||||
is ChatItemTTL.None -> it to generalGetString(R.string.chat_item_ttl_none)
|
||||
is ChatItemTTL.Day -> it to generalGetString(R.string.chat_item_ttl_day)
|
||||
is ChatItemTTL.Week -> it to generalGetString(R.string.chat_item_ttl_week)
|
||||
is ChatItemTTL.Month -> it to generalGetString(R.string.chat_item_ttl_month)
|
||||
is ChatItemTTL.Seconds -> it to String.format(generalGetString(R.string.chat_item_ttl_seconds), it.secs)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(MR.strings.delete_messages_after),
|
||||
generalGetString(R.string.delete_messages_after),
|
||||
values,
|
||||
current,
|
||||
icon = null,
|
||||
@@ -300,146 +295,151 @@ private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onS
|
||||
fun RunChatSetting(
|
||||
runChat: Boolean,
|
||||
stopped: Boolean,
|
||||
chatDbDeleted: Boolean,
|
||||
startChat: () -> Unit,
|
||||
stopChatAlert: () -> Unit
|
||||
) {
|
||||
val chatRunningText = if (stopped) stringResource(MR.strings.chat_is_stopped) else stringResource(MR.strings.chat_is_running)
|
||||
SettingsActionItemWithContent(
|
||||
icon = if (stopped) painterResource(MR.images.ic_report_filled) else painterResource(MR.images.ic_play_arrow_filled),
|
||||
text = chatRunningText,
|
||||
iconColor = if (stopped) Color.Red else MaterialTheme.colors.primary,
|
||||
) {
|
||||
DefaultSwitch(
|
||||
checked = runChat,
|
||||
onCheckedChange = { runChatSwitch ->
|
||||
if (runChatSwitch) {
|
||||
startChat()
|
||||
} else {
|
||||
stopChatAlert()
|
||||
}
|
||||
},
|
||||
)
|
||||
SectionItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
|
||||
Icon(
|
||||
if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow,
|
||||
chatRunningText,
|
||||
tint = if (stopped) Color.Red else MaterialTheme.colors.primary
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
chatRunningText,
|
||||
Modifier.padding(end = 24.dp)
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
enabled = !chatDbDeleted,
|
||||
checked = runChat,
|
||||
onCheckedChange = { runChatSwitch ->
|
||||
if (runChatSwitch) {
|
||||
startChat()
|
||||
} else {
|
||||
stopChatAlert()
|
||||
}
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
|
||||
return stringResource(if (chatArchiveTime < chatLastStart) MR.strings.old_database_archive else MR.strings.new_database_archive)
|
||||
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
|
||||
}
|
||||
|
||||
private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
|
||||
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
|
||||
withApi {
|
||||
try {
|
||||
if (chatDbChanged.value) {
|
||||
initChatController()
|
||||
SimplexApp.context.initChatController()
|
||||
chatDbChanged.value = false
|
||||
}
|
||||
if (m.chatDbStatus.value !is DBMigrationResult.OK) {
|
||||
/** Hide current view and show [DatabaseErrorView] */
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
ModalManager.shared.closeModals()
|
||||
return@withApi
|
||||
}
|
||||
if (m.currentUser.value == null) {
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
return@withApi
|
||||
} else {
|
||||
m.controller.apiStartChat()
|
||||
runChat.value = true
|
||||
m.chatRunning.value = true
|
||||
}
|
||||
m.controller.apiStartChat()
|
||||
runChat.value = true
|
||||
m.chatRunning.value = true
|
||||
val ts = Clock.System.now()
|
||||
m.controller.appPrefs.chatLastStart.set(ts)
|
||||
chatLastStart.value = ts
|
||||
platform.androidChatStartedAfterBeingOff()
|
||||
when (m.controller.appPrefs.notificationsMode.get()) {
|
||||
NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) }
|
||||
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
|
||||
}
|
||||
} catch (e: Error) {
|
||||
runChat.value = false
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.toString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean?>) {
|
||||
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.stop_chat_question),
|
||||
text = generalGetString(MR.strings.stop_chat_to_export_import_or_delete_chat_database),
|
||||
confirmText = generalGetString(MR.strings.stop_chat_confirmation),
|
||||
onConfirm = { authStopChat(m, runChat) },
|
||||
title = generalGetString(R.string.stop_chat_question),
|
||||
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
|
||||
confirmText = generalGetString(R.string.stop_chat_confirmation),
|
||||
onConfirm = { authStopChat(m, runChat, context) },
|
||||
onDismiss = { runChat.value = true }
|
||||
)
|
||||
}
|
||||
|
||||
private fun exportProhibitedAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.set_password_to_export),
|
||||
text = generalGetString(MR.strings.set_password_to_export_desc),
|
||||
title = generalGetString(R.string.set_password_to_export),
|
||||
text = generalGetString(R.string.set_password_to_export_desc),
|
||||
)
|
||||
}
|
||||
|
||||
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>) {
|
||||
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
||||
if (m.controller.appPrefs.performLA.get()) {
|
||||
authenticate(
|
||||
generalGetString(MR.strings.auth_stop_chat),
|
||||
generalGetString(MR.strings.auth_log_in_using_credential),
|
||||
generalGetString(R.string.auth_stop_chat),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success, is LAResult.Unavailable -> {
|
||||
stopChat(m, runChat)
|
||||
LAResult.Success, LAResult.Unavailable -> {
|
||||
stopChat(m, runChat, context)
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
runChat.value = true
|
||||
}
|
||||
is LAResult.Failed -> {
|
||||
LAResult.Failed -> {
|
||||
runChat.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
stopChat(m, runChat)
|
||||
stopChat(m, runChat, context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean?>) {
|
||||
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
||||
withApi {
|
||||
try {
|
||||
m.controller.apiStopChat()
|
||||
runChat.value = false
|
||||
stopChatAsync(m)
|
||||
platform.androidChatStopped()
|
||||
m.chatRunning.value = false
|
||||
SimplexService.safeStopService(context)
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
} catch (e: Error) {
|
||||
runChat.value = true
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_stopping_chat), e.toString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopChatAsync(m: ChatModel) {
|
||||
m.controller.apiStopChat()
|
||||
m.chatRunning.value = false
|
||||
}
|
||||
|
||||
suspend fun deleteChatAsync(m: ChatModel) {
|
||||
m.controller.apiDeleteStorage()
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
m.controller.appPrefs.storeDBPassphrase.set(true)
|
||||
}
|
||||
|
||||
private fun exportArchive(
|
||||
context: Context,
|
||||
m: ChatModel,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
chatArchiveName: MutableState<String?>,
|
||||
chatArchiveTime: MutableState<Instant?>,
|
||||
chatArchiveFile: MutableState<String?>,
|
||||
saveArchiveLauncher: FileChooserLauncher
|
||||
saveArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>
|
||||
) {
|
||||
progressIndicator.value = true
|
||||
withApi {
|
||||
try {
|
||||
val archiveFile = exportChatArchive(m, chatArchiveName, chatArchiveTime, chatArchiveFile)
|
||||
val archiveFile = exportChatArchive(m, context, chatArchiveName, chatArchiveTime, chatArchiveFile)
|
||||
chatArchiveFile.value = archiveFile
|
||||
saveArchiveLauncher.launch(archiveFile.substringAfterLast(File.separator))
|
||||
saveArchiveLauncher.launch(archiveFile.substringAfterLast("/"))
|
||||
progressIndicator.value = false
|
||||
} catch (e: Error) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_exporting_chat_database), e.toString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_exporting_chat_database), e.toString())
|
||||
progressIndicator.value = false
|
||||
}
|
||||
}
|
||||
@@ -447,6 +447,7 @@ private fun exportArchive(
|
||||
|
||||
private suspend fun exportChatArchive(
|
||||
m: ChatModel,
|
||||
context: Context,
|
||||
chatArchiveName: MutableState<String?>,
|
||||
chatArchiveTime: MutableState<Instant?>,
|
||||
chatArchiveFile: MutableState<String?>
|
||||
@@ -454,10 +455,10 @@ private suspend fun exportChatArchive(
|
||||
val archiveTime = Clock.System.now()
|
||||
val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
|
||||
val archiveName = "simplex-chat.$ts.zip"
|
||||
val archivePath = "${filesDir.absolutePath}${File.separator}$archiveName"
|
||||
val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString())
|
||||
val archivePath = "${getFilesDirectory(context)}/$archiveName"
|
||||
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
|
||||
m.controller.apiExportArchive(config)
|
||||
deleteOldArchive(m)
|
||||
deleteOldArchive(m, context)
|
||||
m.controller.appPrefs.chatArchiveName.set(archiveName)
|
||||
chatArchiveName.value = archiveName
|
||||
m.controller.appPrefs.chatArchiveTime.set(archiveTime)
|
||||
@@ -466,10 +467,10 @@ private suspend fun exportChatArchive(
|
||||
return archivePath
|
||||
}
|
||||
|
||||
private fun deleteOldArchive(m: ChatModel) {
|
||||
private fun deleteOldArchive(m: ChatModel, context: Context) {
|
||||
val chatArchiveName = m.controller.appPrefs.chatArchiveName.get()
|
||||
if (chatArchiveName != null) {
|
||||
val file = File("${filesDir.absolutePath}${File.separator}$chatArchiveName")
|
||||
val file = File("${getFilesDirectory(context)}/$chatArchiveName")
|
||||
val fileDeleted = file.delete()
|
||||
if (fileDeleted) {
|
||||
m.controller.appPrefs.chatArchiveName.set(null)
|
||||
@@ -480,55 +481,79 @@ private fun deleteOldArchive(m: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableState<String?>): ManagedActivityResultLauncher<String, Uri?> =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(),
|
||||
onResult = { destination ->
|
||||
try {
|
||||
destination?.let {
|
||||
val filePath = chatArchiveFile.value
|
||||
if (filePath != null) {
|
||||
val contentResolver = cxt.contentResolver
|
||||
contentResolver.openOutputStream(destination)?.let { stream ->
|
||||
val outputStream = BufferedOutputStream(stream)
|
||||
File(filePath).inputStream().use { it.copyTo(outputStream) }
|
||||
outputStream.close()
|
||||
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(cxt, generalGetString(R.string.file_not_found), 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")
|
||||
} finally {
|
||||
chatArchiveFile.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private fun importArchiveAlert(
|
||||
m: ChatModel,
|
||||
importedArchiveURI: URI,
|
||||
context: Context,
|
||||
importedArchiveUri: Uri,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
progressIndicator: MutableState<Boolean>
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.import_database_question),
|
||||
text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
|
||||
confirmText = generalGetString(MR.strings.import_database_confirmation),
|
||||
onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator) },
|
||||
destructive = true,
|
||||
title = generalGetString(R.string.import_database_question),
|
||||
text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
|
||||
confirmText = generalGetString(R.string.import_database_confirmation),
|
||||
onConfirm = { importArchive(m, context, importedArchiveUri, appFilesCountAndSize, progressIndicator) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun importArchive(
|
||||
m: ChatModel,
|
||||
importedArchiveURI: URI,
|
||||
context: Context,
|
||||
importedArchiveUri: Uri,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
progressIndicator: MutableState<Boolean>
|
||||
) {
|
||||
progressIndicator.value = true
|
||||
val archivePath = saveArchiveFromURI(importedArchiveURI)
|
||||
val archivePath = saveArchiveFromUri(context, importedArchiveUri)
|
||||
if (archivePath != null) {
|
||||
withApi {
|
||||
try {
|
||||
m.controller.apiDeleteStorage()
|
||||
try {
|
||||
val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString())
|
||||
val archiveErrors = m.controller.apiImportArchive(config)
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath)
|
||||
if (archiveErrors.isEmpty()) {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database))
|
||||
}
|
||||
} else {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database) + "\n" + generalGetString(MR.strings.non_fatal_errors_occured_during_import))
|
||||
}
|
||||
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
|
||||
m.controller.apiImportArchive(config)
|
||||
DatabaseUtils.removeDatabaseKey()
|
||||
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
|
||||
}
|
||||
} catch (e: Error) {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_importing_database), e.toString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_importing_database), e.toString())
|
||||
}
|
||||
}
|
||||
} catch (e: Error) {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_deleting_database), e.toString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString())
|
||||
}
|
||||
} finally {
|
||||
File(archivePath).delete()
|
||||
@@ -537,32 +562,31 @@ private fun importArchive(
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveArchiveFromURI(importedArchiveURI: URI): String? {
|
||||
private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): String? {
|
||||
return try {
|
||||
val inputStream = importedArchiveURI.inputStream()
|
||||
val archiveName = getFileName(importedArchiveURI)
|
||||
val inputStream = context.contentResolver.openInputStream(importedArchiveUri)
|
||||
val archiveName = getFileName(context, importedArchiveUri)
|
||||
if (inputStream != null && archiveName != null) {
|
||||
val archivePath = "$databaseExportDir${File.separator}$archiveName"
|
||||
val archivePath = "${context.cacheDir}/$archiveName"
|
||||
val destFile = File(archivePath)
|
||||
Files.copy(inputStream, destFile.toPath())
|
||||
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
archivePath
|
||||
} else {
|
||||
Log.e(TAG, "saveArchiveFromURI null inputStream")
|
||||
Log.e(TAG, "saveArchiveFromUri null inputStream")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "saveArchiveFromURI error: ${e.message}")
|
||||
Log.e(TAG, "saveArchiveFromUri error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteChatAlert(m: ChatModel, progressIndicator: MutableState<Boolean>) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.delete_chat_profile_question),
|
||||
text = generalGetString(MR.strings.delete_chat_profile_action_cannot_be_undone_warning),
|
||||
confirmText = generalGetString(MR.strings.delete_verb),
|
||||
onConfirm = { deleteChat(m, progressIndicator) },
|
||||
destructive = true,
|
||||
title = generalGetString(R.string.delete_chat_profile_question),
|
||||
text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = { deleteChat(m, progressIndicator) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -570,13 +594,16 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
|
||||
progressIndicator.value = true
|
||||
withApi {
|
||||
try {
|
||||
deleteChatAsync(m)
|
||||
m.controller.apiDeleteStorage()
|
||||
m.chatDbDeleted.value = true
|
||||
DatabaseUtils.removeDatabaseKey()
|
||||
m.controller.appPrefs.storeDBPassphrase.set(true)
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_deleted), generalGetString(MR.strings.restart_the_app_to_create_a_new_chat_profile))
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
|
||||
}
|
||||
} catch (e: Error) {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_deleting_database), e.toString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,6 +614,7 @@ private fun setCiTTL(
|
||||
chatItemTTL: MutableState<ChatItemTTL>,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
context: Context
|
||||
) {
|
||||
Log.d(TAG, "DatabaseView setChatItemTTL ${chatItemTTL.value.seconds ?: -1}")
|
||||
progressIndicator.value = true
|
||||
@@ -595,12 +623,12 @@ private fun setCiTTL(
|
||||
m.controller.setChatItemTTL(chatItemTTL.value)
|
||||
// Update model on success
|
||||
m.chatItemTTL.value = chatItemTTL.value
|
||||
afterSetCiTTL(m, progressIndicator, appFilesCountAndSize)
|
||||
afterSetCiTTL(m, progressIndicator, appFilesCountAndSize, context)
|
||||
} catch (e: Exception) {
|
||||
// Rollback to model's value
|
||||
chatItemTTL.value = m.chatItemTTL.value
|
||||
afterSetCiTTL(m, progressIndicator, appFilesCountAndSize)
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_message_deletion), e.stackTraceToString())
|
||||
afterSetCiTTL(m, progressIndicator, appFilesCountAndSize, context)
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_changing_message_deletion), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,9 +637,10 @@ private fun afterSetCiTTL(
|
||||
m: ChatModel,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
context: Context
|
||||
) {
|
||||
progressIndicator.value = false
|
||||
appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath)
|
||||
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
|
||||
withApi {
|
||||
try {
|
||||
val chats = m.controller.apiGetChats()
|
||||
@@ -622,19 +651,19 @@ private fun afterSetCiTTL(
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteFilesAndMediaAlert(appFilesCountAndSize: MutableState<Pair<Int, Long>>) {
|
||||
private fun deleteFilesAndMediaAlert(context: Context, appFilesCountAndSize: MutableState<Pair<Int, Long>>) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.delete_files_and_media_question),
|
||||
text = generalGetString(MR.strings.delete_files_and_media_desc),
|
||||
confirmText = generalGetString(MR.strings.delete_verb),
|
||||
onConfirm = { deleteFiles(appFilesCountAndSize) },
|
||||
title = generalGetString(R.string.delete_files_and_media_question),
|
||||
text = generalGetString(R.string.delete_files_and_media_desc),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = { deleteFiles(appFilesCountAndSize, context) },
|
||||
destructive = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteFiles(appFilesCountAndSize: MutableState<Pair<Int, Long>>) {
|
||||
deleteAppFiles()
|
||||
appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath)
|
||||
private fun deleteFiles(appFilesCountAndSize: MutableState<Pair<Int, Long>>, context: Context) {
|
||||
deleteAppFiles(context)
|
||||
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
|
||||
}
|
||||
|
||||
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
|
||||
@@ -643,11 +672,12 @@ private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean
|
||||
alert.invoke()
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)*/
|
||||
)
|
||||
@Composable
|
||||
fun PreviewDatabaseLayout() {
|
||||
SimpleXTheme {
|
||||
@@ -658,14 +688,14 @@ fun PreviewDatabaseLayout() {
|
||||
useKeyChain = false,
|
||||
chatDbEncrypted = false,
|
||||
initialRandomDBPassphrase = SharedPreference({ true }, {}),
|
||||
importArchiveLauncher = rememberFileChooserLauncher(true) {},
|
||||
importArchiveLauncher = rememberGetContentLauncher {},
|
||||
chatArchiveName = remember { mutableStateOf("dummy_archive") },
|
||||
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
|
||||
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
|
||||
chatDbDeleted = false,
|
||||
privacyFullBackup = SharedPreference({ true }, {}),
|
||||
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
|
||||
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
|
||||
currentUser = User.sampleData,
|
||||
users = listOf(UserInfo.sampleData),
|
||||
startChat = {},
|
||||
stopChatAlert = {},
|
||||
exportArchive = {},
|
||||
@@ -0,0 +1,167 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
|
||||
class AlertManager {
|
||||
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
|
||||
|
||||
fun showAlert(alert: @Composable () -> Unit) {
|
||||
Log.d(TAG, "AlertManager.showAlert")
|
||||
alertViews.add(alert)
|
||||
}
|
||||
|
||||
fun hideAlert() {
|
||||
alertViews.removeLastOrNull()
|
||||
}
|
||||
|
||||
fun showAlertDialogButtons(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
buttons = buttons
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialogButtonsColumn(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
showAlert {
|
||||
Dialog(onDismissRequest = this::hideAlert) {
|
||||
Column(Modifier.background(MaterialTheme.colors.background)) {
|
||||
Text(title, Modifier.padding(DEFAULT_PADDING), fontSize = 18.sp)
|
||||
if (text != null) {
|
||||
Text(text)
|
||||
}
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialog(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok),
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
onDismissRequest: (() -> Unit)? = null,
|
||||
destructive: Boolean = false
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialogStacked(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok),
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
onDismissRequest: (() -> Unit)? = null,
|
||||
destructive: Boolean = false
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
buttons = {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp).padding(top = 16.dp, bottom = 2.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertMsg(
|
||||
title: String, text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertMsg(
|
||||
title: Int,
|
||||
text: Int? = null,
|
||||
confirmText: Int = R.string.ok,
|
||||
onConfirm: (() -> Unit)? = null
|
||||
) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText), onConfirm)
|
||||
|
||||
@Composable
|
||||
fun showInView() {
|
||||
remember { alertViews }.lastOrNull()?.invoke()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared = AlertManager()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatInfo
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
||||
val icon =
|
||||
if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
|
||||
else Icons.Filled.AccountCircle
|
||||
ProfileImage(size, chatInfo.image, icon, iconColor)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
||||
Box(Modifier.size(size)) {
|
||||
Icon(
|
||||
Icons.Filled.TheaterComedy, stringResource(R.string.incognito),
|
||||
modifier = Modifier.size(size).padding(size / 12),
|
||||
iconColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileImage(
|
||||
size: Dp,
|
||||
image: String? = null,
|
||||
icon: ImageVector = Icons.Filled.AccountCircle,
|
||||
color: Color = MaterialTheme.colors.secondary
|
||||
) {
|
||||
Box(Modifier.size(size)) {
|
||||
if (image == null) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
||||
tint = color,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
val imageBitmap = base64ToBitmap(image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
stringResource(R.string.image_descr_profile_image),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(size).padding(size / 12).clip(CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatInfoImage() {
|
||||
SimpleXTheme {
|
||||
ChatInfoImage(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
size = 55.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.newchat.ActionButton
|
||||
|
||||
sealed class AttachmentOption {
|
||||
object TakePhoto: AttachmentOption()
|
||||
object PickImage: AttachmentOption()
|
||||
object PickFile: AttachmentOption()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseAttachmentView(
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
hide: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.onFocusChanged { focusState ->
|
||||
if (!focusState.hasFocus) hide()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
||||
attachmentOption.value = AttachmentOption.TakePhoto
|
||||
hide()
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
||||
attachmentOption.value = AttachmentOption.PickImage
|
||||
hide()
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
||||
attachmentOption.value = AttachmentOption.PickFile
|
||||
hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CloseSheetBar(close: () -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = AppBarHeight)
|
||||
.padding(horizontal = AppBarHorizontalPadding),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.width(TitleInsetWithIcon - AppBarHorizontalPadding)
|
||||
.padding(top = 4.dp), // Like in DefaultAppBar
|
||||
content = { NavigationButtonBack(close) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBarTitle(title: String, withPadding: Boolean = true) {
|
||||
val padding = if (withPadding)
|
||||
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING )
|
||||
else
|
||||
PaddingValues(bottom = DEFAULT_PADDING)
|
||||
Text(
|
||||
title,
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(padding),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewCloseSheetBar() {
|
||||
SimpleXTheme {
|
||||
CloseSheetBar(close = {})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user