mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
297 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b115bda8f5 | |||
| 85ab37ae5d | |||
| dc5f6673a2 | |||
| b78a5931e9 | |||
| 104e1040bf | |||
| 39818b6fbb | |||
| 3523c7ebd7 | |||
| 9670da4646 | |||
| 1e22028189 | |||
| 74421a5f52 | |||
| 8c3c639d7f | |||
| 91460932fe | |||
| f70a203f0e | |||
| 3113aab8a8 | |||
| d0b5f16be6 | |||
| 41a4e13ff6 | |||
| 3ac7d5977b | |||
| 0f0cb234bd | |||
| c14c289ba7 | |||
| c20c7085b0 | |||
| 05122fc476 | |||
| ee5997cdf7 | |||
| 563687f9e4 | |||
| 3d3503498a | |||
| 4b905a80e6 | |||
| fee9b3ae84 | |||
| 4c8bc19182 | |||
| f9be6e6434 | |||
| cf001054c2 | |||
| 54705c07bd | |||
| ca423024c5 | |||
| af17daafaa | |||
| f0b551cffd | |||
| 56727a8672 | |||
| 5fd522c79c | |||
| b66eb5b67f | |||
| 88645cb003 | |||
| fff0659b1e | |||
| 04719ff8df | |||
| 83c1340830 | |||
| 6d06dda645 | |||
| 868f0abaaf | |||
| 3649321b67 | |||
| 135bdf3842 | |||
| 22edd92079 | |||
| 059f1e1e79 | |||
| b263d7e547 | |||
| 7a54351f15 | |||
| f9c691cab1 | |||
| cd6cad9a96 | |||
| e8ad216b26 | |||
| 3808b7415b | |||
| e1454b1445 | |||
| 6e9e6057af | |||
| 575706c7c7 | |||
| 58f6b168e6 | |||
| 841afa1e80 | |||
| 9c5acd609c | |||
| 23212def51 | |||
| 06e46b0bea | |||
| a3bd51a5fa | |||
| 5c49e8f7ea | |||
| ef28215284 | |||
| 7f70fe4d64 | |||
| 05385ce997 | |||
| f0f7226fa5 | |||
| 428d3cdba5 | |||
| dd5e99ea42 | |||
| 628f119151 | |||
| 0e411b0eac | |||
| 7fd4b7edae | |||
| 9cb2542079 | |||
| 07d2c9ff49 | |||
| 6730a687e2 | |||
| 86c782d08b | |||
| fe6cd91a61 | |||
| bf46e0dc57 | |||
| 937492c204 | |||
| c0878769be | |||
| e2c332c230 | |||
| 629c5d7434 | |||
| 090e54c8d8 | |||
| 1a2efc3b4f | |||
| 4e57f7c488 | |||
| 31d8f73eac | |||
| 409982ad71 | |||
| 6c7e81777e | |||
| 4934045f4e | |||
| aeebb89dc2 | |||
| 1246ad2437 | |||
| f46b813235 | |||
| 83b77748b6 | |||
| 4dde46a646 | |||
| b7dd787043 | |||
| bce8af16de | |||
| 96b8f0e979 | |||
| 36f635f6f0 | |||
| d15fec4fab | |||
| 1b435d89f3 | |||
| bbaa45a0e1 | |||
| 5578183777 | |||
| 520800ded0 | |||
| 47a6a81854 | |||
| 2a4b7b83a4 | |||
| 792c442aa3 | |||
| 54b39e8d00 | |||
| 39f82e9e1a | |||
| ab7fed1628 | |||
| c94691c89e | |||
| 3c95b76e5a | |||
| a977a0dd17 | |||
| e1a7b02e59 | |||
| 3537d3871c | |||
| 093e7b4c78 | |||
| 6e9cf2ba91 | |||
| 7c06961ff8 | |||
| 2b53406ccf | |||
| 06c79cc2bc | |||
| 5e870ec30f | |||
| 6a6d246dc5 | |||
| 07191bfb61 | |||
| b62895ca76 | |||
| bd2a748169 | |||
| 06c7b9a995 | |||
| 42b6bf96ff | |||
| aa6fb2fc12 | |||
| 8bfeab7071 | |||
| 97662b040e | |||
| a9ba16b07a | |||
| 36d93f5b0e | |||
| ba25850b73 | |||
| 9b75553ddc | |||
| b390630f4b | |||
| df329d305b | |||
| 59b4ce2474 | |||
| 9442656bbe | |||
| 0494cce77d | |||
| 909a8aaf9c | |||
| edf217111f | |||
| f0e18c62fe | |||
| a615dbec91 | |||
| a8ef92a933 | |||
| c1cca9385a | |||
| 267207cc15 | |||
| 012115b330 | |||
| c4aa988fb3 | |||
| c236a759d5 | |||
| 67323a41eb | |||
| 962166c2ef | |||
| b0ed64533f | |||
| bc7fe4ec75 | |||
| 923f7cbfd8 | |||
| 5d55657186 | |||
| f2067a047f | |||
| 7dfd6e9a99 | |||
| 2eca3e789c | |||
| 3351503744 | |||
| 107fa37aa6 | |||
| e8c14896aa | |||
| d5b9f4014e | |||
| 29b333cf0c | |||
| 7e340af48e | |||
| 568c9201d6 | |||
| 98ccab394a | |||
| 392b1028b3 | |||
| d32e0d330f | |||
| d1571798f4 | |||
| ff35a3fee5 | |||
| 29b27fa602 | |||
| 08e0d7339f | |||
| 6a05a56e3e | |||
| f1e34531c2 | |||
| c07d4a5e4e | |||
| 76a7dfeabb | |||
| 63a98fa9d3 | |||
| 78f854e2c5 | |||
| 17f806e7a2 | |||
| 7c9f351849 | |||
| 41e5bea8d6 | |||
| 933f2ce614 | |||
| 5089dfdada | |||
| 7f9e68c58d | |||
| c7dcdb1186 | |||
| 69138a24de | |||
| bf0fdf6d42 | |||
| 9ff7dc8977 | |||
| 7aedd3d9e9 | |||
| 42088fc78d | |||
| 4be50fc923 | |||
| e080690c2e | |||
| f6e2f11299 | |||
| 9d70cf1e7b | |||
| 35af0786c0 | |||
| 2a32810182 | |||
| 2bc591783d | |||
| 0c716da346 | |||
| ca0a51a485 | |||
| 6e8aa5595d | |||
| 0af5031790 | |||
| e5e8d95ba4 | |||
| 33011b5d48 | |||
| 8085515f56 | |||
| f3f661ee40 | |||
| a26f2a58d1 | |||
| 7fa78de6d4 | |||
| 46241c31e1 | |||
| c06cef9727 | |||
| 43adb7de82 | |||
| 06835ee3fc | |||
| 9eb244f9c1 | |||
| f3a3fe0710 | |||
| 22ee465d3b | |||
| 8097611207 | |||
| 85e62c4f79 | |||
| 05417fd1e8 | |||
| 3f5ca84c84 | |||
| 0fc3453f20 | |||
| 6904ad68d9 | |||
| 766009269e | |||
| aa79a3058c | |||
| 00471a095d | |||
| de2d169fbc | |||
| 7072dd4f7e | |||
| 65f3fe8afc | |||
| 03b4bea82a | |||
| 039f810f4f | |||
| 51bb2fe60b | |||
| 6586e45d86 | |||
| 0d220d63ea | |||
| 9d009663ba | |||
| a611040c41 | |||
| b232b6132f | |||
| f512298d10 | |||
| 082e12683b | |||
| 229f385f42 | |||
| 4dd2b1d88b | |||
| da4e103cec | |||
| 619d58900c | |||
| a8216bbd54 | |||
| 19f3890bed | |||
| 4734758be0 | |||
| ed97518a53 | |||
| ddde821064 | |||
| 7999e88554 | |||
| 2b5e3a9459 | |||
| 38b3965e68 | |||
| f68d5e1e60 | |||
| 32c133d6f8 | |||
| 5371ad82c2 | |||
| 6eb6004706 | |||
| fc31b404d7 | |||
| 4c60309d1d | |||
| 6597400f61 | |||
| 8356d7858f | |||
| 944c502101 | |||
| 0244f2da9a | |||
| 79d891e5bc | |||
| 313963dab6 | |||
| 6727613dc1 | |||
| e54688ad89 | |||
| 1a36c88f72 | |||
| 1e587df3d4 | |||
| 3613fc953e | |||
| 74b11d1c5d | |||
| a1562bf0e7 | |||
| 73447ce22b | |||
| 956cf6b203 | |||
| 378118b82e | |||
| 92abdde69e | |||
| 5e5c851173 | |||
| ed519a5cfe | |||
| 69758971af | |||
| 025f838f43 | |||
| e651952a34 | |||
| 02ca7234fb | |||
| 5fa2d7f7ce | |||
| 0169589e7c | |||
| 2d4348c50d | |||
| 14f6e5c16f | |||
| 51a2fa8c28 | |||
| 7343e4a51a | |||
| b4d7afb4c1 | |||
| 2fc6873c42 | |||
| 7683254de2 | |||
| 3a077d927d | |||
| a5e74ea2f0 | |||
| 53a71cf28c | |||
| e6551abc68 | |||
| bd7aa81625 | |||
| 04592f52de | |||
| a06499d710 | |||
| 6d1414af71 | |||
| 9cd7a7fdb0 | |||
| 3c2f5d14f5 | |||
| 985f3dffd3 | |||
| 0a048eb286 | |||
| 2cffe91e0b |
+43
-20
@@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- stable
|
||||
- sqlcipher
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
@@ -49,50 +50,74 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-20.04
|
||||
cache_path: ~/.stack
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
- os: ubuntu-18.04
|
||||
cache_path: ~/.stack
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-18_04-x86-64
|
||||
- os: macos-latest
|
||||
cache_path: ~/.stack
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
- os: windows-latest
|
||||
cache_path: C:/sr
|
||||
cache_path: C:/cabal
|
||||
asset_name: simplex-chat-windows-x86-64
|
||||
steps:
|
||||
- name: Clone project
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Stack
|
||||
- name: Setup Haskell
|
||||
uses: haskell/actions/setup@v1
|
||||
with:
|
||||
ghc-version: '8.10.7'
|
||||
enable-stack: true
|
||||
stack-version: 'latest'
|
||||
ghc-version: "8.10.7"
|
||||
cabal-version: "latest"
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ matrix.cache_path }}
|
||||
key: ${{ matrix.os }}-${{ hashFiles('stack.yaml') }}
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
dist-newstyle
|
||||
key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
|
||||
|
||||
# / Unix
|
||||
|
||||
- name: Unix prepare cabal.project.local for Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Unix prepare cabal.project.local for Ubuntu
|
||||
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
|
||||
id: unix_build
|
||||
if: matrix.os != 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
stack build --test
|
||||
echo "::set-output name=local_install_root::$(stack path --local-install-root)"
|
||||
cabal build --enable-tests
|
||||
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest'
|
||||
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_build.outputs.local_install_root }}/bin/simplex-chat
|
||||
file: ${{ steps.unix_build.outputs.bin_path }}
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
@@ -102,25 +127,23 @@ jobs:
|
||||
|
||||
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
|
||||
# * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
|
||||
# * So we're running a separate set of actions for Windows build
|
||||
|
||||
# TODO run tests on Windows
|
||||
- name: Windows build
|
||||
id: windows_build
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: cmd
|
||||
run: |
|
||||
stack build
|
||||
stack path --local-install-root > tmp_file
|
||||
set /p local_install_root= < tmp_file
|
||||
echo ::set-output name=local_install_root::%local_install_root%
|
||||
cabal build --enable-tests
|
||||
cabal list-bin simplex-chat > tmp_bin_path
|
||||
set /p bin_path= < tmp_bin_path
|
||||
echo ::set-output name=bin_path::%bin_path%
|
||||
|
||||
- name: Windows 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.windows_build.outputs.local_install_root }}\bin\simplex-chat.exe
|
||||
file: ${{ steps.windows_build.outputs.bin_path }}
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Build Eleventy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- stable
|
||||
paths:
|
||||
- website/**
|
||||
- images/**
|
||||
- blog/**
|
||||
- .github/workflows/web.yml
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install dependencies & build
|
||||
run: |
|
||||
./website/web.sh
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
publish_dir: ./website/_site
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+29
@@ -43,3 +43,32 @@ stack.yaml.lock
|
||||
# Temporary test files
|
||||
tests/tmp
|
||||
logs/
|
||||
|
||||
|
||||
# for website
|
||||
website/node_modules/
|
||||
website/src/blog/
|
||||
website/src/img/images/
|
||||
website/src/images/
|
||||
# Generated files
|
||||
website/package/generated*
|
||||
|
||||
# Ignore build tool output, e.g. code coverage
|
||||
website/.nyc_output/
|
||||
website/coverage/
|
||||
|
||||
# Ignore API documentation
|
||||
website/api-docs/
|
||||
|
||||
# Ignore folders from source code editors
|
||||
website/.vscode
|
||||
website/.idea
|
||||
|
||||
# Ignore eleventy output when doing manual tests
|
||||
website/_site/
|
||||
|
||||
website/package-lock.json
|
||||
|
||||
# Ignore test files
|
||||
website/.cache
|
||||
website/test/stubs-layout-cache/_includes/*.js
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
|
||||
|
||||
**NEW**: v4.0 is released - now local chat database is encrypted with passphrase! See [the release announcement](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Why privacy matters](#why-privacy-matters)
|
||||
@@ -79,18 +81,16 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
## News and updates
|
||||
|
||||
Selected updates:
|
||||
Recent updates:
|
||||
|
||||
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced netwrok settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.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)
|
||||
|
||||
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
|
||||
|
||||
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
|
||||
|
||||
[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md)
|
||||
|
||||
[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md)
|
||||
|
||||
[Jan 12, 2022. SimpleX v1 released: the only messaging and application platform without user identities](./20220112-simplex-chat-v1-released.md)
|
||||
|
||||
[All updates](./blog)
|
||||
|
||||
## Make a private connection
|
||||
@@ -142,22 +142,23 @@ What is already implemented:
|
||||
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.
|
||||
|
||||
We plan to add soon:
|
||||
|
||||
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
2. Local database encryption. Currently the local chat database stored on your device is not encrypted.
|
||||
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.
|
||||
4. Independent implementation audit.
|
||||
|
||||
## For developers
|
||||
|
||||
We plan that the SimpleX platform will grow into the platform supporting any distributed Internet application. This will allow you to build any service that people can access via chat, with custom web-based UI widgets that anybody with basic HTML/CSS/JavaScript knowledge can create in a few hours.
|
||||
You can:
|
||||
|
||||
You already can:
|
||||
|
||||
- use SimpleX Chat library to integrate chat functionality into your apps.
|
||||
- use SimpleX Chat bot templates in Haskell to build your own chat bot services (TypeScript SDK is coming soon).
|
||||
- use SimpleX Chat library to integrate chat functionality into your mobile apps.
|
||||
- create chat bots and services in Haskell - see [simple](./apps/simplex-bot/) and more [advanced chat bot example](./apps/simplex-bot-advanced/).
|
||||
- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScipt chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
|
||||
- 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.
|
||||
|
||||
@@ -177,20 +178,27 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ Chat database export and import
|
||||
- ✅ Chat groups in mobile apps.
|
||||
- ✅ Connecting to messaging servers via Tor.
|
||||
- 🏗 Dual server addresses to access messaging servers as v3 hidden services (in progress).
|
||||
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
|
||||
- Chat database encryption.
|
||||
- ✅ Dual server addresses to access messaging servers as v3 hidden services.
|
||||
- ✅ Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (ready for announcement).
|
||||
- ✅ Incognito mode to share a new random name with each contact.
|
||||
- ✅ Chat database encryption.
|
||||
- 🏗 Automatic chat history deletion.
|
||||
- 🏗 SMP queue redundancy and rotation.
|
||||
- 🏗 Links to join groups and improve groups stability.
|
||||
- Feeds/broadcasts
|
||||
- Disappearing messages, with mutual agreement.
|
||||
- Voice messages
|
||||
- Video messages
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
- SMP protocol improvements:
|
||||
- SMP queue redundancy and rotation.
|
||||
- Message delivery confirmation.
|
||||
- Supporting the same profile on multiple devices.
|
||||
- Message delivery confirmation.
|
||||
- Supporting the same profile on multiple devices.
|
||||
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||
- keep all your contacts and groups even if you lose the domain.
|
||||
- the server doesn't have information about your contacts and groups.
|
||||
- Channels server for large groups and broadcast channels.
|
||||
- Media server to optimize sending large files to groups.
|
||||
- Desktop client.
|
||||
- Using the same profile on multiple devices.
|
||||
|
||||
## Help us pay for 3rd party security audit
|
||||
|
||||
@@ -209,6 +217,7 @@ 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 wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
|
||||
Thank you,
|
||||
|
||||
|
||||
@@ -16,3 +16,4 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
app/src/main/cpp/libs/
|
||||
|
||||
+2
@@ -13,7 +13,9 @@
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 49
|
||||
versionName "3.2"
|
||||
versionCode 63
|
||||
versionName "4.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
@@ -94,6 +94,7 @@ dependencies {
|
||||
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"
|
||||
@@ -111,6 +112,7 @@ dependencies {
|
||||
|
||||
//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'
|
||||
@@ -118,6 +120,10 @@ dependencies {
|
||||
// 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'
|
||||
|
||||
@@ -63,6 +63,12 @@
|
||||
<data android:pathPrefix="/invitation" />
|
||||
<data android:pathPrefix="/contact" />
|
||||
</intent-filter>
|
||||
<!-- Receive files from other apps -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
@@ -103,6 +109,12 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- NtfManager action processing (buttons in notifications) -->
|
||||
<receiver
|
||||
android:name=".model.NtfManager$NtfActionReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- SimplexService foreground service -->
|
||||
<service
|
||||
android:name=".SimplexService"
|
||||
|
||||
@@ -24,8 +24,8 @@ var TransformOperation;
|
||||
let activeCall;
|
||||
const processCommand = (function () {
|
||||
const defaultIceServers = [
|
||||
{ urls: ["stun:stun.simplex.im:5349"] },
|
||||
{ urls: ["turn:turn.simplex.im:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
{ urls: ["stun:stun.simplex.im:443"] },
|
||||
{ urls: ["turn:turn.simplex.im:443"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
];
|
||||
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
|
||||
return {
|
||||
@@ -495,6 +495,7 @@ function callCryptoFunction() {
|
||||
const initialPlainTextRequired = {
|
||||
key: 10,
|
||||
delta: 3,
|
||||
empty: 1,
|
||||
};
|
||||
const IV_LENGTH = 12;
|
||||
function encryptFrame(key) {
|
||||
@@ -505,7 +506,9 @@ function callCryptoFunction() {
|
||||
const initial = data.subarray(0, n);
|
||||
const plaintext = data.subarray(n, data.byteLength);
|
||||
try {
|
||||
const ciphertext = new Uint8Array(plaintext.length ? await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext) : 0);
|
||||
const ciphertext = plaintext.length
|
||||
? new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext))
|
||||
: new Uint8Array(0);
|
||||
frame.data = concatN(initial, ciphertext, iv).buffer;
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
@@ -523,7 +526,9 @@ function callCryptoFunction() {
|
||||
const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH);
|
||||
const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength);
|
||||
try {
|
||||
const plaintext = new Uint8Array(ciphertext.length ? await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext) : 0);
|
||||
const plaintext = ciphertext.length
|
||||
? new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext))
|
||||
: new Uint8Array(0);
|
||||
frame.data = concatN(initial, plaintext).buffer;
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ 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
|
||||
@@ -61,7 +64,7 @@ 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.
|
||||
|
||||
@@ -22,20 +22,33 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass
|
||||
}
|
||||
|
||||
// from simplex-chat
|
||||
typedef void* chat_ctrl;
|
||||
typedef long* chat_ctrl;
|
||||
|
||||
extern chat_ctrl chat_init(const char *path);
|
||||
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);
|
||||
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);
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
|
||||
const char *_data = (*env)->GetStringUTFChars(env, datadir, JNI_FALSE);
|
||||
jlong res = (jlong)chat_init(_data);
|
||||
(*env)->ReleaseStringUTFChars(env, datadir, _data);
|
||||
return res;
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
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);
|
||||
jlong _ctrl = (jlong) 0;
|
||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, &_ctrl));
|
||||
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
||||
|
||||
// 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);
|
||||
// Java's String
|
||||
(*env)->SetObjectArrayElement(env, ret, 0, res);
|
||||
// Java's Long
|
||||
(*env)->SetObjectArrayElement(env, ret, 1,
|
||||
(*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Long"),
|
||||
(*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Long"), "<init>", "(J)V"),
|
||||
_ctrl));
|
||||
return ret;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.os.SystemClock.elapsedRealtime
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -15,13 +16,12 @@ import androidx.compose.material.Surface
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Replay
|
||||
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.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
@@ -30,28 +30,43 @@ 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.ChatListView
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
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.connectViaUri
|
||||
import chat.simplex.app.views.newchat.withUriAction
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||
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>()
|
||||
private val chatController by lazy { (application as SimplexApp).chatController }
|
||||
private val userAuthorized = mutableStateOf<Boolean?>(null)
|
||||
private val enteredBackground = mutableStateOf<Long?>(null)
|
||||
private val laFailed = mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
// testJson()
|
||||
val m = vm.chatModel
|
||||
processNotificationIntent(intent, m)
|
||||
// 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)
|
||||
processExternalIntent(intent, m)
|
||||
}
|
||||
setContent {
|
||||
SimpleXTheme {
|
||||
Surface(
|
||||
@@ -71,27 +86,38 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||
}
|
||||
}
|
||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||
SimplexApp.context.schedulePeriodicWakeUp()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
processIntent(intent, vm.chatModel)
|
||||
processExternalIntent(intent, vm.chatModel)
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
withApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
Lifecycle.Event.ON_START -> {
|
||||
val enteredBackgroundVal = enteredBackground.value
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val enteredBackgroundVal = enteredBackground.value
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,13 +249,20 @@ fun MainPage(
|
||||
showLANotice: () -> Unit
|
||||
) {
|
||||
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
|
||||
var chatsAccessAuthorized by remember { mutableStateOf(false) }
|
||||
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 (
|
||||
@@ -275,6 +308,11 @@ fun MainPage(
|
||||
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) {
|
||||
@@ -289,15 +327,17 @@ fun MainPage(
|
||||
else {
|
||||
showAdvertiseLAAlert = true
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA, stopped)
|
||||
if (chatModel.chatId.value == null) {
|
||||
if (chatModel.sharedContent.value == null)
|
||||
ChatListView(chatModel, setPerformLA, stopped)
|
||||
else
|
||||
ShareListView(chatModel, stopped)
|
||||
}
|
||||
else ChatView(chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo ->
|
||||
Box(Modifier.padding(horizontal = 20.dp)) {
|
||||
SimpleXInfo(chatModel, onboarding = true)
|
||||
}
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
|
||||
}
|
||||
ModalManager.shared.showInView()
|
||||
@@ -347,6 +387,27 @@ fun processIntent(intent: Intent?, chatModel: 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.Image(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
|
||||
@@ -6,10 +6,11 @@ import android.util.Log
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.getFilesDirectory
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.*
|
||||
@@ -26,21 +27,53 @@ external fun pipeStdOutToSocket(socketName: String) : Int
|
||||
|
||||
// SimpleX API
|
||||
typealias ChatCtrl = Long
|
||||
external fun chatInit(path: String): ChatCtrl
|
||||
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
|
||||
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
val chatController: ChatController by lazy {
|
||||
val ctrl = chatInit(getFilesDirectory(applicationContext))
|
||||
ChatController(ctrl, ntfManager, applicationContext, appPreferences)
|
||||
lateinit var chatController: ChatController
|
||||
|
||||
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 by lazy {
|
||||
chatController.chatModel
|
||||
}
|
||||
val chatModel: ChatModel
|
||||
get() = chatController.chatModel
|
||||
|
||||
private val ntfManager: NtfManager by lazy {
|
||||
NtfManager(applicationContext, appPreferences)
|
||||
@@ -53,36 +86,47 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
context = this
|
||||
initChatController()
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
withApi {
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user == null) {
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
} else {
|
||||
chatController.startChat(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
Log.d(TAG, "onStateChanged: $event")
|
||||
withApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP ->
|
||||
if (!appPreferences.runServiceInBackground.get()) SimplexService.stop(applicationContext)
|
||||
Lifecycle.Event.ON_START ->
|
||||
if (chatModel.chatRunning.value != false) SimplexService.start(applicationContext)
|
||||
Lifecycle.Event.ON_RESUME ->
|
||||
Lifecycle.Event.ON_START -> {
|
||||
if (chatModel.chatRunning.value == true) {
|
||||
kotlin.runCatching {
|
||||
val chats = chatController.apiGetChats()
|
||||
chatModel.updateChats(chats)
|
||||
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
|
||||
appPrefs.runServiceInBackground.get() && isIgnoringBatteryOptimizations(chatModel.controller.appContext)
|
||||
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))
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -109,6 +153,13 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -24,7 +24,6 @@ class SimplexService: Service() {
|
||||
private var isStartingService = false
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var serviceNotification: Notification? = null
|
||||
private val chatController by lazy { (application as SimplexApp).chatController }
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand startId: $startId")
|
||||
@@ -33,7 +32,6 @@ class SimplexService: Service() {
|
||||
Log.d(TAG, "intent action $action")
|
||||
when (action) {
|
||||
Action.START.name -> startService()
|
||||
Action.STOP.name -> stopService()
|
||||
else -> Log.e(TAG, "No action in the intent")
|
||||
}
|
||||
} else {
|
||||
@@ -56,7 +54,7 @@ class SimplexService: Service() {
|
||||
Log.d(TAG, "Simplex service destroyed")
|
||||
stopService()
|
||||
|
||||
// If private notifications are enabled and battery optimization is disabled, restart the service
|
||||
// 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()
|
||||
@@ -68,19 +66,21 @@ class SimplexService: Service() {
|
||||
val self = this
|
||||
isStartingService = true
|
||||
withApi {
|
||||
val chatController = (application as SimplexApp).chatController
|
||||
try {
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user == null) {
|
||||
chatController.chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
} else {
|
||||
Log.w(TAG, "Starting foreground service")
|
||||
chatController.startChat(user)
|
||||
isServiceStarted = true
|
||||
saveServiceState(self, ServiceState.STARTED)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||
acquire()
|
||||
}
|
||||
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)
|
||||
stopService()
|
||||
return@withApi
|
||||
}
|
||||
isServiceStarted = true
|
||||
saveServiceState(self, ServiceState.STARTED)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -151,7 +151,10 @@ class SimplexService: Service() {
|
||||
|
||||
// re-schedules the task when "Clear recent apps" is pressed
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
// If private notifications aren't enabled or battery optimization isn't disabled, we shouldn't restart the service
|
||||
// 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
|
||||
}
|
||||
@@ -209,7 +212,6 @@ class SimplexService: Service() {
|
||||
|
||||
enum class Action {
|
||||
START,
|
||||
STOP
|
||||
}
|
||||
|
||||
enum class ServiceState {
|
||||
@@ -226,6 +228,8 @@ class SimplexService: Service() {
|
||||
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"
|
||||
@@ -240,7 +244,7 @@ class SimplexService: Service() {
|
||||
|
||||
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
||||
|
||||
suspend fun stop(context: Context) = serviceAction(context, Action.STOP)
|
||||
fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java))
|
||||
|
||||
private suspend fun serviceAction(context: Context, action: Action) {
|
||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||
@@ -270,6 +274,41 @@ class SimplexService: Service() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.usersettings.NotificationPreviewMode
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.descriptors.*
|
||||
@@ -25,6 +27,9 @@ class ChatModel(val controller: ChatController) {
|
||||
val userCreated = mutableStateOf<Boolean?>(null)
|
||||
val chatRunning = mutableStateOf<Boolean?>(null)
|
||||
val chatDbChanged = mutableStateOf<Boolean>(false)
|
||||
val chatDbEncrypted = mutableStateOf<Boolean?>(false)
|
||||
val chatDbStatus = mutableStateOf<DBMigrationResult?>(null)
|
||||
val chatDbDeleted = mutableStateOf(false)
|
||||
val chats = mutableStateListOf<Chat>()
|
||||
|
||||
// current chat
|
||||
@@ -32,10 +37,10 @@ class ChatModel(val controller: ChatController) {
|
||||
val chatItems = mutableStateListOf<ChatItem>()
|
||||
val groupMembers = mutableStateListOf<GroupMember>()
|
||||
|
||||
var connReqInvitation: String? = null
|
||||
val terminalItems = mutableStateListOf<TerminalItem>()
|
||||
val userAddress = mutableStateOf<String?>(null)
|
||||
val userSMPServers = mutableStateOf<(List<String>)?>(null)
|
||||
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
|
||||
|
||||
// set when app opened from external intent
|
||||
val clearOverlays = mutableStateOf<Boolean>(false)
|
||||
@@ -44,9 +49,11 @@ class ChatModel(val controller: ChatController) {
|
||||
val appOpenUrl = mutableStateOf<Uri?>(null)
|
||||
|
||||
// preferences
|
||||
val runServiceInBackground = mutableStateOf(true)
|
||||
val notificationsMode = mutableStateOf(NotificationsMode.default)
|
||||
var notificationPreviewMode = mutableStateOf(NotificationPreviewMode.default)
|
||||
val performLA = mutableStateOf(false)
|
||||
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
|
||||
var incognito = mutableStateOf(false)
|
||||
|
||||
// current WebRTC call
|
||||
val callManager = CallManager(this)
|
||||
@@ -57,7 +64,13 @@ class ChatModel(val controller: ChatController) {
|
||||
val showCallView = mutableStateOf(false)
|
||||
val switchingCall = mutableStateOf(false)
|
||||
|
||||
fun updateUserProfile(profile: Profile) {
|
||||
// currently showing QR code
|
||||
val connReqInv = mutableStateOf(null as String?)
|
||||
|
||||
// working with external intents
|
||||
val sharedContent = mutableStateOf(null as SharedContent?)
|
||||
|
||||
fun updateUserProfile(profile: LocalProfile) {
|
||||
val user = currentUser.value
|
||||
if (user != null) {
|
||||
currentUser.value = user.copy(profile = profile)
|
||||
@@ -66,6 +79,7 @@ class ChatModel(val controller: ChatController) {
|
||||
|
||||
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
|
||||
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
|
||||
fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
|
||||
private fun getChatIndex(id: String): Int = chats.indexOfFirst { it.id == id }
|
||||
fun addChat(chat: Chat) = chats.add(index = 0, chat)
|
||||
|
||||
@@ -100,6 +114,12 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
chats.clear()
|
||||
chats.addAll(mergedChats)
|
||||
|
||||
val cId = chatId.value
|
||||
// If chat is null, it was deleted in background after apiGetChats call
|
||||
if (cId != null && getChat(cId) == null) {
|
||||
chatId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) {
|
||||
@@ -158,6 +178,10 @@ class ChatModel(val controller: ChatController) {
|
||||
val pItem = chat.chatItems.lastOrNull()
|
||||
if (pItem?.id == cItem.id) {
|
||||
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
|
||||
if (pItem.isRcvNew && !cItem.isRcvNew) {
|
||||
// status changed from New to Read, update counter
|
||||
decreaseCounterInChat(cInfo.id)
|
||||
}
|
||||
}
|
||||
res = false
|
||||
} else {
|
||||
@@ -246,6 +270,18 @@ class ChatModel(val controller: ChatController) {
|
||||
return markedRead
|
||||
}
|
||||
|
||||
private fun decreaseCounterInChat(chatId: ChatId) {
|
||||
val chatIndex = getChatIndex(chatId)
|
||||
if (chatIndex == -1) return
|
||||
|
||||
val chat = chats[chatIndex]
|
||||
chats[chatIndex] = chat.copy(
|
||||
chatStats = chat.chatStats.copy(
|
||||
unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// func popChat(_ id: String) {
|
||||
// if let i = getChatIndex(id) {
|
||||
// popChat_(i)
|
||||
@@ -257,6 +293,15 @@ class ChatModel(val controller: ChatController) {
|
||||
chats.add(index = 0, chat)
|
||||
}
|
||||
|
||||
fun dismissConnReqView(id: String) {
|
||||
if (connReqInv.value == null) return
|
||||
val info = getChat(id)?.chatInfo as? ChatInfo.ContactConnection ?: return
|
||||
if (info.contactConnection.connReqInv == connReqInv.value) {
|
||||
connReqInv.value = null
|
||||
ModalManager.shared.closeModals()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChat(id: String) {
|
||||
chats.removeAll { it.id == id }
|
||||
}
|
||||
@@ -290,19 +335,20 @@ data class User(
|
||||
val userId: Long,
|
||||
val userContactId: Long,
|
||||
val localDisplayName: String,
|
||||
val profile: Profile,
|
||||
val profile: LocalProfile,
|
||||
val activeUser: Boolean
|
||||
): NamedChat {
|
||||
override val displayName: String get() = profile.displayName
|
||||
override val fullName: String get() = profile.fullName
|
||||
override val image: String? get() = profile.image
|
||||
override val localAlias: String = ""
|
||||
|
||||
companion object {
|
||||
val sampleData = User(
|
||||
userId = 1,
|
||||
userContactId = 1,
|
||||
localDisplayName = "alice",
|
||||
profile = Profile.sampleData,
|
||||
profile = LocalProfile.sampleData,
|
||||
activeUser = true
|
||||
)
|
||||
}
|
||||
@@ -314,8 +360,9 @@ interface NamedChat {
|
||||
val displayName: String
|
||||
val fullName: String
|
||||
val image: String?
|
||||
val localAlias: String
|
||||
val chatViewName: String
|
||||
get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName")
|
||||
get() = localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
|
||||
}
|
||||
|
||||
interface SomeChat {
|
||||
@@ -325,6 +372,7 @@ interface SomeChat {
|
||||
val apiId: Long
|
||||
val ready: Boolean
|
||||
val sendMsgEnabled: Boolean
|
||||
val ntfsEnabled: Boolean
|
||||
val createdAt: Instant
|
||||
val updatedAt: Instant
|
||||
}
|
||||
@@ -375,6 +423,8 @@ data class Chat (
|
||||
|
||||
@Serializable
|
||||
sealed class ChatInfo: SomeChat, NamedChat {
|
||||
abstract val incognito: Boolean
|
||||
|
||||
@Serializable @SerialName("direct")
|
||||
class Direct(val contact: Contact): ChatInfo() {
|
||||
override val chatType get() = ChatType.Direct
|
||||
@@ -383,11 +433,14 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val apiId get() = contact.apiId
|
||||
override val ready get() = contact.ready
|
||||
override val sendMsgEnabled get() = contact.sendMsgEnabled
|
||||
override val ntfsEnabled get() = contact.chatSettings.enableNtfs
|
||||
override val incognito get() = contact.contactConnIncognito
|
||||
override val createdAt get() = contact.createdAt
|
||||
override val updatedAt get() = contact.updatedAt
|
||||
override val displayName get() = contact.displayName
|
||||
override val fullName get() = contact.fullName
|
||||
override val image get() = contact.image
|
||||
override val localAlias: String get() = contact.localAlias
|
||||
|
||||
companion object {
|
||||
val sampleData = Direct(Contact.sampleData)
|
||||
@@ -402,11 +455,14 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val apiId get() = groupInfo.apiId
|
||||
override val ready get() = groupInfo.ready
|
||||
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
|
||||
override val ntfsEnabled get() = groupInfo.chatSettings.enableNtfs
|
||||
override val incognito get() = groupInfo.membership.memberIncognito
|
||||
override val createdAt get() = groupInfo.createdAt
|
||||
override val updatedAt get() = groupInfo.updatedAt
|
||||
override val displayName get() = groupInfo.displayName
|
||||
override val fullName get() = groupInfo.fullName
|
||||
override val image get() = groupInfo.image
|
||||
override val localAlias get() = groupInfo.localAlias
|
||||
|
||||
companion object {
|
||||
val sampleData = Group(GroupInfo.sampleData)
|
||||
@@ -421,11 +477,14 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val apiId get() = contactRequest.apiId
|
||||
override val ready get() = contactRequest.ready
|
||||
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
|
||||
override val ntfsEnabled get() = false
|
||||
override val incognito get() = false
|
||||
override val createdAt get() = contactRequest.createdAt
|
||||
override val updatedAt get() = contactRequest.updatedAt
|
||||
override val displayName get() = contactRequest.displayName
|
||||
override val fullName get() = contactRequest.fullName
|
||||
override val image get() = contactRequest.image
|
||||
override val localAlias get() = contactRequest.localAlias
|
||||
|
||||
companion object {
|
||||
val sampleData = ContactRequest(UserContactRequest.sampleData)
|
||||
@@ -440,11 +499,14 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val apiId get() = contactConnection.apiId
|
||||
override val ready get() = contactConnection.ready
|
||||
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
|
||||
override val ntfsEnabled get() = false
|
||||
override val incognito get() = contactConnection.incognito
|
||||
override val createdAt get() = contactConnection.createdAt
|
||||
override val updatedAt get() = contactConnection.updatedAt
|
||||
override val displayName get() = contactConnection.displayName
|
||||
override val fullName get() = contactConnection.fullName
|
||||
override val image get() = contactConnection.image
|
||||
override val localAlias get() = contactConnection.localAlias
|
||||
|
||||
companion object {
|
||||
fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): ContactConnection =
|
||||
@@ -454,13 +516,13 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Contact(
|
||||
data class Contact(
|
||||
val contactId: Long,
|
||||
override val localDisplayName: String,
|
||||
val profile: Profile,
|
||||
val profile: LocalProfile,
|
||||
val activeConn: Connection,
|
||||
val viaGroup: Long? = null,
|
||||
// val chatSettings: ChatSettings,
|
||||
val chatSettings: ChatSettings,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
@@ -469,19 +531,25 @@ class Contact(
|
||||
override val apiId get() = contactId
|
||||
override val ready get() = activeConn.connStatus == ConnStatus.Ready
|
||||
override val sendMsgEnabled get() = true
|
||||
override val displayName get() = profile.displayName
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
override val displayName get() = localAlias.ifEmpty { profile.displayName }
|
||||
override val fullName get() = profile.fullName
|
||||
override val image get() = profile.image
|
||||
override val localAlias get() = profile.localAlias
|
||||
|
||||
val isIndirectContact: Boolean get() =
|
||||
activeConn.connLevel > 0 || viaGroup != null
|
||||
|
||||
val contactConnIncognito =
|
||||
activeConn.customUserProfileId != null
|
||||
|
||||
companion object {
|
||||
val sampleData = Contact(
|
||||
contactId = 1,
|
||||
localDisplayName = "alice",
|
||||
profile = Profile.sampleData,
|
||||
profile = LocalProfile.sampleData,
|
||||
activeConn = Connection.sampleData,
|
||||
chatSettings = ChatSettings(true),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
@@ -503,10 +571,10 @@ class ContactSubStatus(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int) {
|
||||
class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val customUserProfileId: Long? = null) {
|
||||
val id: ChatId get() = ":$connId"
|
||||
companion object {
|
||||
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0)
|
||||
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, customUserProfileId = null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,13 +582,16 @@ class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: In
|
||||
class Profile(
|
||||
override val displayName: String,
|
||||
override val fullName: String,
|
||||
override val image: String? = null
|
||||
override val image: String? = null,
|
||||
override val localAlias : String = ""
|
||||
): NamedChat {
|
||||
val profileViewName: String
|
||||
get() {
|
||||
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
|
||||
}
|
||||
|
||||
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias)
|
||||
|
||||
companion object {
|
||||
val sampleData = Profile(
|
||||
displayName = "alice",
|
||||
@@ -529,6 +600,28 @@ class Profile(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class LocalProfile(
|
||||
val profileId: Long,
|
||||
override val displayName: String,
|
||||
override val fullName: String,
|
||||
override val image: String? = null,
|
||||
override val localAlias: String,
|
||||
): NamedChat {
|
||||
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
|
||||
|
||||
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias)
|
||||
|
||||
companion object {
|
||||
val sampleData = LocalProfile(
|
||||
profileId = 1L,
|
||||
displayName = "alice",
|
||||
fullName = "Alice",
|
||||
localAlias = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Group (
|
||||
val groupInfo: GroupInfo,
|
||||
@@ -536,23 +629,26 @@ class Group (
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class GroupInfo (
|
||||
data class GroupInfo (
|
||||
val groupId: Long,
|
||||
override val localDisplayName: String,
|
||||
val groupProfile: GroupProfile,
|
||||
val membership: GroupMember,
|
||||
// val chatSettings: ChatSettings,
|
||||
val hostConnCustomUserProfileId: Long? = null,
|
||||
val chatSettings: ChatSettings,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
override val chatType get() = ChatType.Group
|
||||
override val id get() = "#$groupId"
|
||||
override val apiId get() = groupId
|
||||
override val ready get() = true
|
||||
override val ready get() = membership.memberActive
|
||||
override val sendMsgEnabled get() = membership.memberActive
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
override val displayName get() = groupProfile.displayName
|
||||
override val fullName get() = groupProfile.fullName
|
||||
override val image get() = groupProfile.image
|
||||
override val localAlias get() = ""
|
||||
|
||||
val canEdit: Boolean
|
||||
get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent
|
||||
@@ -569,6 +665,8 @@ class GroupInfo (
|
||||
localDisplayName = "team",
|
||||
groupProfile = GroupProfile.sampleData,
|
||||
membership = GroupMember.sampleData,
|
||||
hostConnCustomUserProfileId = null,
|
||||
chatSettings = ChatSettings(true),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
@@ -579,7 +677,8 @@ class GroupInfo (
|
||||
class GroupProfile (
|
||||
override val displayName: String,
|
||||
override val fullName: String,
|
||||
override val image: String? = null
|
||||
override val image: String? = null,
|
||||
override val localAlias: String = "",
|
||||
): NamedChat {
|
||||
companion object {
|
||||
val sampleData = GroupProfile(
|
||||
@@ -599,17 +698,18 @@ class GroupMember (
|
||||
var memberStatus: GroupMemberStatus,
|
||||
var invitedBy: InvitedBy,
|
||||
val localDisplayName: String,
|
||||
val memberProfile: Profile,
|
||||
val memberProfile: LocalProfile,
|
||||
val memberContactId: Long? = null,
|
||||
val memberContactProfileId: Long,
|
||||
var activeConn: Connection? = null
|
||||
) {
|
||||
val id: String get() = "#$groupId @$groupMemberId"
|
||||
val displayName: String get() = memberProfile.displayName
|
||||
val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName }
|
||||
val fullName: String get() = memberProfile.fullName
|
||||
val image: String? get() = memberProfile.image
|
||||
|
||||
val chatViewName: String
|
||||
get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName")
|
||||
get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
|
||||
|
||||
val memberActive: Boolean get() = when (this.memberStatus) {
|
||||
GroupMemberStatus.MemRemoved -> false
|
||||
@@ -639,12 +739,20 @@ class GroupMember (
|
||||
GroupMemberStatus.MemCreator -> true
|
||||
}
|
||||
|
||||
fun canBeRemoved(membership: GroupMember): Boolean {
|
||||
val userRole = membership.memberRole
|
||||
fun canBeRemoved(groupInfo: GroupInfo): Boolean {
|
||||
val userRole = groupInfo.membership.memberRole
|
||||
return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft
|
||||
&& userRole >= GroupMemberRole.Admin && userRole >= memberRole && membership.memberCurrent
|
||||
&& userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberCurrent
|
||||
}
|
||||
|
||||
fun canChangeRoleTo(groupInfo: GroupInfo): List<GroupMemberRole>? =
|
||||
if (!canBeRemoved(groupInfo)) null
|
||||
else groupInfo.membership.memberRole.let { userRole ->
|
||||
GroupMemberRole.values().filter { it <= userRole }
|
||||
}
|
||||
|
||||
val memberIncognito = memberProfile.profileId != memberContactProfileId
|
||||
|
||||
companion object {
|
||||
val sampleData = GroupMember(
|
||||
groupMemberId = 1,
|
||||
@@ -655,8 +763,9 @@ class GroupMember (
|
||||
memberStatus = GroupMemberStatus.MemComplete,
|
||||
invitedBy = InvitedBy.IBUser(),
|
||||
localDisplayName = "alice",
|
||||
memberProfile = Profile.sampleData,
|
||||
memberProfile = LocalProfile.sampleData,
|
||||
memberContactId = 1,
|
||||
memberContactProfileId = 1L,
|
||||
activeConn = Connection.sampleData
|
||||
)
|
||||
}
|
||||
@@ -770,9 +879,11 @@ class UserContactRequest (
|
||||
override val apiId get() = contactRequestId
|
||||
override val ready get() = true
|
||||
override val sendMsgEnabled get() = false
|
||||
override val ntfsEnabled get() = false
|
||||
override val displayName get() = profile.displayName
|
||||
override val fullName get() = profile.fullName
|
||||
override val image get() = profile.image
|
||||
override val localAlias get() = ""
|
||||
|
||||
companion object {
|
||||
val sampleData = UserContactRequest(
|
||||
@@ -791,6 +902,9 @@ class PendingContactConnection(
|
||||
val pccAgentConnId: String,
|
||||
val pccConnStatus: ConnStatus,
|
||||
val viaContactUri: Boolean,
|
||||
val customUserProfileId: Long? = null,
|
||||
val connReqInv: String? = null,
|
||||
override val localAlias: String,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
@@ -799,8 +913,10 @@ class PendingContactConnection(
|
||||
override val apiId get() = pccConnId
|
||||
override val ready get() = false
|
||||
override val sendMsgEnabled get() = false
|
||||
override val ntfsEnabled get() = false
|
||||
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
|
||||
override val displayName: String get() {
|
||||
if (localAlias.isNotEmpty()) return localAlias
|
||||
val initiated = pccConnStatus.initiated
|
||||
return if (initiated == null) {
|
||||
// this should not be in the chat list
|
||||
@@ -814,14 +930,20 @@ class PendingContactConnection(
|
||||
}
|
||||
override val fullName get() = ""
|
||||
override val image get() = null
|
||||
|
||||
val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
|
||||
|
||||
val incognito = customUserProfileId != null
|
||||
|
||||
val description: String get() {
|
||||
val initiated = pccConnStatus.initiated
|
||||
return if (initiated == null) "" else generalGetString(
|
||||
if (initiated && !viaContactUri) R.string.description_you_shared_one_time_link
|
||||
else if (viaContactUri ) R.string.description_via_contact_address_link
|
||||
else R.string.description_via_one_time_link
|
||||
if (initiated && !viaContactUri)
|
||||
if (incognito) R.string.description_you_shared_one_time_link_incognito else R.string.description_you_shared_one_time_link
|
||||
else if (viaContactUri )
|
||||
if (incognito) R.string.description_via_contact_address_link_incognito else R.string.description_via_contact_address_link
|
||||
else
|
||||
if (incognito) R.string.description_via_one_time_link_incognito else R.string.description_via_one_time_link
|
||||
)
|
||||
}
|
||||
|
||||
@@ -832,6 +954,8 @@ class PendingContactConnection(
|
||||
pccAgentConnId = "abcd",
|
||||
pccConnStatus = status,
|
||||
viaContactUri = viaContactUri,
|
||||
localAlias = "",
|
||||
customUserProfileId = null,
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
@@ -886,7 +1010,7 @@ data class ChatItem (
|
||||
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
|
||||
|
||||
val memberDisplayName: String? get() =
|
||||
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName
|
||||
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
|
||||
else null
|
||||
|
||||
val isDeletedContent: Boolean get() =
|
||||
@@ -903,6 +1027,24 @@ data class ChatItem (
|
||||
else -> false
|
||||
}
|
||||
|
||||
val isMutedMemberEvent: Boolean get() =
|
||||
when (content) {
|
||||
is CIContent.RcvGroupEventContent ->
|
||||
when (content.rcvGroupEvent) {
|
||||
is RcvGroupEvent.GroupUpdated -> true
|
||||
is RcvGroupEvent.MemberConnected -> true
|
||||
is RcvGroupEvent.UserDeleted -> false
|
||||
is RcvGroupEvent.GroupDeleted -> false
|
||||
is RcvGroupEvent.MemberAdded -> false
|
||||
is RcvGroupEvent.MemberLeft -> false
|
||||
is RcvGroupEvent.MemberRole -> true
|
||||
is RcvGroupEvent.UserRole -> false
|
||||
is RcvGroupEvent.MemberDeleted -> false
|
||||
}
|
||||
is CIContent.SndGroupEventContent -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))
|
||||
|
||||
companion object {
|
||||
@@ -1094,11 +1236,11 @@ class CIQuote (
|
||||
): ItemContent {
|
||||
override val text: String get() = content.text
|
||||
|
||||
fun sender(user: User): String? = when (chatDir) {
|
||||
fun sender(membership: GroupMember?): String? = when (chatDir) {
|
||||
is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun)
|
||||
is CIDirection.DirectRcv -> null
|
||||
is CIDirection.GroupSnd -> user.displayName
|
||||
is CIDirection.GroupRcv -> chatDir.groupMember.memberProfile.displayName
|
||||
is CIDirection.GroupSnd -> membership?.displayName
|
||||
is CIDirection.GroupRcv -> chatDir.groupMember.displayName
|
||||
null -> null
|
||||
}
|
||||
|
||||
@@ -1179,9 +1321,11 @@ class CIGroupInvitation (
|
||||
val groupMemberId: Long,
|
||||
val localDisplayName: String,
|
||||
val groupProfile: GroupProfile,
|
||||
val status: CIGroupInvitationStatus
|
||||
val status: CIGroupInvitationStatus,
|
||||
) {
|
||||
val text: String get() = String.format(generalGetString(R.string.group_invitation_item_description), groupProfile.displayName)
|
||||
val text: String get() = String.format(
|
||||
generalGetString(R.string.group_invitation_item_description),
|
||||
groupProfile.displayName)
|
||||
|
||||
companion object {
|
||||
fun getSample(
|
||||
@@ -1346,6 +1490,9 @@ enum class FormatColor(val color: String) {
|
||||
@Serializable
|
||||
class SndFileTransfer() {}
|
||||
|
||||
@Serializable
|
||||
class RcvFileTransfer() {}
|
||||
|
||||
@Serializable
|
||||
class FileTransferMeta() {}
|
||||
|
||||
@@ -1394,6 +1541,8 @@ sealed class RcvGroupEvent() {
|
||||
@Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
|
||||
@Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent()
|
||||
@Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent()
|
||||
@Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): RcvGroupEvent()
|
||||
@Serializable @SerialName("userRole") class UserRole(val role: GroupMemberRole): RcvGroupEvent()
|
||||
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
|
||||
@Serializable @SerialName("userDeleted") class UserDeleted(): RcvGroupEvent()
|
||||
@Serializable @SerialName("groupDeleted") class GroupDeleted(): RcvGroupEvent()
|
||||
@@ -1403,6 +1552,8 @@ sealed class RcvGroupEvent() {
|
||||
is MemberAdded -> String.format(generalGetString(R.string.rcv_group_event_member_added), profile.profileViewName)
|
||||
is MemberConnected -> generalGetString(R.string.rcv_group_event_member_connected)
|
||||
is MemberLeft -> generalGetString(R.string.rcv_group_event_member_left)
|
||||
is MemberRole -> String.format(generalGetString(R.string.member_role), profile.profileViewName, role.text)
|
||||
is UserRole -> String.format(generalGetString(R.string.your_member_role), role.text)
|
||||
is MemberDeleted -> String.format(generalGetString(R.string.rcv_group_event_member_deleted), profile.profileViewName)
|
||||
is UserDeleted -> generalGetString(R.string.rcv_group_event_user_deleted)
|
||||
is GroupDeleted -> generalGetString(R.string.rcv_group_event_group_deleted)
|
||||
@@ -1412,13 +1563,48 @@ sealed class RcvGroupEvent() {
|
||||
|
||||
@Serializable
|
||||
sealed class SndGroupEvent() {
|
||||
@Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): SndGroupEvent()
|
||||
@Serializable @SerialName("userRole") class UserRole(val role: GroupMemberRole): SndGroupEvent()
|
||||
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
|
||||
@Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent()
|
||||
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent()
|
||||
|
||||
val text: String get() = when (this) {
|
||||
is MemberRole -> String.format(generalGetString(R.string.member_role), profile.profileViewName, role.text)
|
||||
is UserRole -> String.format(generalGetString(R.string.your_member_role), role.text)
|
||||
is MemberDeleted -> String.format(generalGetString(R.string.snd_group_event_member_deleted), profile.profileViewName)
|
||||
is UserLeft -> generalGetString(R.string.snd_group_event_user_left)
|
||||
is GroupUpdated -> generalGetString(R.string.snd_group_event_group_profile_updated)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ChatItemTTL: Comparable<ChatItemTTL?> {
|
||||
object Day: ChatItemTTL()
|
||||
object Week: ChatItemTTL()
|
||||
object Month: ChatItemTTL()
|
||||
data class Seconds(val secs: Long): ChatItemTTL()
|
||||
object None: ChatItemTTL()
|
||||
|
||||
override fun compareTo(other: ChatItemTTL?): Int = (seconds ?: Long.MAX_VALUE).compareTo(other?.seconds ?: Long.MAX_VALUE)
|
||||
|
||||
val seconds: Long?
|
||||
get() =
|
||||
when (this) {
|
||||
is None -> null
|
||||
is Day -> 86400L
|
||||
is Week -> 7 * 86400L
|
||||
is Month -> 30 * 86400L
|
||||
is Seconds -> secs
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromSeconds(seconds: Long?): ChatItemTTL =
|
||||
when (seconds) {
|
||||
null -> None
|
||||
86400L -> Day
|
||||
7 * 86400L -> Week
|
||||
30 * 86400L -> Month
|
||||
else -> Seconds(seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import chat.simplex.app.views.chatlist.acceptContactRequest
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.NotificationPreviewMode
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class NtfManager(val context: Context, private val appPreferences: AppPreferences) {
|
||||
@@ -26,6 +27,8 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
|
||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||
const val CallNotificationId: Int = -1
|
||||
|
||||
private const val ChatIdKey: String = "chatId"
|
||||
}
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -33,13 +36,17 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
private val msgNtfTimeoutMs = 30000L
|
||||
|
||||
init {
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, "SimpleX Chat messages", NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, "SimpleX Chat calls (lock screen)", NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel())
|
||||
}
|
||||
|
||||
enum class NotificationAction {
|
||||
ACCEPT_CONTACT_REQUEST
|
||||
}
|
||||
|
||||
private fun callNotificationChannel(): NotificationChannel {
|
||||
val callChannel = NotificationChannel(CallChannel, "SimpleX Chat calls", NotificationManager.IMPORTANCE_HIGH)
|
||||
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
|
||||
val attrs = AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
@@ -63,28 +70,69 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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 notification = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setContentTitle(displayName)
|
||||
.setContentText(msgText)
|
||||
val previewMode = appPreferences.notificationPreviewMode.get()
|
||||
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)
|
||||
}
|
||||
val builder = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setContentTitle(title)
|
||||
.setContentText(content)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setGroup(MessageGroup)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setLargeIcon(largeIcon)
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
.setVibrate(if (actions.isEmpty()) null else longArrayOf(0, 250, 250, 250))
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
|
||||
.setSilent(recentNotification)
|
||||
.build()
|
||||
.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(ChatIdKey, chatId)
|
||||
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
|
||||
val actionButton = when (action) {
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(R.string.accept)
|
||||
}
|
||||
builder.addAction(0, actionButton, actionPendingIntent)
|
||||
}
|
||||
|
||||
val summary = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
@@ -97,7 +145,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
// using cInfo.id only shows one notification per chat and updates it when the message arrives
|
||||
notify(chatId.hashCode(), notification)
|
||||
notify(chatId.hashCode(), builder.build())
|
||||
notify(0, summary)
|
||||
}
|
||||
}
|
||||
@@ -130,13 +178,23 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
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(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)
|
||||
|
||||
ntfBuilder = ntfBuilder
|
||||
.setContentTitle(invitation.contact.displayName)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setLargeIcon(if (image == null) BitmapFactory.decodeResource(context.resources, R.drawable.icon) else base64ToBitmap(image))
|
||||
.setLargeIcon(largeIcon)
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
@@ -171,10 +229,31 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
var intent = Intent(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.setAction(intentAction)
|
||||
if (chatId != null) intent = intent.putExtra("chatId", chatId)
|
||||
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent)
|
||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 chatId = intent?.getStringExtra(ChatIdKey) ?: return
|
||||
val cInfo = SimplexApp.context.chatModel.getChat(chatId)?.chatInfo
|
||||
when (intent.action) {
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
|
||||
if (cInfo !is ChatInfo.ContactRequest) return
|
||||
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
|
||||
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ val Purple500 = Color(0xFF6200EE)
|
||||
val Purple700 = Color(0xFF3700B3)
|
||||
val Teal200 = Color(0xFF03DAC5)
|
||||
val Gray = Color(0x22222222)
|
||||
val Indigo = Color(0xff330099)
|
||||
val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files
|
||||
val SimplexGreen = Color(77, 218, 103, 255)
|
||||
val SecretColor = Color(0x40808080)
|
||||
@@ -23,5 +24,6 @@ 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)
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
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.Composable
|
||||
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
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
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,
|
||||
@@ -18,7 +31,7 @@ private val DarkColorPalette = darkColors(
|
||||
onSurface = Color(0xFFFFFBFA),
|
||||
// onError: Color = Color.Black,
|
||||
)
|
||||
private val LightColorPalette = lightColors(
|
||||
val LightColorPalette = lightColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = LightGray,
|
||||
@@ -30,16 +43,32 @@ private val LightColorPalette = lightColors(
|
||||
// onSurface = Color.Black,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SimpleXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
||||
val colors = if (darkTheme) {
|
||||
DarkColorPalette
|
||||
} else {
|
||||
LightColorPalette
|
||||
}
|
||||
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 = colors,
|
||||
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,5 +1,6 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
@@ -7,42 +8,101 @@ 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.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
||||
BackHandler(onBack = close)
|
||||
TerminalLayout(
|
||||
chatModel.terminalItems,
|
||||
composeState,
|
||||
sendCommand = {
|
||||
withApi {
|
||||
// show "in progress"
|
||||
chatModel.controller.sendCmd(CC.Console(composeState.value.message))
|
||||
composeState.value = ComposeState(useLinkPreviews = false)
|
||||
// hide "in progress"
|
||||
val authorized = remember { mutableStateOf(!chatModel.controller.appPrefs.performLA.get()) }
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(authorized.value) {
|
||||
if (!authorized.value) {
|
||||
runAuth(authorized = authorized, context)
|
||||
}
|
||||
}
|
||||
if (authorized.value) {
|
||||
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(authorized = authorized, context)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
close
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runAuth(authorized: MutableState<Boolean>, context: Context) {
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_open_chat_console),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success, LAResult.Unavailable -> authorized.value = true
|
||||
is LAResult.Error, LAResult.Failed -> authorized.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendCommand(chatModel: ChatModel, composeState: MutableState<ComposeState>) {
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
|
||||
val s = composeState.value.message
|
||||
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
|
||||
val resp = CR.ChatCmdError(ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
|
||||
chatModel.terminalItems.add(TerminalItem.cmd(CC.Console(s)))
|
||||
chatModel.terminalItems.add(TerminalItem.resp(resp))
|
||||
composeState.value = ComposeState(useLinkPreviews = false)
|
||||
} else {
|
||||
withApi {
|
||||
// show "in progress"
|
||||
chatModel.controller.sendCmd(CC.Console(s))
|
||||
composeState.value = ComposeState(useLinkPreviews = false)
|
||||
// hide "in progress"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TerminalLayout(
|
||||
terminalItems: List<TerminalItem>,
|
||||
@@ -82,35 +142,24 @@ fun TerminalLayout(
|
||||
@Composable
|
||||
fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
val listState = rememberLazyListState()
|
||||
val keyboardState by getKeyboardState()
|
||||
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
|
||||
mutableStateOf(CIListState(false, terminalItems.count(), keyboardState))
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
LazyColumn(state = listState) {
|
||||
items(terminalItems) { item ->
|
||||
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
|
||||
LazyColumn(state = listState, reverseLayout = true) {
|
||||
items(reversedTerminalItems) { item ->
|
||||
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
ModalManager.shared.showModal {
|
||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(item.details)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
val len = terminalItems.count()
|
||||
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
|
||||
scope.launch {
|
||||
ciListState.value = CIListState(true, len, keyboardState)
|
||||
listState.animateScrollToItem(len - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
@@ -26,12 +25,13 @@ 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.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
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
|
||||
@@ -45,13 +45,9 @@ fun CreateProfilePanel(chatModel: ChatModel) {
|
||||
|
||||
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.create_profile),
|
||||
style = MaterialTheme.typography.h4,
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
)
|
||||
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))
|
||||
@@ -102,6 +98,7 @@ fun CreateProfilePanel(chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,14 @@ class CallManager(val chatModel: ChatModel) {
|
||||
)
|
||||
showCallView.value = true
|
||||
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
|
||||
callCommand.value = WCallCommand.Start (media = invitation.callType.media, aesKey = invitation.sharedKey, relay = useRelay)
|
||||
val iceServers = getIceServers()
|
||||
Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
|
||||
callCommand.value = WCallCommand.Start(
|
||||
media = invitation.callType.media,
|
||||
aesKey = invitation.sharedKey,
|
||||
iceServers = iceServers,
|
||||
relay = useRelay
|
||||
)
|
||||
callInvitations.remove(invitation.contact.id)
|
||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||
activeCallInvitation.value = null
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.*
|
||||
@@ -29,24 +34,43 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import androidx.webkit.WebViewClientCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
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 kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
fun ActiveCallView(chatModel: ChatModel) {
|
||||
BackHandler(onBack = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||
})
|
||||
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) SimplexService.start(SimplexApp.context)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// Stop it when call ended
|
||||
if (!ntfModeService) SimplexService.stop(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()) {
|
||||
@@ -122,6 +146,17 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) ActiveCallOverlay(call, chatModel)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
// Lock orientation to portrait in order to have good experience with calls
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
onDispose {
|
||||
// Unlock orientation
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -150,9 +185,19 @@ private fun setCallSound(cxt: Context, call: Call) {
|
||||
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 {
|
||||
am.mode = AudioManager.MODE_IN_CALL
|
||||
am.isSpeakerphoneOn = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE }?.let {
|
||||
am.setCommunicationDevice(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +382,8 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
||||
val wv = webView.value
|
||||
if (wv != null) processCommand(wv, WCallCommand.End)
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
webView.value?.destroy()
|
||||
webView.value = null
|
||||
}
|
||||
}
|
||||
LaunchedEffect(callCommand.value, webView.value) {
|
||||
|
||||
+11
-8
@@ -45,16 +45,19 @@ fun IncomingCallAlertLayout(
|
||||
ignoreCall: () -> Unit,
|
||||
acceptCall: () -> Unit
|
||||
) {
|
||||
val color = if (isSystemInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||
Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
|
||||
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(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
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)
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaPlayer
|
||||
import android.media.*
|
||||
import android.net.Uri
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import androidx.core.content.ContextCompat
|
||||
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
|
||||
|
||||
class SoundPlayer {
|
||||
var player: MediaPlayer? = null
|
||||
private var player: MediaPlayer? = null
|
||||
var playing = false
|
||||
|
||||
fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) {
|
||||
if (sound) player = MediaPlayer.create(cxt, R.raw.ring_once)
|
||||
player?.reset()
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.build()
|
||||
)
|
||||
setDataSource(SimplexApp.context, Uri.parse("android.resource://" + SimplexApp.context.packageName + "/" + R.raw.ring_once))
|
||||
prepare()
|
||||
}
|
||||
val vibrator = ContextCompat.getSystemService(cxt, Vibrator::class.java)
|
||||
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
playing = true
|
||||
|
||||
@@ -3,11 +3,13 @@ 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,
|
||||
@@ -115,7 +117,7 @@ sealed class WCallResponse {
|
||||
// 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 class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
|
||||
@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
|
||||
@@ -153,4 +155,48 @@ class ConnectionState(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,70 @@
|
||||
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.Composable
|
||||
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.SettingsActionItem
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
@Composable
|
||||
fun ChatInfoView(chatModel: ChatModel, connStats: ConnectionStats?, close: () -> Unit) {
|
||||
fun ChatInfoView(
|
||||
chatModel: ChatModel,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
close: () -> Unit,
|
||||
onChatUpdated: (Chat) -> 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, onChatUpdated)
|
||||
},
|
||||
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -82,10 +109,14 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit
|
||||
@Composable
|
||||
fun ChatInfoLayout(
|
||||
chat: Chat,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
deleteContact: () -> Unit,
|
||||
clearChat: () -> Unit
|
||||
clearChat: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -97,13 +128,27 @@ fun ChatInfoLayout(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
ChatInfoHeader(chat.chatInfo)
|
||||
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()
|
||||
|
||||
if (connStats != null) {
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
SectionItemView {
|
||||
SectionItemView({
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.network_status),
|
||||
chat.serverInfo.networkStatus.statusExplanation
|
||||
)}) {
|
||||
NetworkStatusRow(chat.serverInfo.networkStatus)
|
||||
}
|
||||
val rcvServers = connStats.rcvServers
|
||||
@@ -119,15 +164,10 @@ fun ChatInfoLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
ClearChatButton(clearChat)
|
||||
}
|
||||
ClearChatButton(clearChat)
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
DeleteContactButton(deleteContact)
|
||||
}
|
||||
DeleteContactButton(deleteContact)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
@@ -143,19 +183,20 @@ fun ChatInfoLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoHeader(cInfo: ChatInfo) {
|
||||
fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Text(
|
||||
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
@@ -167,16 +208,56 @@ fun ChatInfoHeader(cInfo: ChatInfo) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.network_status),
|
||||
networkStatus.statusExplanation
|
||||
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
|
||||
) {
|
||||
@@ -223,42 +304,39 @@ fun ServerImage(networkStatus: Chat.NetworkStatus) {
|
||||
@Composable
|
||||
fun SimplexServers(text: String, servers: List<String>) {
|
||||
val info = servers.joinToString(separator = ", ") { it.substringAfter("@") }
|
||||
InfoRow(text, info)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearChatButton(clearChat: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { clearChat() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Restore,
|
||||
stringResource(R.string.clear_chat_button),
|
||||
tint = WarningOrange
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.clear_chat_button), color = WarningOrange)
|
||||
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 DeleteContactButton(deleteContact: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { deleteContact() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_delete_contact),
|
||||
tint = Color.Red
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_delete_contact), color = Color.Red)
|
||||
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, onChatUpdated: (Chat) -> Unit) = withApi {
|
||||
chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let {
|
||||
chatModel.updateContact(it)
|
||||
onChatUpdated(chatModel.getChat(chatModel.chatId.value ?: return@withApi) ?: return@withApi)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,8 +350,12 @@ fun PreviewChatInfoLayout() {
|
||||
chatItems = arrayListOf(),
|
||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
||||
),
|
||||
Contact.sampleData,
|
||||
localAlias = "",
|
||||
developerTools = false,
|
||||
connStats = null,
|
||||
onLocalAliasChanged = {},
|
||||
customUserProfile = null,
|
||||
deleteContact = {}, clearChat = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.*
|
||||
@@ -28,14 +30,14 @@ import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.chat.group.AddGroupMembersView
|
||||
import chat.simplex.app.views.chat.group.GroupChatInfoView
|
||||
import chat.simplex.app.views.chat.item.ChatItemView
|
||||
import chat.simplex.app.views.chat.item.ItemAction
|
||||
import chat.simplex.app.views.chat.group.*
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.chatlist.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.AppBarHeight
|
||||
@@ -44,24 +46,44 @@ import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.Clock
|
||||
import java.io.File
|
||||
import kotlin.math.sign
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ChatView(chatModel: ChatModel) {
|
||||
var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
|
||||
val searchText = remember { mutableStateOf("") }
|
||||
val searchText = rememberSaveable { mutableStateOf("") }
|
||||
val user = chatModel.currentUser.value
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) }
|
||||
val attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }
|
||||
val composeState = rememberSaveable(saver = ComposeState.saver()) {
|
||||
mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews))
|
||||
}
|
||||
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
|
||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
|
||||
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
activeChat = if (chatModel.chatId.value == null) {
|
||||
null
|
||||
} else {
|
||||
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
|
||||
// Also for situation when chatId changes after clicking in notification, etc
|
||||
chatModel.getChat(chatModel.chatId.value!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeChat == null || user == null) {
|
||||
chatModel.chatId.value = null
|
||||
} else {
|
||||
val chat = activeChat!!
|
||||
BackHandler { chatModel.chatId.value = null }
|
||||
|
||||
// We need to have real unreadCount value for displaying it inside top right button
|
||||
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
||||
val unreadCount = remember {
|
||||
@@ -89,42 +111,31 @@ fun ChatView(chatModel: ChatModel) {
|
||||
chatModel.chatItems,
|
||||
searchText,
|
||||
useLinkPreviews = useLinkPreviews,
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
back = { chatModel.chatId.value = null },
|
||||
info = {
|
||||
withApi {
|
||||
val cInfo = chat.chatInfo
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
val connStats = chatModel.controller.apiContactInfo(cInfo.apiId)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
ChatInfoView(chatModel, connStats, close)
|
||||
val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) {
|
||||
activeChat = it
|
||||
}
|
||||
}
|
||||
} else if (cInfo is ChatInfo.Group) {
|
||||
setGroupMembers(cInfo.groupInfo, chatModel)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
GroupChatInfoView(chatModel, close)
|
||||
}
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
GroupChatInfoView(chatModel, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
openDirectChat = { contactId ->
|
||||
val c = chatModel.chats.firstOrNull {
|
||||
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
|
||||
}
|
||||
if (c != null) {
|
||||
withApi {
|
||||
openChat(c.chatInfo, chatModel)
|
||||
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
|
||||
activeChat = c
|
||||
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
|
||||
withApi {
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -174,13 +185,8 @@ fun ChatView(chatModel: ChatModel) {
|
||||
addMembers = { groupInfo ->
|
||||
withApi {
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -195,6 +201,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
)
|
||||
}
|
||||
},
|
||||
changeNtfsState = { enabled, currentValue -> changeNtfsStatePerChat(enabled, currentValue, chat, chatModel) },
|
||||
onSearchValueChanged = { value ->
|
||||
if (searchText.value == value) return@ChatLayout
|
||||
val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout
|
||||
@@ -220,9 +227,10 @@ fun ChatLayout(
|
||||
chatItems: List<ChatItem>,
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
chatModelIncognito: Boolean,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
@@ -231,6 +239,7 @@ fun ChatLayout(
|
||||
acceptCall: (Contact) -> Unit,
|
||||
addMembers: (GroupInfo) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
@@ -252,24 +261,22 @@ fun ChatLayout(
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
|
||||
val setFloatingButton = { button: @Composable () -> Unit ->
|
||||
floatingButton.value = button
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, onSearchValueChanged) },
|
||||
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, changeNtfsState, onSearchValueChanged) },
|
||||
bottomBar = composeView,
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
floatingActionButton = floatingButton.value,
|
||||
floatingActionButton = { floatingButton.value() },
|
||||
) { contentPadding ->
|
||||
CompositionLocalProvider(
|
||||
// Makes horizontal and vertical scrolling to coexist nicely.
|
||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
|
||||
) {
|
||||
BoxWithConstraints(Modifier.padding(contentPadding)) {
|
||||
ChatItemsList(
|
||||
user, chat, unreadCount, composeState, chatItems, searchValue,
|
||||
useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage,
|
||||
receiveFile, joinGroup, acceptCall, markRead, floatingButton
|
||||
)
|
||||
}
|
||||
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
|
||||
ChatItemsList(
|
||||
user, chat, unreadCount, composeState, chatItems, searchValue,
|
||||
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,10 +291,12 @@ fun ChatInfoToolbar(
|
||||
info: () -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
addMembers: (GroupInfo) -> Unit,
|
||||
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var showMenu by rememberSaveable { mutableStateOf(false) }
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
val onBackClicked = {
|
||||
if (!showSearch) {
|
||||
back()
|
||||
@@ -321,7 +330,7 @@ fun ChatInfoToolbar(
|
||||
startCall(CallMediaType.Video)
|
||||
})
|
||||
}
|
||||
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) {
|
||||
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
|
||||
barButtons.add {
|
||||
IconButton({
|
||||
showMenu = false
|
||||
@@ -331,6 +340,22 @@ fun ChatInfoToolbar(
|
||||
}
|
||||
}
|
||||
}
|
||||
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
|
||||
menuItems.add {
|
||||
ItemAction(
|
||||
if (ntfsEnabled.value) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
||||
if (ntfsEnabled.value) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
|
||||
onClick = {
|
||||
showMenu = false
|
||||
// Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu
|
||||
scope.launch {
|
||||
delay(200)
|
||||
changeNtfsState(!ntfsEnabled.value, ntfsEnabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
barButtons.add {
|
||||
IconButton({ showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
@@ -365,6 +390,9 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (cInfo.incognito) {
|
||||
IncognitoImage(size = 36.dp, Indigo)
|
||||
}
|
||||
ChatInfoImage(cInfo, size = imageSize, iconColor)
|
||||
Column(
|
||||
Modifier.padding(start = 8.dp),
|
||||
@@ -374,7 +402,7 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
|
||||
Text(
|
||||
cInfo.fullName,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
@@ -405,20 +433,20 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
chatItems: List<ChatItem>,
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
chatModelIncognito: Boolean,
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
floatingButton: MutableState<@Composable () -> Unit>
|
||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val cxt = LocalContext.current
|
||||
|
||||
// Helps to scroll to bottom after moving from Group to Direct chat
|
||||
// and prevents scrolling to bottom on orientation change
|
||||
var shouldAutoScroll by rememberSaveable { mutableStateOf(true) }
|
||||
@@ -447,88 +475,107 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
|
||||
LazyColumn(state = listState, reverseLayout = true) {
|
||||
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
|
||||
val scrollToItem: (Long) -> Unit = { itemId: Long ->
|
||||
val index = reversedChatItems.indexOfFirst { it.id == itemId }
|
||||
if (index != -1) {
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||
itemsIndexed(reversedChatItems) { i, cItem ->
|
||||
val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false }
|
||||
val directions = setOf(DismissDirection.EndToStart)
|
||||
val swipeableModifier = SwipeToDismissModifier(
|
||||
state = dismissState,
|
||||
directions = directions,
|
||||
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
|
||||
)
|
||||
val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd))
|
||||
val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart))
|
||||
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chat.chatInfo is ChatInfo.Group) {
|
||||
if (cItem.chatDir is CIDirection.GroupRcv) {
|
||||
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
|
||||
val member = cItem.chatDir.groupMember
|
||||
val showMember = showMemberImage(member, prevItem)
|
||||
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
|
||||
if (showMember) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId == null) {
|
||||
MemberImage(member)
|
||||
CompositionLocalProvider(
|
||||
// Makes horizontal and vertical scrolling to coexist nicely.
|
||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
|
||||
) {
|
||||
val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false }
|
||||
val directions = setOf(DismissDirection.EndToStart)
|
||||
val swipeableModifier = SwipeToDismissModifier(
|
||||
state = dismissState,
|
||||
directions = directions,
|
||||
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
|
||||
)
|
||||
val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd))
|
||||
val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart))
|
||||
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
openDirectChat(contactId)
|
||||
// Scroll to first unread message when direct chat will be loaded
|
||||
shouldAutoScroll = true
|
||||
}
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
val sent = cItem.chatDir.sent
|
||||
Box(
|
||||
Modifier.padding(
|
||||
start = if (sent) 76.dp else 12.dp,
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
).then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
|
||||
val provider = {
|
||||
providerForGallery(i, chatItems, cItem.id) { indexInReversed ->
|
||||
scope.launch {
|
||||
listState.scrollToItem(
|
||||
kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1),
|
||||
-maxHeightRounded
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chat.chatInfo is ChatInfo.Group) {
|
||||
if (cItem.chatDir is CIDirection.GroupRcv) {
|
||||
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
|
||||
val member = cItem.chatDir.groupMember
|
||||
val showMember = showMemberImage(member, prevItem)
|
||||
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
|
||||
if (showMember) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId == null) {
|
||||
MemberImage(member)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
showMemberInfo(chat.chatInfo.groupInfo, member)
|
||||
}
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
val sent = cItem.chatDir.sent
|
||||
Box(
|
||||
Modifier.padding(
|
||||
start = if (sent) 76.dp else 12.dp,
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
).then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cItem.isRcvNew) {
|
||||
LaunchedEffect(cItem.id) {
|
||||
scope.launch {
|
||||
delay(750)
|
||||
markRead(CC.ItemRange(cItem.id, cItem.id), null)
|
||||
if (cItem.isRcvNew) {
|
||||
LaunchedEffect(cItem.id) {
|
||||
scope.launch {
|
||||
delay(600)
|
||||
markRead(CC.ItemRange(cItem.id, cItem.id), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, floatingButton, listState)
|
||||
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -538,14 +585,13 @@ fun BoxWithConstraintsScope.FloatingButtons(
|
||||
minUnreadItemId: Long,
|
||||
searchValue: State<String>,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
floatingButton: MutableState<@Composable () -> Unit>,
|
||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||
listState: LazyListState
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) }
|
||||
var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) }
|
||||
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
|
||||
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
@@ -554,40 +600,43 @@ fun BoxWithConstraintsScope.FloatingButtons(
|
||||
firstVisibleIndex = it
|
||||
firstItemIsVisible = firstVisibleIndex == 0
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
// When both snapshotFlows located in one LaunchedEffect second block will never be called because coroutine is paused on first block
|
||||
// so separate them into two LaunchedEffects
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
lastIndexOfVisibleItems = it
|
||||
}
|
||||
}
|
||||
|
||||
val bottomUnreadCount by remember {
|
||||
derivedStateOf {
|
||||
if (unreadCount.value == 0) return@derivedStateOf 0
|
||||
|
||||
val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
|
||||
if (chatItems.size <= from || from < 0) return@derivedStateOf 0
|
||||
|
||||
chatItems.subList(from, chatItems.size).count { it.isRcvNew }
|
||||
}
|
||||
}
|
||||
|
||||
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
|
||||
|
||||
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
|
||||
val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty()
|
||||
val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
|
||||
floatingButton.value = bottomEndFloatingButton(
|
||||
bottomUnreadCount,
|
||||
showButtonWithCounter,
|
||||
showButtonWithArrow,
|
||||
onClickArrowDown = {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onClickCounter = {
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
|
||||
}
|
||||
)
|
||||
setFloatingButton(
|
||||
bottomEndFloatingButton(
|
||||
bottomUnreadCount,
|
||||
showButtonWithCounter,
|
||||
showButtonWithArrow,
|
||||
onClickArrowDown = {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onClickCounter = {
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
|
||||
}
|
||||
))
|
||||
}
|
||||
// Don't show top FAB if is in search
|
||||
if (searchValue.value.isNotEmpty()) return
|
||||
@@ -735,7 +784,70 @@ private fun bottomEndFloatingButton(
|
||||
}
|
||||
}
|
||||
|
||||
private fun ViewConfiguration.bigTouchSlop(slop: Float = 80f) = object: ViewConfiguration {
|
||||
private fun providerForGallery(
|
||||
listStateIndex: Int,
|
||||
chatItems: List<ChatItem>,
|
||||
cItemId: Long,
|
||||
scrollTo: (Int) -> Unit
|
||||
): ImageGalleryProvider {
|
||||
fun canShowImage(item: ChatItem): Boolean =
|
||||
item.content.msgContent is MsgContent.MCImage && item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null
|
||||
|
||||
fun item(skipInternalIndex: Int, initialChatId: Long): Pair<Int, ChatItem>? {
|
||||
var processedInternalIndex = -skipInternalIndex.sign
|
||||
val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId }
|
||||
for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) {
|
||||
val item = chatItems[chatItemsIndex]
|
||||
if (canShowImage(item)) {
|
||||
processedInternalIndex += skipInternalIndex.sign
|
||||
}
|
||||
if (processedInternalIndex == skipInternalIndex) {
|
||||
return chatItemsIndex to item
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
var initialIndex = Int.MAX_VALUE / 2
|
||||
var initialChatId = cItemId
|
||||
return object: ImageGalleryProvider {
|
||||
override val initialIndex: Int = initialIndex
|
||||
override val totalImagesSize = mutableStateOf(Int.MAX_VALUE)
|
||||
override fun getImage(index: Int): Pair<Bitmap, Uri>? {
|
||||
val internalIndex = initialIndex - index
|
||||
val file = item(internalIndex, initialChatId)?.second?.file
|
||||
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, file)
|
||||
return if (imageBitmap != null && filePath != null) {
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
imageBitmap to uri
|
||||
} else null
|
||||
}
|
||||
|
||||
override fun currentPageChanged(index: Int) {
|
||||
val internalIndex = initialIndex - index
|
||||
val item = item(internalIndex, initialChatId) ?: return
|
||||
initialIndex = index
|
||||
initialChatId = item.second.id
|
||||
}
|
||||
|
||||
override fun scrollToStart() {
|
||||
initialIndex = 0
|
||||
initialChatId = chatItems.first { canShowImage(it) }.id
|
||||
}
|
||||
|
||||
override fun onDismiss(index: Int) {
|
||||
val internalIndex = initialIndex - index
|
||||
val indexInChatItems = item(internalIndex, initialChatId)?.first ?: return
|
||||
val indexInReversed = chatItems.lastIndex - indexInChatItems
|
||||
// Do not scroll to active item, just to different items
|
||||
if (indexInReversed == listStateIndex) return
|
||||
scrollTo(indexInReversed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
|
||||
override val longPressTimeoutMillis
|
||||
get() =
|
||||
this@bigTouchSlop.longPressTimeoutMillis
|
||||
@@ -793,9 +905,10 @@ fun PreviewChatLayout() {
|
||||
chatItems = chatItems,
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
chatModelIncognito = false,
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
showMemberInfo = { _, _ -> },
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
@@ -804,6 +917,7 @@ fun PreviewChatLayout() {
|
||||
acceptCall = { _ -> },
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
changeNtfsState = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
)
|
||||
}
|
||||
@@ -849,9 +963,10 @@ fun PreviewGroupChatLayout() {
|
||||
chatItems = chatItems,
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
chatModelIncognito = false,
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
showMemberInfo = { _, _ -> },
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
@@ -860,6 +975,7 @@ fun PreviewGroupChatLayout() {
|
||||
acceptCall = { _ -> },
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
changeNtfsState = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -31,7 +30,7 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
tint = if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
Text(fileName)
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
@@ -1,45 +1,51 @@
|
||||
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.Composable
|
||||
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(image: String, cancelImage: () -> Unit, cancelEnabled: Boolean) {
|
||||
fun ComposeImageView(images: List<String>, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val imageBitmap = base64ToBitmap(image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"preview image",
|
||||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.height(60.dp)
|
||||
.padding(end = 8.dp)
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
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 = cancelImage, modifier = Modifier.padding(0.dp)) {
|
||||
IconButton(onClick = cancelImages) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import ComposeFileView
|
||||
import ComposeImageView
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
@@ -26,6 +25,7 @@ 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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -42,21 +42,26 @@ 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 {
|
||||
object NoPreview: ComposePreview()
|
||||
class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||
class ImagePreview(val image: String): ComposePreview()
|
||||
class FilePreview(val fileName: String): ComposePreview()
|
||||
@Serializable object NoPreview: ComposePreview()
|
||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
||||
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ComposeContextItem {
|
||||
object NoContextItem: ComposeContextItem()
|
||||
class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
|
||||
class EditingItem(val chatItem: ChatItem): 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,
|
||||
@@ -99,13 +104,22 @@ data class ComposeState(
|
||||
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(image = mc.image)
|
||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
||||
is MsgContent.MCFile -> {
|
||||
val fileName = chatItem.file?.fileName ?: ""
|
||||
ComposePreview.FilePreview(fileName)
|
||||
@@ -131,7 +145,7 @@ fun ComposeView(
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
// attachments
|
||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||
val chosenContent = remember { mutableStateOf<List<UploadContent>>(emptyList()) }
|
||||
val chosenFile = remember { mutableStateOf<Uri?>(null) }
|
||||
val photoUri = remember { mutableStateOf<Uri?>(null) }
|
||||
val photoTmpFile = remember { mutableStateOf<File?>(null) }
|
||||
@@ -168,9 +182,9 @@ fun ComposeView(
|
||||
|
||||
val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? ->
|
||||
if (bitmap != null) {
|
||||
chosenImage.value = bitmap
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
|
||||
chosenContent.value = listOf(UploadContent.SimpleImage(bitmap))
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview)))
|
||||
}
|
||||
}
|
||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
@@ -180,23 +194,46 @@ fun ComposeView(
|
||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
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 bitmap = ImageDecoder.decodeBitmap(source)
|
||||
chosenImage.value = bitmap
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
|
||||
val drawable = ImageDecoder.decodeDrawable(source)
|
||||
var bitmap: Bitmap? = ImageDecoder.decodeBitmap(source)
|
||||
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 {
|
||||
if (bitmap != null) content.add(UploadContent.SimpleImage(bitmap))
|
||||
}
|
||||
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 filesLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
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(preview = ComposePreview.FilePreview(fileName))
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName))
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -206,6 +243,9 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { processPickedImage(it, null) }
|
||||
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||
|
||||
LaunchedEffect(attachmentOption.value) {
|
||||
when (attachmentOption.value) {
|
||||
@@ -221,7 +261,11 @@ fun ComposeView(
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.PickImage -> {
|
||||
galleryLauncher.launch("image/*")
|
||||
try {
|
||||
galleryLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryLauncherFallback.launch("image/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.PickFile -> {
|
||||
@@ -306,7 +350,7 @@ fun ComposeView(
|
||||
fun clearState() {
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
textStyle.value = smallFont
|
||||
chosenImage.value = null
|
||||
chosenContent.value = emptyList()
|
||||
chosenFile.value = null
|
||||
linkUrl.value = null
|
||||
prevLinkUrl.value = null
|
||||
@@ -336,26 +380,30 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
var mc: MsgContent? = null
|
||||
var file: String? = null
|
||||
val msgs: ArrayList<MsgContent> = ArrayList()
|
||||
val files: ArrayList<String> = ArrayList()
|
||||
when (val preview = cs.preview) {
|
||||
ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
|
||||
is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
|
||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message))
|
||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||
is ComposePreview.ImagePreview -> {
|
||||
val chosenImageVal = chosenImage.value
|
||||
if (chosenImageVal != null) {
|
||||
file = saveImage(context, chosenImageVal)
|
||||
chosenContent.value.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.bitmap)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
}
|
||||
if (file != null) {
|
||||
mc = MsgContent.MCImage(cs.message, preview.image)
|
||||
files.add(file)
|
||||
msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
file = saveFileFromUri(context, chosenFileVal)
|
||||
val file = saveFileFromUri(context, chosenFileVal)
|
||||
if (file != null) {
|
||||
mc = MsgContent.MCFile(cs.message)
|
||||
files.add((file))
|
||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,17 +412,19 @@ fun ComposeView(
|
||||
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (mc != null) {
|
||||
if (msgs.isNotEmpty()) {
|
||||
withApi {
|
||||
val aChatItem = chatModel.controller.apiSendMessage(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
file = file,
|
||||
quotedItemId = quotedItemId,
|
||||
mc = mc
|
||||
)
|
||||
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
||||
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 {
|
||||
@@ -406,9 +456,9 @@ fun ComposeView(
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
}
|
||||
|
||||
fun cancelImage() {
|
||||
fun cancelImages() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenImage.value = null
|
||||
chosenContent.value = emptyList()
|
||||
}
|
||||
|
||||
fun cancelFile() {
|
||||
@@ -422,8 +472,8 @@ fun ComposeView(
|
||||
ComposePreview.NoPreview -> {}
|
||||
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
|
||||
is ComposePreview.ImagePreview -> ComposeImageView(
|
||||
preview.image,
|
||||
::cancelImage,
|
||||
preview.images,
|
||||
::cancelImages,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
is ComposePreview.FilePreview -> ComposeFileView(
|
||||
@@ -447,6 +497,16 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(chatModel.sharedContent.value) {
|
||||
when (val shared = chatModel.sharedContent.value) {
|
||||
is SharedContent.Text -> onMessageChange(shared.text)
|
||||
is SharedContent.Image -> processPickedImage(listOf(shared.uri), shared.text)
|
||||
is SharedContent.File -> processPickedFile(shared.uri, shared.text)
|
||||
null -> {}
|
||||
}
|
||||
chatModel.sharedContent.value = null
|
||||
}
|
||||
|
||||
Column {
|
||||
contextItemView()
|
||||
when {
|
||||
@@ -486,3 +546,29 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PickFromGallery: 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()
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ fun ContextItemView(
|
||||
)
|
||||
MarkdownText(
|
||||
contextItem.text, contextItem.formattedText,
|
||||
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3
|
||||
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = cancelContextItem) {
|
||||
|
||||
@@ -18,17 +18,21 @@ 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.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.text.BidiFormatter
|
||||
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 kotlinx.coroutines.delay
|
||||
import kotlin.math.min
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@@ -48,7 +52,7 @@ fun SendMsgView(
|
||||
delay(50)
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
val isRtl = remember(cs.message) { BidiFormatter.getInstance().isRtl(cs.message.subSequence(0, min(50, cs.message.length))) }
|
||||
BasicTextField(
|
||||
value = cs.message,
|
||||
onValueChange = onMessageChange,
|
||||
@@ -69,14 +73,18 @@ fun SendMsgView(
|
||||
Modifier.background(MaterialTheme.colors.background),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 5.dp)
|
||||
.padding(bottom = 7.dp)
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
) {
|
||||
innerTextField()
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 5.dp)
|
||||
.padding(bottom = 7.dp)
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
|
||||
+84
-113
@@ -11,11 +11,14 @@ 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.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -26,11 +29,12 @@ 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, chatModel: ChatModel, close: () -> Unit) {
|
||||
val selectedContacts = remember { mutableStateListOf<Long>() }
|
||||
val selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) }
|
||||
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
|
||||
|
||||
BackHandler(onBack = close)
|
||||
AddGroupMembersLayout(
|
||||
@@ -40,10 +44,12 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () ->
|
||||
selectedRole = selectedRole,
|
||||
inviteMembers = {
|
||||
withApi {
|
||||
selectedContacts.forEach {
|
||||
val member = chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value)
|
||||
for (contactId in selectedContacts) {
|
||||
val member = chatModel.controller.apiAddMember(groupInfo.groupId, contactId, selectedRole.value)
|
||||
if (member != null) {
|
||||
chatModel.upsertGroupMember(groupInfo, member)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
close.invoke()
|
||||
@@ -86,6 +92,7 @@ fun AddGroupMembersLayout(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.button_add_members))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
@@ -93,7 +100,7 @@ fun AddGroupMembersLayout(
|
||||
ChatInfoToolbarTitle(
|
||||
ChatInfo.Group(groupInfo),
|
||||
imageSize = 60.dp,
|
||||
iconColor = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight
|
||||
iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight
|
||||
)
|
||||
}
|
||||
SectionSpacer()
|
||||
@@ -115,9 +122,7 @@ fun AddGroupMembersLayout(
|
||||
RoleSelectionRow(groupInfo, selectedRole)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty())
|
||||
}
|
||||
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty())
|
||||
}
|
||||
SectionCustomFooter {
|
||||
InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection)
|
||||
@@ -125,7 +130,7 @@ fun AddGroupMembersLayout(
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
ContactList(contacts = contactsToAdd, selectedContacts, addContact, removeContact)
|
||||
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, addContact, removeContact)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
@@ -133,99 +138,49 @@ fun AddGroupMembersLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
|
||||
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(stringResource(R.string.new_member_role))
|
||||
RoleDropdownMenu(groupInfo, selectedRole)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RoleDropdownMenu(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
|
||||
val options = GroupMemberRole.values()
|
||||
.filter { it <= groupInfo.membership.memberRole }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(0.7f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(
|
||||
selectedRole.value.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Icon(
|
||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
generalGetString(R.string.invite_to_group_button),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
options.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
selectedRole.value = selectionOption
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
selectionOption.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InviteMembersButton(inviteMembers: () -> Unit, disabled: Boolean) {
|
||||
val modifier = if (disabled) Modifier else Modifier.clickable { inviteMembers() }
|
||||
Row(
|
||||
modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
Text(stringResource(R.string.invite_to_group_button), color = color)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Icon(
|
||||
Icons.Outlined.Check,
|
||||
stringResource(R.string.invite_to_group_button),
|
||||
tint = color
|
||||
val values = GroupMemberRole.values().filter { it <= groupInfo.membership.memberRole }.map { it to it.text }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.new_member_role),
|
||||
values,
|
||||
selectedRole,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = { selectedRole.value = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Check,
|
||||
stringResource(R.string.invite_to_group_button),
|
||||
click = onClick,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
disabled = disabled,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (selectedContactsCount >= 1) Arrangement.SpaceBetween else Arrangement.End,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (selectedContactsCount >= 1) {
|
||||
Text(
|
||||
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Box(
|
||||
Modifier.clickable { clearSelection() }
|
||||
) {
|
||||
@@ -235,12 +190,6 @@ fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit)
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.no_contacts_selected),
|
||||
@@ -255,17 +204,16 @@ fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit)
|
||||
fun ContactList(
|
||||
contacts: List<Contact>,
|
||||
selectedContacts: SnapshotStateList<Long>,
|
||||
groupInfo: GroupInfo,
|
||||
addContact: (Long) -> Unit,
|
||||
removeContact: (Long) -> Unit
|
||||
) {
|
||||
Column {
|
||||
contacts.forEachIndexed { index, contact ->
|
||||
SectionItemView {
|
||||
ContactCheckRow(
|
||||
contact, addContact, removeContact,
|
||||
checked = selectedContacts.contains(contact.apiId)
|
||||
)
|
||||
}
|
||||
ContactCheckRow(
|
||||
contact, groupInfo, addContact, removeContact,
|
||||
checked = selectedContacts.contains(contact.apiId)
|
||||
)
|
||||
if (index < contacts.lastIndex) {
|
||||
SectionDivider()
|
||||
}
|
||||
@@ -276,32 +224,55 @@ fun ContactList(
|
||||
@Composable
|
||||
fun ContactCheckRow(
|
||||
contact: Contact,
|
||||
groupInfo: GroupInfo,
|
||||
addContact: (Long) -> Unit,
|
||||
removeContact: (Long) -> Unit,
|
||||
checked: Boolean
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { if (!checked) addContact(contact.apiId) else removeContact(contact.apiId) },
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
ProfileImage(size = 36.dp, contact.image)
|
||||
Text(contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
|
||||
val icon: ImageVector
|
||||
val iconColor: Color
|
||||
if (prohibitedToInviteIncognito) {
|
||||
icon = Icons.Filled.TheaterComedy
|
||||
iconColor = HighOrLowlight
|
||||
} else if (checked) {
|
||||
icon = Icons.Filled.CheckCircle
|
||||
iconColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
icon = Icons.Outlined.Circle
|
||||
iconColor = HighOrLowlight
|
||||
}
|
||||
SectionItemView(click = {
|
||||
if (prohibitedToInviteIncognito) {
|
||||
showProhibitedToInviteIncognitoAlertDialog()
|
||||
} else if (!checked)
|
||||
addContact(contact.apiId)
|
||||
else
|
||||
removeContact(contact.apiId)
|
||||
}) {
|
||||
ProfileImage(size = 36.dp, contact.image)
|
||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
||||
Text(
|
||||
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Icon(
|
||||
if (checked) Icons.Filled.CheckCircle else Icons.Outlined.Circle,
|
||||
icon,
|
||||
contentDescription = stringResource(R.string.icon_descr_contact_checked),
|
||||
tint = if (checked) MaterialTheme.colors.primary else HighOrLowlight
|
||||
tint = iconColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showProhibitedToInviteIncognitoAlertDialog() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.invite_prohibited),
|
||||
text = generalGetString(R.string.invite_prohibited_description),
|
||||
confirmText = generalGetString(R.string.ok),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewAddGroupMembersLayout() {
|
||||
|
||||
+68
-60
@@ -11,11 +11,12 @@ 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.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.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
|
||||
@@ -24,6 +25,7 @@ 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.*
|
||||
|
||||
@@ -44,43 +46,36 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
addMembers = {
|
||||
withApi {
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
}
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
}
|
||||
}
|
||||
},
|
||||
showMemberInfo = { member ->
|
||||
withApi {
|
||||
val connStats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
GroupMemberInfoView(groupInfo, member, connStats, chatModel, close)
|
||||
}
|
||||
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) }
|
||||
},
|
||||
deleteGroup = { deleteGroupDialog(chat.chatInfo, chatModel, close) },
|
||||
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }
|
||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteGroupDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
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(R.string.delete_group_for_all_members_cannot_undo_warning),
|
||||
text = generalGetString(alertTextKey),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
@@ -133,18 +128,20 @@ fun GroupChatInfoLayout(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
ChatInfoHeader(chat.chatInfo)
|
||||
GroupChatInfoHeader(chat.chatInfo)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
||||
if (groupInfo.canAddMembers) {
|
||||
SectionItemView {
|
||||
AddMembersButton(addMembers)
|
||||
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(height = 50.dp) {
|
||||
SectionItemView(minHeight = 50.dp) {
|
||||
MemberRow(groupInfo.membership, user = true)
|
||||
}
|
||||
if (members.isNotEmpty()) {
|
||||
@@ -156,25 +153,17 @@ fun GroupChatInfoLayout(
|
||||
|
||||
SectionView {
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView {
|
||||
EditGroupProfileButton(editGroupProfile)
|
||||
}
|
||||
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
|
||||
SectionDivider()
|
||||
}
|
||||
SectionItemView {
|
||||
ClearChatButton(clearChat)
|
||||
}
|
||||
ClearChatButton(clearChat)
|
||||
if (groupInfo.canDelete) {
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
DeleteGroupButton(deleteGroup)
|
||||
}
|
||||
SectionItemView(deleteGroup) { DeleteGroupButton() }
|
||||
}
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
LeaveGroupButton(leaveGroup)
|
||||
}
|
||||
SectionItemView(leaveGroup) { LeaveGroupButton() }
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
@@ -191,20 +180,42 @@ fun GroupChatInfoLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddMembersButton(addMembers: () -> Unit) {
|
||||
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
|
||||
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { addMembers() },
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Add,
|
||||
stringResource(R.string.button_add_members),
|
||||
tint = MaterialTheme.colors.primary
|
||||
tint = tint
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_add_members), color = MaterialTheme.colors.primary)
|
||||
Text(stringResource(R.string.button_add_members), color = tint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +223,8 @@ fun AddMembersButton(addMembers: () -> Unit) {
|
||||
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
||||
Column {
|
||||
members.forEachIndexed { index, member ->
|
||||
SectionItemView(height = 50.dp) {
|
||||
MemberRow(member, showMemberInfo)
|
||||
SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
|
||||
MemberRow(member)
|
||||
}
|
||||
if (index < members.lastIndex) {
|
||||
SectionDivider()
|
||||
@@ -223,10 +234,9 @@ fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Uni
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = null, user: Boolean = false) {
|
||||
val modifier = if (showMemberInfo != null) Modifier.clickable { showMemberInfo(member) } else Modifier
|
||||
fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
Row(
|
||||
modifier.fillMaxSize(),
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -236,7 +246,10 @@ fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = nu
|
||||
) {
|
||||
ProfileImage(size = 46.dp, member.image)
|
||||
Column {
|
||||
Text(member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
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(
|
||||
@@ -256,11 +269,10 @@ fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = nu
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditGroupProfileButton(editGroupProfile: () -> Unit) {
|
||||
fun EditGroupProfileButton() {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { editGroupProfile() },
|
||||
.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
@@ -274,11 +286,9 @@ fun EditGroupProfileButton(editGroupProfile: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LeaveGroupButton(leaveGroup: () -> Unit) {
|
||||
fun LeaveGroupButton() {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { leaveGroup() },
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
@@ -292,11 +302,9 @@ fun LeaveGroupButton(leaveGroup: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteGroupButton(deleteGroup: () -> Unit) {
|
||||
fun DeleteGroupButton() {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { deleteGroup() },
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
@@ -322,7 +330,7 @@ fun PreviewGroupChatInfoLayout() {
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+128
-20
@@ -11,7 +11,7 @@ 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.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -24,20 +24,64 @@ 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.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) {
|
||||
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,
|
||||
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }
|
||||
openDirectChat = {
|
||||
withApi {
|
||||
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
|
||||
if (oldChat != null) {
|
||||
openChat(oldChat.chatInfo, chatModel)
|
||||
} else {
|
||||
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
|
||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
||||
newChat = newChat.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -64,8 +108,11 @@ fun GroupMemberInfoLayout(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
newRole: MutableState<GroupMemberRole>,
|
||||
developerTools: Boolean,
|
||||
openDirectChat: () -> Unit,
|
||||
removeMember: () -> Unit,
|
||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -81,8 +128,22 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
OpenChatButton(openDirectChat)
|
||||
}
|
||||
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()
|
||||
@@ -113,11 +174,9 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
}
|
||||
|
||||
if (member.canBeRemoved(groupInfo.membership)) {
|
||||
if (member.canBeRemoved(groupInfo)) {
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
RemoveMemberButton(removeMember)
|
||||
}
|
||||
RemoveMemberButton(removeMember)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
@@ -139,7 +198,7 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ProfileImage(size = 192.dp, member.image, color = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
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,
|
||||
@@ -158,23 +217,69 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoveMemberButton(removeMember: () -> Unit) {
|
||||
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
|
||||
.fillMaxSize()
|
||||
.clickable { removeMember() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_remove_member),
|
||||
tint = Color.Red
|
||||
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
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_remove_member), color = Color.Red)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewGroupMemberInfoLayout() {
|
||||
@@ -183,8 +288,11 @@ fun PreviewGroupMemberInfoLayout() {
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
member = GroupMember.sampleData,
|
||||
connStats = null,
|
||||
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
||||
developerTools = false,
|
||||
removeMember = {}
|
||||
openDirectChat = {},
|
||||
removeMember = {},
|
||||
onRoleSelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -17,14 +17,14 @@ 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.SimpleXTheme
|
||||
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
|
||||
@@ -78,7 +78,7 @@ fun GroupProfileLayout(
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = 16.dp),
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
@@ -147,6 +147,7 @@ fun GroupProfileLayout(
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@@ -39,7 +38,7 @@ fun CIFileView(
|
||||
@Composable
|
||||
fun fileIcon(
|
||||
innerIcon: ImageVector? = null,
|
||||
color: Color = if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
color: Color = if (isInDarkTheme()) FileDark else FileLight
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -105,7 +104,7 @@ fun CIFileView(
|
||||
fun progressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(32.dp),
|
||||
color = if (isSystemInDarkTheme()) FileDark else FileLight,
|
||||
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
}
|
||||
@@ -206,6 +205,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
|
||||
FramedItemView(ChatInfo.Direct.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
|
||||
}
|
||||
}
|
||||
|
||||
+10
-12
@@ -3,7 +3,6 @@ package chat.simplex.app.views.chat.item
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@@ -19,7 +18,6 @@ import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
@@ -29,6 +27,7 @@ fun CIGroupInvitationView(
|
||||
ci: ChatItem,
|
||||
groupInvitation: CIGroupInvitation,
|
||||
memberRole: GroupMemberRole,
|
||||
chatIncognito: Boolean = false,
|
||||
joinGroup: (Long) -> Unit
|
||||
) {
|
||||
val sent = ci.chatDir.sent
|
||||
@@ -38,8 +37,8 @@ fun CIGroupInvitationView(
|
||||
fun groupInfoView() {
|
||||
val p = groupInvitation.groupProfile
|
||||
val iconColor =
|
||||
if (action) MaterialTheme.colors.primary
|
||||
else if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary
|
||||
else if (isInDarkTheme()) FileDark else FileLight
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
@@ -47,7 +46,7 @@ fun CIGroupInvitationView(
|
||||
.padding(vertical = 4.dp)
|
||||
.padding(end = 2.dp)
|
||||
) {
|
||||
ProfileImage(size = 60.dp, icon = Icons.Filled.SupervisedUserCircle, 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),
|
||||
@@ -72,13 +71,10 @@ fun CIGroupInvitationView(
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptInvitation() {
|
||||
Log.d(TAG, "CIGroupInvitationView acceptInvitation")
|
||||
joinGroup(groupInvitation.groupId)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = if (action) Modifier.clickable(onClick = ::acceptInvitation) else Modifier,
|
||||
modifier = if (action) Modifier.clickable(onClick = {
|
||||
joinGroup(groupInvitation.groupId)
|
||||
}) else Modifier,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (sent) SentColorLight else ReceivedColorLight,
|
||||
) {
|
||||
@@ -100,7 +96,9 @@ fun CIGroupInvitationView(
|
||||
Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp))
|
||||
if (action) {
|
||||
groupInvitationText()
|
||||
Text(stringResource(R.string.group_invitation_tap_to_join), color = MaterialTheme.colors.primary)
|
||||
Text(stringResource(
|
||||
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)) {
|
||||
groupInvitationText()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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.*
|
||||
@@ -6,6 +7,7 @@ import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ArrowDownward
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -13,19 +15,32 @@ 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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIFile
|
||||
import chat.simplex.app.model.CIFileStatus
|
||||
import chat.simplex.app.views.chat.item.ImageFullScreenView
|
||||
import chat.simplex.app.views.chat.item.ImageGalleryProvider
|
||||
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
|
||||
) {
|
||||
@@ -65,6 +80,13 @@ fun CIImageView(
|
||||
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 -> {}
|
||||
}
|
||||
}
|
||||
@@ -88,13 +110,55 @@ fun CIImageView(
|
||||
)
|
||||
}
|
||||
|
||||
@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(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
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
val context = LocalContext.current
|
||||
val imageBitmap: Bitmap? = getLoadedImage(context, file)
|
||||
if (imageBitmap != null) {
|
||||
imageView(imageBitmap, onClick = {
|
||||
val filePath = getLoadedFilePath(context, file)
|
||||
if (imageBitmap != null && filePath != null) {
|
||||
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
val imageLoader = ImageLoader.Builder(context)
|
||||
.components {
|
||||
if (SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
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
|
||||
)
|
||||
imageView(imagePainter, onClick = {
|
||||
if (getLoadedFilePath(context, file) != null) {
|
||||
ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) }
|
||||
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||
ImageFullScreenView(imageProvider, close)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -102,7 +166,14 @@ fun CIImageView(
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation ->
|
||||
receiveFile(file.fileId)
|
||||
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),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -16,7 +15,6 @@ 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.SimplexBlue
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
@@ -56,7 +54,7 @@ fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
|
||||
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
|
||||
}
|
||||
is CIStatus.RcvNew -> {
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue)
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -18,6 +19,7 @@ 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
|
||||
@@ -34,12 +36,15 @@ fun ChatItemView(
|
||||
composeState: MutableState<ComposeState>,
|
||||
cxt: Context,
|
||||
uriHandler: UriHandler? = null,
|
||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||
showMember: Boolean = false,
|
||||
chatModelIncognito: Boolean,
|
||||
useLinkPreviews: Boolean,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit
|
||||
acceptCall: (Contact) -> Unit,
|
||||
scrollToItem: (Long) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sent = cItem.chatDir.sent
|
||||
@@ -62,7 +67,7 @@ fun ChatItemView(
|
||||
EmojiItemView(cItem)
|
||||
} else {
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick)
|
||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
@@ -78,7 +83,11 @@ fun ChatItemView(
|
||||
showMenu.value = false
|
||||
})
|
||||
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
||||
shareText(cxt, cItem.content.text)
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
||||
when {
|
||||
filePath != null -> shareFile(cxt, cItem.text, filePath)
|
||||
else -> shareText(cxt, cItem.content.text)
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
|
||||
@@ -147,8 +156,8 @@ fun ChatItemView(
|
||||
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)
|
||||
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup)
|
||||
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 -> CIGroupEventView(cItem)
|
||||
is CIContent.SndGroupEventContent -> CIGroupEventView(cItem)
|
||||
}
|
||||
@@ -184,13 +193,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = {
|
||||
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))
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_everybody)) }
|
||||
@@ -213,10 +222,12 @@ fun PreviewChatItemView() {
|
||||
useLinkPreviews = true,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
cxt = LocalContext.current,
|
||||
chatModelIncognito = false,
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> }
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -232,10 +243,12 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
useLinkPreviews = true,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
cxt = LocalContext.current,
|
||||
chatModelIncognito = false,
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> }
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,19 @@ fun EmojiText(text: String) {
|
||||
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
|
||||
}
|
||||
|
||||
private fun isSimpleEmoji(c: Int): Boolean = c > 0x238C
|
||||
// https://stackoverflow.com/a/46279500
|
||||
private const val emojiStr = "^(" +
|
||||
"(?:[\\u2700-\\u27bf]|" +
|
||||
"(?:[\\ud83c\\udde6-\\ud83c\\uddff]){2}|" +
|
||||
"[\\ud800\\udc00-\\uDBFF\\uDFFF]|[\\u2600-\\u26FF])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0]|[\\ud83c\\udffb-\\ud83c\\udfff])?" +
|
||||
"(?:\\u200d(?:[^\\ud800-\\udfff]|" +
|
||||
"(?:[\\ud83c\\udde6-\\ud83c\\uddff]){2}|" +
|
||||
"[\\ud800\\udc00-\\uDBFF\\uDFFF]|[\\u2600-\\u26FF])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0]|[\\ud83c\\udffb-\\ud83c\\udfff])?)*|" +
|
||||
"[\\u0023-\\u0039]\\ufe0f?\\u20e3|\\u3299|\\u3297|\\u303d|\\u3030|\\u24c2|[\\ud83c\\udd70-\\ud83c\\udd71]|[\\ud83c\\udd7e-\\ud83c\\udd7f]|\\ud83c\\udd8e|[\\ud83c\\udd91-\\ud83c\\udd9a]|[\\ud83c\\udde6-\\ud83c\\uddff]|[\\ud83c\\ude01-\\ud83c\\ude02]|\\ud83c\\ude1a|\\ud83c\\ude2f|[\\ud83c\\ude32-\\ud83c\\ude3a]|[\\ud83c\\ude50-\\ud83c\\ude51]|\\u203c|\\u2049|[\\u25aa-\\u25ab]|\\u25b6|\\u25c0|[\\u25fb-\\u25fe]|\\u00a9|\\u00ae|\\u2122|\\u2139|\\ud83c\\udc04|[\\u2600-\\u26FF]|\\u2b05|\\u2b06|\\u2b07|\\u2b1b|\\u2b1c|\\u2b50|\\u2b55|\\u231a|\\u231b|\\u2328|\\u23cf|[\\u23e9-\\u23f3]|[\\u23f8-\\u23fa]|\\ud83c\\udccf|\\u2934|\\u2935|[\\u2190-\\u21ff]" +
|
||||
")+$" // Multiple matches with emojis where one follows another without interruptions from other characters
|
||||
private val emojiRegex = Regex(emojiStr)
|
||||
|
||||
fun isEmoji(c: Int): Boolean = isSimpleEmoji(c) // || isCombinedIntoEmoji(c)
|
||||
|
||||
// TODO count perceived emojis, possibly using icu4j
|
||||
fun isShortEmoji(str: String): Boolean {
|
||||
val s = str.trim()
|
||||
return s.codePoints().count() in 1..5 && s.codePoints().allMatch(::isEmoji)
|
||||
return s.codePoints().count() in 1..5 && emojiRegex.matches(str)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
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.asImageBitmap
|
||||
@@ -34,16 +35,22 @@ val ReceivedQuoteColorLight = Color(0x25B1B0B5)
|
||||
|
||||
@Composable
|
||||
fun FramedItemView(
|
||||
user: User,
|
||||
chatInfo: ChatInfo,
|
||||
ci: ChatItem,
|
||||
uriHandler: UriHandler? = null,
|
||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||
showMember: Boolean = false,
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
scrollToItem: (Long) -> Unit = {},
|
||||
) {
|
||||
val sent = ci.chatDir.sent
|
||||
|
||||
fun membership(): GroupMember? {
|
||||
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ciQuotedMsgView(qi: CIQuote) {
|
||||
Box(
|
||||
@@ -51,7 +58,7 @@ fun FramedItemView(
|
||||
contentAlignment = Alignment.TopStart
|
||||
) {
|
||||
MarkdownText(
|
||||
qi.text, qi.formattedText, sender = qi.sender(user), senderBold = true, maxLines = 3,
|
||||
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
||||
)
|
||||
}
|
||||
@@ -63,6 +70,10 @@ fun FramedItemView(
|
||||
Modifier
|
||||
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
|
||||
)
|
||||
) {
|
||||
when (qi.content) {
|
||||
is MsgContent.MCImage -> {
|
||||
@@ -87,7 +98,7 @@ fun FramedItemView(
|
||||
Modifier
|
||||
.padding(top = 6.dp, end = 4.dp)
|
||||
.size(22.dp),
|
||||
tint = if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
}
|
||||
else -> ciQuotedMsgView(qi)
|
||||
@@ -95,10 +106,16 @@ fun FramedItemView(
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (sent) SentColorLight else ReceivedColorLight
|
||||
) {
|
||||
val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
Box(Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(
|
||||
when {
|
||||
transparentBackground -> Color.Transparent
|
||||
sent -> SentColorLight
|
||||
else -> ReceivedColorLight
|
||||
}
|
||||
)) {
|
||||
var metaColor = HighOrLowlight
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||
@@ -122,7 +139,7 @@ fun FramedItemView(
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
when (val mc = ci.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
CIImageView(image = mc.image, file = ci.file, showMenu, receiveFile)
|
||||
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@Box, showMenu, receiveFile)
|
||||
if (mc.text == "") {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
@@ -177,7 +194,7 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
|
||||
),
|
||||
@@ -193,7 +210,7 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
|
||||
),
|
||||
@@ -209,7 +226,7 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1,
|
||||
CIDirection.DirectSnd(),
|
||||
@@ -229,7 +246,7 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
@@ -250,7 +267,7 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
@@ -278,7 +295,7 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
@@ -306,7 +323,7 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
@@ -333,7 +350,7 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
|
||||
+127
-41
@@ -1,55 +1,141 @@
|
||||
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.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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
|
||||
|
||||
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(imageBitmap: Bitmap, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(onClick = close)
|
||||
) {
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var translationX by remember { mutableStateOf(0f) }
|
||||
var translationY by remember { mutableStateOf(0f) }
|
||||
Image(
|
||||
imageBitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = translationX,
|
||||
translationY = translationY,
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures(
|
||||
onGesture = { _, pan, gestureZoom, _ ->
|
||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||
if (scale > 1) {
|
||||
translationX += pan.x * scale
|
||||
translationY += pan.y * scale
|
||||
} else {
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
}
|
||||
)
|
||||
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
|
||||
}
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
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
|
||||
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) }
|
||||
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
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = translationX,
|
||||
translationY = translationY,
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures(
|
||||
onGesture = { _, pan, gestureZoom, _ ->
|
||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||
if (scale > 1) {
|
||||
translationX += pan.x * scale
|
||||
translationY += pan.y * scale
|
||||
} else {
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.core.text.BidiFormatter
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.detectGesture
|
||||
|
||||
@@ -50,51 +51,69 @@ fun MarkdownText (
|
||||
modifier: Modifier = Modifier,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
val reserve = if (edited) " " else " "
|
||||
if (formattedText == null) {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
append(text)
|
||||
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
}
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
} else {
|
||||
var hasLinks = false
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
for (ft in formattedText) {
|
||||
if (ft.format == null) append(ft.text)
|
||||
else {
|
||||
val link = ft.link
|
||||
if (link != null) {
|
||||
hasLinks = true
|
||||
val ftStyle = ft.format.style
|
||||
withAnnotation(tag = "URL", annotation = link) {
|
||||
withStyle(ftStyle) { append(ft.text) }
|
||||
val textLayoutDirection = remember (text) {
|
||||
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
}
|
||||
val reserve = when {
|
||||
textLayoutDirection != LocalLayoutDirection.current && metaText != null -> "\n"
|
||||
edited -> " "
|
||||
else -> " "
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
// When we draw `sender` is has issues on LTR languages set globally with RTL text language
|
||||
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current && sender == null)
|
||||
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
else
|
||||
LocalLayoutDirection.current
|
||||
) {
|
||||
if (formattedText == null) {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
append(text)
|
||||
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
}
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
} else {
|
||||
var hasLinks = false
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
for (ft in formattedText) {
|
||||
if (ft.format == null) append(ft.text)
|
||||
else {
|
||||
val link = ft.link
|
||||
if (link != null) {
|
||||
hasLinks = true
|
||||
val ftStyle = ft.format.style
|
||||
withAnnotation(tag = "URL", annotation = link) {
|
||||
withStyle(ftStyle) { append(ft.text) }
|
||||
}
|
||||
} else {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
}
|
||||
} else {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
}
|
||||
}
|
||||
// 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 (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
}
|
||||
if (hasLinks && uriHandler != null) {
|
||||
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) }
|
||||
},
|
||||
onClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
||||
},
|
||||
shouldConsumeEvent = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
}
|
||||
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
}
|
||||
if (hasLinks && uriHandler != null) {
|
||||
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) }
|
||||
},
|
||||
onClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
||||
},
|
||||
shouldConsumeEvent = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+104
-29
@@ -5,6 +5,7 @@ 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.TheaterComedy
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -15,13 +16,13 @@ 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.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.WarningOrange
|
||||
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
|
||||
|
||||
@@ -38,7 +39,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat, stopped) },
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
|
||||
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
@@ -46,7 +47,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
)
|
||||
is ChatInfo.Group ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat, stopped) },
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
|
||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
@@ -54,7 +55,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,
|
||||
@@ -63,7 +64,11 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
is ChatInfo.ContactConnection ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
|
||||
click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) },
|
||||
click = {
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ContactConnectionInfoView(chatModel, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close)
|
||||
}
|
||||
},
|
||||
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||
showMenu,
|
||||
stopped
|
||||
@@ -119,6 +124,7 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
|
||||
if (showMarkRead) {
|
||||
MarkReadChatAction(chat, chatModel, showMenu)
|
||||
}
|
||||
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
|
||||
ClearChatAction(chat, chatModel, showMenu)
|
||||
DeleteContactAction(chat, chatModel, showMenu)
|
||||
}
|
||||
@@ -127,21 +133,22 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
|
||||
fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||
when (groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> {
|
||||
JoinGroupAction(groupInfo, chatModel, showMenu)
|
||||
JoinGroupAction(chat, groupInfo, chatModel, showMenu)
|
||||
if (groupInfo.canDelete) {
|
||||
DeleteGroupAction(chat, chatModel, showMenu)
|
||||
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (showMarkRead) {
|
||||
MarkReadChatAction(chat, chatModel, showMenu)
|
||||
}
|
||||
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
|
||||
ClearChatAction(chat, chatModel, showMenu)
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
LeaveGroupAction(groupInfo, chatModel, showMenu)
|
||||
}
|
||||
if (groupInfo.canDelete) {
|
||||
DeleteGroupAction(chat, chatModel, showMenu)
|
||||
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,10 +167,22 @@ fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
||||
if (ntfsEnabled) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
|
||||
onClick = {
|
||||
changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.clear_verb),
|
||||
stringResource(R.string.clear_chat_menu_action),
|
||||
Icons.Outlined.Restore,
|
||||
onClick = {
|
||||
clearChatDialog(chat.chatInfo, chatModel)
|
||||
@@ -176,7 +195,7 @@ fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boo
|
||||
@Composable
|
||||
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
stringResource(R.string.delete_contact_menu_action),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
deleteContactDialog(chat.chatInfo, chatModel)
|
||||
@@ -187,12 +206,12 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteGroupAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
stringResource(R.string.delete_group_menu_action),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
deleteGroupDialog(chat.chatInfo, chatModel)
|
||||
deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
@@ -200,12 +219,14 @@ fun DeleteGroupAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<B
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun JoinGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
|
||||
ItemAction(
|
||||
stringResource(R.string.join_group_button),
|
||||
Icons.Outlined.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 = {
|
||||
withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) }
|
||||
joinGroup()
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
@@ -227,8 +248,9 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab
|
||||
@Composable
|
||||
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.accept_contact_button),
|
||||
Icons.Outlined.Check,
|
||||
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(chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -247,11 +269,21 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo
|
||||
|
||||
@Composable
|
||||
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.set_contact_name),
|
||||
Icons.Outlined.Edit,
|
||||
onClick = {
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
|
||||
}
|
||||
showMenu.value = false
|
||||
},
|
||||
)
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel)
|
||||
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
@@ -277,7 +309,7 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel
|
||||
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 = generalGetString(R.string.accept_contact_button),
|
||||
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) }
|
||||
@@ -318,14 +350,14 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
AlertManager.shared.hideAlert()
|
||||
deleteContactConnectionAlert(connection, chatModel)
|
||||
deleteContactConnectionAlert(connection, chatModel) {}
|
||||
}) {
|
||||
Text(stringResource(R.string.delete_verb))
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Button(onClick = { AlertManager.shared.hideAlert() }) {
|
||||
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
}
|
||||
@@ -333,7 +365,7 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel) {
|
||||
fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.delete_pending_connection__question),
|
||||
text = generalGetString(
|
||||
@@ -346,6 +378,7 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel
|
||||
AlertManager.shared.hideAlert()
|
||||
if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) {
|
||||
chatModel.removeChat(connection.id)
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,13 +407,21 @@ fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel)
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.join_group_question),
|
||||
text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members),
|
||||
confirmText = generalGetString(R.string.join_group_button),
|
||||
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(R.string.delete_verb),
|
||||
onDismiss = { deleteGroup(groupInfo, chatModel) }
|
||||
)
|
||||
}
|
||||
|
||||
fun cantInviteIncognitoAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.alert_title_cant_invite_contacts),
|
||||
text = generalGetString(R.string.alert_title_cant_invite_contacts_descr),
|
||||
confirmText = generalGetString(R.string.ok),
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId)
|
||||
@@ -399,6 +440,36 @@ fun groupInvitationAcceptedAlert() {
|
||||
)
|
||||
}
|
||||
|
||||
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 = contact.chatSettings.copy(enableNtfs = enabled)))
|
||||
}
|
||||
is ChatInfo.Group -> with(chat.chatInfo) {
|
||||
ChatInfo.Group(groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(enableNtfs = enabled)))
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
withApi {
|
||||
val res = when (newChatInfo) {
|
||||
is ChatInfo.Direct -> with(newChatInfo) {
|
||||
chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings)
|
||||
}
|
||||
is ChatInfo.Group -> with(newChatInfo) {
|
||||
chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings)
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
if (res && newChatInfo != null) {
|
||||
chatModel.updateChatInfo(newChatInfo)
|
||||
if (!enabled) {
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
}
|
||||
currentState.value = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListNavLinkLayout(
|
||||
chatLinkPreview: @Composable () -> Unit,
|
||||
@@ -407,7 +478,7 @@ fun ChatListNavLinkLayout(
|
||||
showMenu: MutableState<Boolean>,
|
||||
stopped: Boolean
|
||||
) {
|
||||
var modifier = Modifier.fillMaxWidth().height(88.dp)
|
||||
var modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp)
|
||||
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||
Surface(modifier) {
|
||||
Row(
|
||||
@@ -459,6 +530,8 @@ fun PreviewChatListNavLinkDirect() {
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
false,
|
||||
null,
|
||||
stopped = false
|
||||
)
|
||||
},
|
||||
@@ -494,6 +567,8 @@ fun PreviewChatListNavLinkGroup() {
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
false,
|
||||
null,
|
||||
stopped = false
|
||||
)
|
||||
},
|
||||
@@ -516,7 +591,7 @@ fun PreviewChatListNavLinkContactRequest() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = {
|
||||
ContactRequestView(ChatInfo.ContactRequest.sampleData)
|
||||
ContactRequestView(false, ChatInfo.ContactRequest.sampleData)
|
||||
},
|
||||
click = {},
|
||||
dropdownMenuItems = null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -7,144 +8,211 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
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.ChatModel
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.NewChatSheet
|
||||
import chat.simplex.app.views.onboarding.MakeConnection
|
||||
import chat.simplex.app.views.usersettings.SettingsView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ScaffoldController(val scope: CoroutineScope) {
|
||||
lateinit var state: BottomSheetScaffoldState
|
||||
val expanded = mutableStateOf(false)
|
||||
|
||||
fun expand() {
|
||||
expanded.value = true
|
||||
scope.launch { state.bottomSheetState.expand() }
|
||||
}
|
||||
|
||||
fun collapse() {
|
||||
expanded.value = false
|
||||
scope.launch { state.bottomSheetState.collapse() }
|
||||
}
|
||||
|
||||
fun toggleSheet() {
|
||||
if (state.bottomSheetState.isExpanded) collapse() else expand()
|
||||
}
|
||||
|
||||
fun toggleDrawer() = scope.launch {
|
||||
state.drawerState.apply { if (isClosed) open() else close() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun scaffoldController(): ScaffoldController {
|
||||
val ctrl = ScaffoldController(scope = rememberCoroutineScope())
|
||||
val bottomSheetState = rememberBottomSheetState(
|
||||
BottomSheetValue.Collapsed,
|
||||
confirmStateChange = {
|
||||
ctrl.expanded.value = it == BottomSheetValue.Expanded
|
||||
true
|
||||
}
|
||||
)
|
||||
ctrl.state = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
|
||||
return ctrl
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
||||
val scaffoldCtrl = scaffoldController()
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
|
||||
val newChatSheetState by rememberSaveable(stateSaver = NewChatSheetState.saver()) { mutableStateOf(MutableStateFlow(NewChatSheetState.GONE)) }
|
||||
val showNewChatSheet = {
|
||||
newChatSheetState.value = NewChatSheetState.VISIBLE
|
||||
}
|
||||
BottomSheetScaffold(
|
||||
topBar = { ChatListToolbar(scaffoldCtrl, stopped) },
|
||||
scaffoldState = scaffoldCtrl.state,
|
||||
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) },
|
||||
sheetPeekHeight = 0.dp,
|
||||
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||
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 {
|
||||
Box(Modifier.padding(it)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
if (chatModel.chats.isNotEmpty()) {
|
||||
ChatList(chatModel)
|
||||
ChatList(chatModel, search = searchInList)
|
||||
} else {
|
||||
MakeConnection(chatModel)
|
||||
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 (scaffoldCtrl.expanded.value) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { scaffoldCtrl.collapse() },
|
||||
color = Color.Black.copy(alpha = 0.12F)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (searchInList.isEmpty()) {
|
||||
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
|
||||
DefaultTopAppBar(
|
||||
navigationButton = { NavigationButtonMenu { scaffoldCtrl.toggleDrawer() } },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.your_chats),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
onTitleClick = null,
|
||||
showSearch = false,
|
||||
onSearchValueChanged = {},
|
||||
buttons = listOf{
|
||||
if (!stopped) {
|
||||
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
|
||||
Icon(
|
||||
Icons.Outlined.AddCircle,
|
||||
stringResource(R.string.add_contact),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
)
|
||||
Divider()
|
||||
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
|
||||
fun ChatList(chatModel: ChatModel) {
|
||||
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))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatList(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 else chatModel.chats.filter(filter) } }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(chatModel.chats) { chat ->
|
||||
items(chats) { chat ->
|
||||
ChatListNavLinkView(chat, chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.outlined.ErrorOutline
|
||||
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
|
||||
@@ -17,17 +17,15 @@ 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 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.ChatInfoImage
|
||||
import chat.simplex.app.views.helpers.badgeLayout
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) {
|
||||
val cInfo = chat.chatInfo
|
||||
|
||||
@Composable
|
||||
@@ -71,7 +69,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> chatPreviewTitleText(MaterialTheme.colors.primary)
|
||||
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
|
||||
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight)
|
||||
else -> chatPreviewTitleText()
|
||||
}
|
||||
@@ -80,15 +78,18 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewText() {
|
||||
fun chatPreviewText(chatModelIncognito: Boolean) {
|
||||
val ci = chat.chatItems.lastOrNull()
|
||||
if (ci != null) {
|
||||
MarkdownText(
|
||||
ci.text, ci.formattedText, ci.memberDisplayName,
|
||||
metaText = ci.timestampText,
|
||||
ci.text,
|
||||
ci.formattedText,
|
||||
sender = null,
|
||||
metaText = null,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
when (cInfo) {
|
||||
@@ -98,7 +99,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> Text(stringResource(R.string.group_preview_you_are_invited))
|
||||
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
|
||||
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight)
|
||||
else -> {}
|
||||
}
|
||||
@@ -120,7 +121,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
.weight(1F)
|
||||
) {
|
||||
chatPreviewTitle()
|
||||
chatPreviewText()
|
||||
chatPreviewText(chatModelIncognito)
|
||||
}
|
||||
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
|
||||
|
||||
@@ -134,6 +135,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
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) {
|
||||
Box(
|
||||
Modifier.padding(top = 24.dp),
|
||||
@@ -144,12 +146,27 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
color = MaterialTheme.colors.onPrimary,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.background(if (stopped) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.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(
|
||||
@@ -163,6 +180,16 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
@@ -200,6 +227,6 @@ fun ChatStatusImage(chat: Chat) {
|
||||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
ChatPreviewView(Chat.sampleData, stopped = false)
|
||||
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-7
@@ -1,20 +1,17 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.AddLink
|
||||
import androidx.compose.material.icons.outlined.Link
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.PendingContactConnection
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
|
||||
@@ -37,7 +34,7 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
Text(contactConnection.description, maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
Text(contactConnection.description, maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
}
|
||||
val ts = getTimestampText(contactConnection.updatedAt)
|
||||
Column(
|
||||
|
||||
+4
-6
@@ -1,6 +1,5 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
@@ -11,13 +10,12 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatInfo
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
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(
|
||||
@@ -31,9 +29,9 @@ 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
|
||||
)
|
||||
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
}
|
||||
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
||||
Column(
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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) {
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
ShareListNavLinkLayout(
|
||||
chatLinkPreview = { SharePreviewView(chat) },
|
||||
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||
stopped
|
||||
)
|
||||
is ChatInfo.Group ->
|
||||
ShareListNavLinkLayout(
|
||||
chatLinkPreview = { SharePreviewView(chat) },
|
||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||
stopped
|
||||
)
|
||||
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareListNavLinkLayout(
|
||||
chatLinkPreview: @Composable () -> Unit,
|
||||
click: () -> Unit,
|
||||
stopped: Boolean
|
||||
) {
|
||||
SectionItemView(minHeight = 50.dp, click = click, disabled = stopped) {
|
||||
chatLinkPreview()
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharePreviewView(chat: Chat) {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
ProfileImage(size = 46.dp, chat.chatInfo.image)
|
||||
Text(
|
||||
chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (chat.chatInfo.incognito) Indigo else Color.Unspecified
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.Image -> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,25 +59,22 @@ fun ChatArchiveLayout(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
|
||||
AppBarTitle(title)
|
||||
SectionView(stringResource(R.string.chat_archive_section)) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.IosShare,
|
||||
stringResource(R.string.save_archive),
|
||||
saveArchive,
|
||||
textColor = MaterialTheme.colors.primary
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
SectionDivider()
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.delete_archive),
|
||||
deleteArchiveAlert,
|
||||
textColor = Color.Red
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
}
|
||||
val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
|
||||
@@ -137,7 +134,7 @@ private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
|
||||
fun PreviewChatArchiveLayout() {
|
||||
SimpleXTheme {
|
||||
ChatArchiveLayout(
|
||||
title = "New database archive",
|
||||
"New database archive",
|
||||
archiveTime = Clock.System.now(),
|
||||
saveArchive = {},
|
||||
deleteArchiveAlert = {}
|
||||
|
||||
+507
@@ -0,0 +1,507 @@
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
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 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.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
|
||||
|
||||
@Composable
|
||||
fun DatabaseEncryptionView(m: ChatModel) {
|
||||
val progressIndicator = remember { mutableStateOf(false) }
|
||||
val prefs = m.controller.appPrefs
|
||||
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
||||
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
|
||||
val storedKey = remember { val key = DatabaseUtils.getDatabaseKey(); mutableStateOf(key != null && key != "") }
|
||||
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
|
||||
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.getDatabaseKey() ?: "" else "") }
|
||||
val newKey = rememberSaveable { mutableStateOf("") }
|
||||
val confirmNewKey = rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
) {
|
||||
DatabaseEncryptionLayout(
|
||||
useKeychain,
|
||||
prefs,
|
||||
m.chatDbEncrypted.value,
|
||||
currentKey,
|
||||
newKey,
|
||||
confirmNewKey,
|
||||
storedKey,
|
||||
initialRandomDBPassphrase,
|
||||
progressIndicator,
|
||||
onConfirmEncrypt = {
|
||||
progressIndicator.value = true
|
||||
withApi {
|
||||
try {
|
||||
prefs.encryptionStartedAt.set(Clock.System.now())
|
||||
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
|
||||
prefs.encryptionStartedAt.set(null)
|
||||
val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
|
||||
when {
|
||||
sqliteError is SQLiteError.ErrorNotADatabase -> {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.wrong_passphrase_title),
|
||||
generalGetString(R.string.enter_correct_current_passphrase)
|
||||
)
|
||||
}
|
||||
}
|
||||
error != null -> {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database),
|
||||
"failed to set storage encryption: ${error.responseType} ${error.details}"
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
prefs.initialRandomDBPassphrase.set(false)
|
||||
initialRandomDBPassphrase.value = false
|
||||
if (useKeychain.value) {
|
||||
DatabaseUtils.setDatabaseKey(newKey.value)
|
||||
}
|
||||
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_encrypted))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (progressIndicator.value) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DatabaseEncryptionLayout(
|
||||
useKeychain: MutableState<Boolean>,
|
||||
prefs: AppPreferences,
|
||||
chatDbEncrypted: Boolean?,
|
||||
currentKey: MutableState<String>,
|
||||
newKey: MutableState<String>,
|
||||
confirmNewKey: MutableState<String>,
|
||||
storedKey: MutableState<Boolean>,
|
||||
initialRandomDBPassphrase: MutableState<Boolean>,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
onConfirmEncrypt: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
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(R.string.remove_passphrase_from_keychain),
|
||||
text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
|
||||
confirmText = generalGetString(R.string.remove_passphrase),
|
||||
onConfirm = {
|
||||
DatabaseUtils.removeDatabaseKey()
|
||||
setUseKeychain(false, useKeychain, prefs)
|
||||
storedKey.value = false
|
||||
},
|
||||
destructive = true,
|
||||
)
|
||||
} else {
|
||||
setUseKeychain(false, useKeychain, prefs)
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
|
||||
DatabaseKeyField(
|
||||
currentKey,
|
||||
generalGetString(R.string.current_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
isValid = ::validKey,
|
||||
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
|
||||
)
|
||||
}
|
||||
|
||||
DatabaseKeyField(
|
||||
newKey,
|
||||
generalGetString(R.string.new_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
showStrength = true,
|
||||
isValid = ::validKey,
|
||||
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
|
||||
)
|
||||
val onClickUpdate = {
|
||||
// Don't do things concurrently. Shouldn't be here concurrently, just in case
|
||||
if (!progressIndicator.value) {
|
||||
if (currentKey.value == "") {
|
||||
if (useKeychain.value)
|
||||
encryptDatabaseSavedAlert(onConfirmEncrypt)
|
||||
else
|
||||
encryptDatabaseAlert(onConfirmEncrypt)
|
||||
} else {
|
||||
if (useKeychain.value)
|
||||
changeDatabaseKeySavedAlert(onConfirmEncrypt)
|
||||
else
|
||||
changeDatabaseKeyAlert(onConfirmEncrypt)
|
||||
}
|
||||
}
|
||||
}
|
||||
val disabled = currentKey.value == newKey.value ||
|
||||
newKey.value != confirmNewKey.value ||
|
||||
newKey.value.isEmpty() ||
|
||||
!validKey(currentKey.value) ||
|
||||
!validKey(newKey.value) ||
|
||||
progressIndicator.value
|
||||
|
||||
DatabaseKeyField(
|
||||
confirmNewKey,
|
||||
generalGetString(R.string.confirm_new_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
if (!disabled) onClickUpdate()
|
||||
defaultKeyboardAction(ImeAction.Done)
|
||||
}),
|
||||
)
|
||||
|
||||
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(R.string.database_is_not_encrypted))
|
||||
} else if (useKeychain.value) {
|
||||
if (storedKey.value) {
|
||||
SectionTextFooter(generalGetString(R.string.keychain_is_storing_securely))
|
||||
if (initialRandomDBPassphrase.value) {
|
||||
SectionTextFooter(generalGetString(R.string.encrypted_with_random_passphrase))
|
||||
} else {
|
||||
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
|
||||
}
|
||||
} else {
|
||||
SectionTextFooter(generalGetString(R.string.keychain_allows_to_receive_ntfs))
|
||||
}
|
||||
} else {
|
||||
SectionTextFooter(generalGetString(R.string.you_have_to_enter_passphrase_every_time))
|
||||
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
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 = false,
|
||||
)
|
||||
}
|
||||
|
||||
fun encryptDatabaseAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
fun changeDatabaseKeyAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SavePassphraseSetting(
|
||||
useKeychain: Boolean,
|
||||
initialRandomDBPassphrase: Boolean,
|
||||
storedKey: Boolean,
|
||||
progressIndicator: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
SectionItemView {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
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(R.string.save_passphrase_in_keychain),
|
||||
Modifier.padding(end = 24.dp),
|
||||
color = Color.Unspecified
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
checked = useKeychain,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
enabled = !initialRandomDBPassphrase && !progressIndicator
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFormAfterEncryption(
|
||||
m: ChatModel,
|
||||
initialRandomDBPassphrase: MutableState<Boolean>,
|
||||
currentKey: MutableState<String>,
|
||||
newKey: MutableState<String>,
|
||||
confirmNewKey: MutableState<String>,
|
||||
storedKey: MutableState<Boolean>,
|
||||
stored: Boolean = false,
|
||||
) {
|
||||
m.chatDbEncrypted.value = true
|
||||
initialRandomDBPassphrase.value = false
|
||||
m.controller.appPrefs.initialRandomDBPassphrase.set(false)
|
||||
currentKey.value = ""
|
||||
newKey.value = ""
|
||||
confirmNewKey.value = ""
|
||||
storedKey.value = stored
|
||||
}
|
||||
|
||||
fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, prefs: AppPreferences) {
|
||||
useKeychain.value = value
|
||||
prefs.storeDBPassphrase.set(value)
|
||||
}
|
||||
|
||||
fun storeSecurelySaved() = generalGetString(R.string.store_passphrase_securely)
|
||||
|
||||
fun storeSecurelyDanger() = generalGetString(R.string.store_passphrase_securely_without_recover)
|
||||
|
||||
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
|
||||
m.chatDbChanged.value = true
|
||||
progressIndicator.value = false
|
||||
alert.invoke()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun DatabaseKeyField(
|
||||
key: MutableState<String>,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showStrength: Boolean = false,
|
||||
isValid: (String) -> Boolean,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
) {
|
||||
var valid by remember { mutableStateOf(validKey(key.value)) }
|
||||
var showKey by remember { mutableStateOf(false) }
|
||||
val icon = if (valid) {
|
||||
if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
|
||||
} else Icons.Outlined.Error
|
||||
val iconColor = if (valid) {
|
||||
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else HighOrLowlight
|
||||
} else Color.Red
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val keyboardOptions = KeyboardOptions(
|
||||
imeAction = if (keyboardActions.onNext != null) ImeAction.Next else ImeAction.Done,
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Password
|
||||
)
|
||||
val state = remember {
|
||||
mutableStateOf(TextFieldValue(key.value))
|
||||
}
|
||||
val enabled = true
|
||||
val colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Unspecified,
|
||||
textColor = MaterialTheme.colors.onBackground,
|
||||
focusedIndicatorColor = Color.Unspecified,
|
||||
unfocusedIndicatorColor = Color.Unspecified,
|
||||
)
|
||||
val color = MaterialTheme.colors.onBackground
|
||||
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
BasicTextField(
|
||||
value = state.value,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.backgroundColor(enabled).value, shape)
|
||||
.indicatorLine(enabled, false, interactionSource, colors)
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = TextFieldDefaults.MinHeight
|
||||
),
|
||||
onValueChange = {
|
||||
state.value = it
|
||||
key.value = it.text
|
||||
valid = isValid(it.text)
|
||||
},
|
||||
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||
visualTransformation = if (showKey)
|
||||
VisualTransformation.None
|
||||
else
|
||||
VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) },
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboard?.hide()
|
||||
keyboardActions.onDone?.invoke(this)
|
||||
}),
|
||||
singleLine = true,
|
||||
textStyle = TextStyle.Default.copy(
|
||||
color = color,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = state.value.text,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = { Text(placeholder, color = HighOrLowlight) },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
isError = !valid,
|
||||
trailingIcon = {
|
||||
IconButton({ showKey = !showKey }) {
|
||||
Icon(icon, null, tint = iconColor)
|
||||
}
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = colors
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// based on https://generatepasswords.org/how-to-calculate-entropy/
|
||||
private fun passphraseEntropy(s: String): Double {
|
||||
var hasDigits = false
|
||||
var hasUppercase = false
|
||||
var hasLowercase = false
|
||||
var hasSymbols = false
|
||||
for (c in s) {
|
||||
if (c.isDigit()) {
|
||||
hasDigits = true
|
||||
} else if (c.isLetter()) {
|
||||
if (c.isUpperCase()) {
|
||||
hasUppercase = true
|
||||
} else {
|
||||
hasLowercase = true
|
||||
}
|
||||
} else if (c.isASCII()) {
|
||||
hasSymbols = true
|
||||
}
|
||||
}
|
||||
val poolSize = (if (hasDigits) 10 else 0) + (if (hasUppercase) 26 else 0) + (if (hasLowercase) 26 else 0) + (if (hasSymbols) 32 else 0)
|
||||
return s.length * log2(poolSize.toDouble())
|
||||
}
|
||||
|
||||
private enum class PassphraseStrength(val color: Color) {
|
||||
VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
|
||||
|
||||
companion object {
|
||||
fun check(s: String) = with(passphraseEntropy(s)) {
|
||||
when {
|
||||
this > 100 -> STRONG
|
||||
this > 70 -> REASONABLE
|
||||
this > 40 -> WEAK
|
||||
else -> VERY_WEAK
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun validKey(s: String): Boolean {
|
||||
for (c in s) {
|
||||
if (c.isWhitespace() || !c.isASCII()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun Char.isASCII() = code in 32..126
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewDatabaseEncryptionLayout() {
|
||||
SimpleXTheme {
|
||||
DatabaseEncryptionLayout(
|
||||
useKeychain = remember { mutableStateOf(true) },
|
||||
prefs = AppPreferences(SimplexApp.context),
|
||||
chatDbEncrypted = true,
|
||||
currentKey = remember { mutableStateOf("") },
|
||||
newKey = remember { mutableStateOf("") },
|
||||
confirmNewKey = remember { mutableStateOf("") },
|
||||
storedKey = remember { mutableStateOf(true) },
|
||||
initialRandomDBPassphrase = remember { mutableStateOf(true) },
|
||||
progressIndicator = remember { mutableStateOf(false) },
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,11 @@ 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.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -28,17 +29,19 @@ 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 androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.*
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
@Composable
|
||||
fun DatabaseView(
|
||||
@@ -49,19 +52,23 @@ fun DatabaseView(
|
||||
val progressIndicator = remember { mutableStateOf(false) }
|
||||
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 = rememberSaveArchiveLauncher(cxt = context, chatArchiveFile)
|
||||
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory(context))) }
|
||||
val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
importArchiveAlert(m, context, uri, progressIndicator)
|
||||
importArchiveAlert(m, context, uri, appFilesCountAndSize, progressIndicator)
|
||||
}
|
||||
}
|
||||
val chatDbDeleted = remember { m.chatDbDeleted }
|
||||
LaunchedEffect(m.chatRunning) {
|
||||
runChat.value = m.chatRunning.value ?: true
|
||||
}
|
||||
val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) }
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
) {
|
||||
@@ -69,14 +76,30 @@ fun DatabaseView(
|
||||
progressIndicator.value,
|
||||
runChat.value,
|
||||
m.chatDbChanged.value,
|
||||
useKeychain.value,
|
||||
m.chatDbEncrypted.value,
|
||||
m.controller.appPrefs.initialRandomDBPassphrase,
|
||||
importArchiveLauncher,
|
||||
chatArchiveName,
|
||||
chatArchiveTime,
|
||||
chatLastStart,
|
||||
startChat = { startChat(m, runChat, chatLastStart, context) },
|
||||
chatDbDeleted.value,
|
||||
appFilesCountAndSize,
|
||||
chatItemTTL,
|
||||
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
|
||||
stopChatAlert = { stopChatAlert(m, runChat, context) },
|
||||
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
|
||||
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
|
||||
deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(context, appFilesCountAndSize) },
|
||||
onChatItemTTLSelected = {
|
||||
val oldValue = chatItemTTL.value
|
||||
chatItemTTL.value = it
|
||||
if (it < oldValue) {
|
||||
setChatItemTTLAlert(m, chatItemTTL, progressIndicator, appFilesCountAndSize, context)
|
||||
} else if (it != oldValue) {
|
||||
setCiTTL(m, chatItemTTL, progressIndicator, appFilesCountAndSize, context)
|
||||
}
|
||||
},
|
||||
showSettingsModal
|
||||
)
|
||||
if (progressIndicator.value) {
|
||||
@@ -101,40 +124,59 @@ fun DatabaseLayout(
|
||||
progressIndicator: Boolean,
|
||||
runChat: Boolean,
|
||||
chatDbChanged: Boolean,
|
||||
useKeyChain: Boolean,
|
||||
chatDbEncrypted: Boolean?,
|
||||
initialRandomDBPassphrase: Preference<Boolean>,
|
||||
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
|
||||
chatArchiveName: MutableState<String?>,
|
||||
chatArchiveTime: MutableState<Instant?>,
|
||||
chatLastStart: MutableState<Instant?>,
|
||||
chatDbDeleted: Boolean,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
chatItemTTL: MutableState<ChatItemTTL>,
|
||||
startChat: () -> Unit,
|
||||
stopChatAlert: () -> Unit,
|
||||
exportArchive: () -> Unit,
|
||||
deleteChatAlert: () -> Unit,
|
||||
deleteAppFilesAndMedia: () -> Unit,
|
||||
onChatItemTTLSelected: (ChatItemTTL) -> Unit,
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
|
||||
) {
|
||||
val stopped = !runChat
|
||||
val operationsDisabled = !stopped || progressIndicator || chatDbChanged
|
||||
val operationsDisabled = !stopped || progressIndicator
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.your_chat_database),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
|
||||
AppBarTitle(stringResource(R.string.your_chat_database))
|
||||
SectionView(stringResource(R.string.run_chat_section)) {
|
||||
RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert)
|
||||
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.chat_database_section)) {
|
||||
val unencrypted = chatDbEncrypted == false
|
||||
SettingsActionItem(
|
||||
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 HighOrLowlight,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SectionDivider()
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.IosShare,
|
||||
stringResource(R.string.export_database),
|
||||
exportArchive,
|
||||
click = {
|
||||
if (initialRandomDBPassphrase.get()) {
|
||||
exportProhibitedAlert()
|
||||
} else {
|
||||
exportArchive()
|
||||
}
|
||||
},
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SectionDivider()
|
||||
@@ -143,6 +185,7 @@ fun DatabaseLayout(
|
||||
stringResource(R.string.import_database),
|
||||
{ importArchiveLauncher.launch("application/zip") },
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SectionDivider()
|
||||
@@ -164,28 +207,91 @@ fun DatabaseLayout(
|
||||
stringResource(R.string.delete_database),
|
||||
deleteChatAlert,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
}
|
||||
SectionTextFooter(
|
||||
if (chatDbChanged) {
|
||||
stringResource(R.string.restart_the_app_to_use_new_chat_database)
|
||||
if (stopped) {
|
||||
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
|
||||
} else {
|
||||
if (stopped) {
|
||||
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
|
||||
} else {
|
||||
stringResource(R.string.stop_chat_to_enable_database_actions)
|
||||
}
|
||||
stringResource(R.string.stop_chat_to_enable_database_actions)
|
||||
}
|
||||
)
|
||||
SectionSpacer()
|
||||
|
||||
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(R.string.delete_files_and_media),
|
||||
color = if (deleteFilesDisabled) HighOrLowlight else Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
val (count, size) = appFilesCountAndSize.value
|
||||
SectionTextFooter(
|
||||
if (count == 0) {
|
||||
stringResource(R.string.no_received_app_files)
|
||||
} else {
|
||||
String.format(stringResource(R.string.total_files_count_and_size), count, formatBytes(size))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setChatItemTTLAlert(
|
||||
m: ChatModel, selectedChatItemTTL: MutableState<ChatItemTTL>,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
context: Context
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onSelected: (ChatItemTTL) -> Unit) {
|
||||
val values = remember {
|
||||
val all: ArrayList<ChatItemTTL> = arrayListOf(ChatItemTTL.None, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day)
|
||||
if (current.value is ChatItemTTL.Seconds) {
|
||||
all.add(current.value)
|
||||
}
|
||||
all.map {
|
||||
when (it) {
|
||||
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(R.string.delete_messages_after),
|
||||
values,
|
||||
current,
|
||||
icon = null,
|
||||
enabled = enabled,
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RunChatSetting(
|
||||
runChat: Boolean,
|
||||
stopped: Boolean,
|
||||
chatDbChanged: Boolean,
|
||||
chatDbDeleted: Boolean,
|
||||
startChat: () -> Unit,
|
||||
stopChatAlert: () -> Unit
|
||||
) {
|
||||
@@ -195,16 +301,16 @@ fun RunChatSetting(
|
||||
Icon(
|
||||
if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow,
|
||||
chatRunningText,
|
||||
tint = if (chatDbChanged) HighOrLowlight else if (stopped) Color.Red else MaterialTheme.colors.primary
|
||||
tint = if (stopped) Color.Red else MaterialTheme.colors.primary
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
chatRunningText,
|
||||
Modifier.padding(end = 24.dp),
|
||||
color = if (chatDbChanged) HighOrLowlight else Color.Unspecified
|
||||
Modifier.padding(end = 24.dp)
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
enabled = !chatDbDeleted,
|
||||
checked = runChat,
|
||||
onCheckedChange = { runChatSwitch ->
|
||||
if (runChatSwitch) {
|
||||
@@ -217,7 +323,6 @@ fun RunChatSetting(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
enabled = !chatDbChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -228,16 +333,28 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
|
||||
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
|
||||
}
|
||||
|
||||
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, context: Context) {
|
||||
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
|
||||
withApi {
|
||||
try {
|
||||
if (chatDbChanged.value) {
|
||||
SimplexApp.context.initChatController()
|
||||
chatDbChanged.value = false
|
||||
}
|
||||
if (m.chatDbStatus.value !is DBMigrationResult.OK) {
|
||||
/** Hide current view and show [DatabaseErrorView] */
|
||||
ModalManager.shared.closeModals()
|
||||
return@withApi
|
||||
}
|
||||
m.controller.apiStartChat()
|
||||
runChat.value = true
|
||||
m.chatRunning.value = true
|
||||
val ts = Clock.System.now()
|
||||
m.controller.appPrefs.chatLastStart.set(ts)
|
||||
chatLastStart.value = ts
|
||||
SimplexService.start(context)
|
||||
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(R.string.error_starting_chat), e.toString())
|
||||
@@ -250,11 +367,42 @@ private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>, context:
|
||||
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 = { stopChat(m, runChat, context) },
|
||||
onConfirm = { authStopChat(m, runChat, context) },
|
||||
onDismiss = { runChat.value = true }
|
||||
)
|
||||
}
|
||||
|
||||
private fun exportProhibitedAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
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>, context: Context) {
|
||||
if (m.controller.appPrefs.performLA.get()) {
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_stop_chat),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success, LAResult.Unavailable -> {
|
||||
stopChat(m, runChat, context)
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
runChat.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
stopChat(m, runChat, context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
||||
withApi {
|
||||
try {
|
||||
@@ -262,6 +410,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Cont
|
||||
runChat.value = false
|
||||
m.chatRunning.value = false
|
||||
SimplexService.stop(context)
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
} catch (e: Error) {
|
||||
runChat.value = true
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
|
||||
@@ -358,16 +507,28 @@ private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableSt
|
||||
}
|
||||
)
|
||||
|
||||
private fun importArchiveAlert(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState<Boolean>) {
|
||||
private fun importArchiveAlert(
|
||||
m: ChatModel,
|
||||
context: Context,
|
||||
importedArchiveUri: Uri,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
progressIndicator: MutableState<Boolean>
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
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, progressIndicator) }
|
||||
onConfirm = { importArchive(m, context, importedArchiveUri, appFilesCountAndSize, progressIndicator) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState<Boolean>) {
|
||||
private fun importArchive(
|
||||
m: ChatModel,
|
||||
context: Context,
|
||||
importedArchiveUri: Uri,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
progressIndicator: MutableState<Boolean>
|
||||
) {
|
||||
progressIndicator.value = true
|
||||
val archivePath = saveArchiveFromUri(context, importedArchiveUri)
|
||||
if (archivePath != null) {
|
||||
@@ -377,6 +538,8 @@ private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Ur
|
||||
try {
|
||||
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))
|
||||
}
|
||||
@@ -429,6 +592,9 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
|
||||
withApi {
|
||||
try {
|
||||
m.controller.apiDeleteStorage()
|
||||
m.chatDbDeleted.value = true
|
||||
DatabaseUtils.removeDatabaseKey()
|
||||
m.controller.appPrefs.storeDBPassphrase.set(true)
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
|
||||
}
|
||||
@@ -440,6 +606,63 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCiTTL(
|
||||
m: ChatModel,
|
||||
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
|
||||
withApi {
|
||||
try {
|
||||
m.controller.setChatItemTTL(chatItemTTL.value)
|
||||
// Update model on success
|
||||
m.chatItemTTL.value = chatItemTTL.value
|
||||
afterSetCiTTL(m, progressIndicator, appFilesCountAndSize, context)
|
||||
} catch (e: Exception) {
|
||||
// Rollback to model's value
|
||||
chatItemTTL.value = m.chatItemTTL.value
|
||||
afterSetCiTTL(m, progressIndicator, appFilesCountAndSize, context)
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_changing_message_deletion), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun afterSetCiTTL(
|
||||
m: ChatModel,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
context: Context
|
||||
) {
|
||||
progressIndicator.value = false
|
||||
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
|
||||
withApi {
|
||||
try {
|
||||
val chats = m.controller.apiGetChats()
|
||||
m.updateChats(chats)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "apiGetChats error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteFilesAndMediaAlert(context: Context, appFilesCountAndSize: MutableState<Pair<Int, Long>>) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
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>>, context: Context) {
|
||||
deleteAppFiles(context)
|
||||
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
|
||||
}
|
||||
|
||||
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
|
||||
m.chatDbChanged.value = true
|
||||
progressIndicator.value = false
|
||||
@@ -459,15 +682,23 @@ fun PreviewDatabaseLayout() {
|
||||
progressIndicator = false,
|
||||
runChat = true,
|
||||
chatDbChanged = false,
|
||||
useKeyChain = false,
|
||||
chatDbEncrypted = false,
|
||||
initialRandomDBPassphrase = Preference({ true }, {}),
|
||||
importArchiveLauncher = rememberGetContentLauncher {},
|
||||
chatArchiveName = remember { mutableStateOf("dummy_archive") },
|
||||
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
|
||||
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
|
||||
chatDbDeleted = false,
|
||||
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
|
||||
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
|
||||
startChat = {},
|
||||
stopChatAlert = {},
|
||||
exportArchive = {},
|
||||
deleteChatAlert = {},
|
||||
showSettingsModal = { {} }
|
||||
deleteAppFilesAndMedia = {},
|
||||
showSettingsModal = { {} },
|
||||
onChatItemTTLSelected = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
|
||||
@@ -44,22 +46,24 @@ class AlertManager {
|
||||
confirmText: String = generalGetString(R.string.ok),
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||
onDismiss: (() -> Unit)? = null
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
onDismissRequest: (() -> Unit)? = null,
|
||||
destructive: Boolean = false
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
@@ -79,7 +83,7 @@ class AlertManager {
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
@@ -88,6 +92,13 @@ class AlertManager {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
if (presentAlert.value) alertView.value?.invoke()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
|
||||
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
||||
@@ -6,8 +6,7 @@ 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.AccountCircle
|
||||
import androidx.compose.material.icons.filled.SupervisedUserCircle
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -31,6 +30,17 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme
|
||||
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,
|
||||
|
||||
@@ -3,37 +3,46 @@ package chat.simplex.app.views.helpers
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CloseSheetBar(close: () -> Unit) {
|
||||
Row (
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.heightIn(min = AppBarHeight)
|
||||
.padding(horizontal = AppBarHorizontalPadding),
|
||||
) {
|
||||
IconButton(onClick = close) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
stringResource(R.string.icon_descr_close_button),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
interface ValueTitle <T> {
|
||||
val value: T
|
||||
val title: String
|
||||
}
|
||||
|
||||
data class ValueTitleDesc <T> (
|
||||
override val value: T,
|
||||
override val title: String,
|
||||
val description: String
|
||||
): ValueTitle<T>
|
||||
@@ -0,0 +1,73 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.views.usersettings.Cryptor
|
||||
import kotlinx.serialization.*
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
|
||||
object DatabaseUtils {
|
||||
private val cryptor = Cryptor()
|
||||
|
||||
private val appPreferences: AppPreferences by lazy {
|
||||
AppPreferences(SimplexApp.context)
|
||||
}
|
||||
|
||||
private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
|
||||
|
||||
private fun hasDatabase(rootDir: String): Boolean =
|
||||
File(rootDir + File.separator + "files_chat.db").exists() && File(rootDir + File.separator + "files_agent.db").exists()
|
||||
|
||||
fun getDatabaseKey(): String? {
|
||||
return cryptor.decryptData(
|
||||
appPreferences.encryptedDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
|
||||
appPreferences.initializationVectorDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
|
||||
DATABASE_PASSWORD_ALIAS,
|
||||
)
|
||||
}
|
||||
|
||||
fun setDatabaseKey(key: String) {
|
||||
val data = cryptor.encryptText(key, DATABASE_PASSWORD_ALIAS)
|
||||
appPreferences.encryptedDBPassphrase.set(data.first.toBase64String())
|
||||
appPreferences.initializationVectorDBPassphrase.set(data.second.toBase64String())
|
||||
}
|
||||
|
||||
fun removeDatabaseKey() {
|
||||
cryptor.deleteKey(DATABASE_PASSWORD_ALIAS)
|
||||
appPreferences.encryptedDBPassphrase.set(null)
|
||||
appPreferences.initializationVectorDBPassphrase.set(null)
|
||||
}
|
||||
|
||||
fun useDatabaseKey(): String {
|
||||
Log.d(TAG, "useDatabaseKey ${appPreferences.storeDBPassphrase.get()}")
|
||||
var dbKey = ""
|
||||
val useKeychain = appPreferences.storeDBPassphrase.get()
|
||||
if (useKeychain) {
|
||||
if (!hasDatabase(SimplexApp.context.dataDir.absolutePath)) {
|
||||
dbKey = randomDatabasePassword()
|
||||
setDatabaseKey(dbKey)
|
||||
appPreferences.initialRandomDBPassphrase.set(true)
|
||||
} else {
|
||||
dbKey = getDatabaseKey() ?: ""
|
||||
}
|
||||
}
|
||||
return dbKey
|
||||
}
|
||||
|
||||
private fun randomDatabasePassword(): String {
|
||||
val s = ByteArray(32)
|
||||
SecureRandom().nextBytes(s)
|
||||
return s.toBase64String().replace("\n", "")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class DBMigrationResult {
|
||||
@Serializable @SerialName("ok") object OK: DBMigrationResult()
|
||||
@Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult()
|
||||
@Serializable @SerialName("error") class Error(val dbFile: String, val migrationError: String): DBMigrationResult()
|
||||
@Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult()
|
||||
@Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult()
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun DefaultBasicTextField(
|
||||
modifier: Modifier,
|
||||
initialValue: String,
|
||||
placeholder: (@Composable () -> Unit)? = null,
|
||||
leadingIcon: (@Composable () -> Unit)? = null,
|
||||
focus: Boolean = false,
|
||||
color: Color = MaterialTheme.colors.onBackground,
|
||||
textStyle: TextStyle = TextStyle.Default,
|
||||
selectTextOnFocus: Boolean = false,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
onValueChange: (String) -> Unit,
|
||||
) {
|
||||
val state = remember {
|
||||
mutableStateOf(TextFieldValue(initialValue))
|
||||
}
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!focus) return@LaunchedEffect
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
keyboard?.show()
|
||||
}
|
||||
val enabled = true
|
||||
val colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Unspecified,
|
||||
textColor = MaterialTheme.colors.onBackground,
|
||||
focusedIndicatorColor = Color.Unspecified,
|
||||
unfocusedIndicatorColor = Color.Unspecified,
|
||||
)
|
||||
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
BasicTextField(
|
||||
value = state.value,
|
||||
modifier = modifier
|
||||
.background(colors.backgroundColor(enabled).value, shape)
|
||||
.indicatorLine(enabled, false, interactionSource, colors)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
if (focusState.isFocused && selectTextOnFocus) {
|
||||
val text = state.value.text
|
||||
state.value = state.value.copy(
|
||||
selection = TextRange(0, text.length)
|
||||
)
|
||||
}
|
||||
}
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = TextFieldDefaults.MinHeight
|
||||
),
|
||||
onValueChange = {
|
||||
state.value = it
|
||||
onValueChange(it.text)
|
||||
},
|
||||
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboard?.hide()
|
||||
keyboardActions.onDone?.invoke(this)
|
||||
}),
|
||||
singleLine = true,
|
||||
textStyle = textStyle.copy(
|
||||
color = color,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = state.value.text,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = placeholder,
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
leadingIcon = leadingIcon,
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = colors
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -5,21 +5,19 @@ 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.ArrowBackIos
|
||||
import androidx.compose.material.icons.outlined.Menu
|
||||
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.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.ToolbarDark
|
||||
import chat.simplex.app.ui.theme.ToolbarLight
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun DefaultTopAppBar(
|
||||
navigationButton: @Composable RowScope.() -> Unit,
|
||||
title: @Composable () -> Unit,
|
||||
title: (@Composable () -> Unit)?,
|
||||
onTitleClick: (() -> Unit)? = null,
|
||||
showSearch: Boolean,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
@@ -34,15 +32,15 @@ fun DefaultTopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
if (!showSearch) {
|
||||
title()
|
||||
title?.invoke()
|
||||
} else {
|
||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
|
||||
}
|
||||
},
|
||||
backgroundColor = if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight,
|
||||
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
|
||||
navigationIcon = navigationButton,
|
||||
buttons = if (!showSearch) buttons else emptyList(),
|
||||
centered = !showSearch
|
||||
centered = !showSearch,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,7 +90,6 @@ private fun TopAppBar(
|
||||
content = navigationIcon
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
@@ -119,6 +116,6 @@ private fun TopAppBar(
|
||||
}
|
||||
|
||||
val AppBarHeight = 56.dp
|
||||
private val AppBarHorizontalPadding = 4.dp
|
||||
private val TitleInsetWithoutIcon = 16.dp - AppBarHorizontalPadding
|
||||
private val TitleInsetWithIcon = 72.dp
|
||||
val AppBarHorizontalPadding = 4.dp
|
||||
private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding
|
||||
val TitleInsetWithIcon = 72.dp
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
sealed class SharedContent {
|
||||
data class Text(val text: String): SharedContent()
|
||||
data class Image(val text: String, val uri: Uri): SharedContent()
|
||||
data class File(val text: String, val uri: Uri): SharedContent()
|
||||
}
|
||||
|
||||
enum class NewChatSheetState {
|
||||
VISIBLE, HIDING, GONE;
|
||||
fun isVisible(): Boolean {
|
||||
return this == VISIBLE
|
||||
}
|
||||
fun isHiding(): Boolean {
|
||||
return this == HIDING
|
||||
}
|
||||
fun isGone(): Boolean {
|
||||
return this == GONE
|
||||
}
|
||||
companion object {
|
||||
fun saver(): Saver<MutableStateFlow<NewChatSheetState>, *> = Saver(
|
||||
save = { it.value.toString() },
|
||||
restore = {
|
||||
MutableStateFlow(valueOf(it))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class UploadContent {
|
||||
data class SimpleImage(val bitmap: Bitmap): UploadContent()
|
||||
data class AnimatedImage(val uri: Uri): UploadContent()
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ExpandLess
|
||||
import androidx.compose.material.icons.outlined.ExpandMore
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
|
||||
@Composable
|
||||
fun <T> ExposedDropDownSettingRow(
|
||||
title: String,
|
||||
values: List<Pair<T, String>>,
|
||||
selection: State<T>,
|
||||
label: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color = HighOrLowlight,
|
||||
enabled: State<Boolean> = mutableStateOf(true),
|
||||
onSelected: (T) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
icon,
|
||||
"",
|
||||
Modifier.padding(end = 8.dp),
|
||||
tint = iconTint
|
||||
)
|
||||
}
|
||||
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
||||
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded && enabled.value
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(start = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(
|
||||
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Icon(
|
||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
generalGetString(R.string.icon_descr_more_button),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
modifier = Modifier.widthIn(min = 200.dp),
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
values.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onSelected(selectionOption.first)
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
selectionOption.second + (if (label != null) " $label" else ""),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,24 +19,13 @@ package chat.simplex.app.views.helpers
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.interaction.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToDown
|
||||
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.consumeAllChanges
|
||||
import androidx.compose.ui.input.pointer.consumeDownChange
|
||||
import androidx.compose.ui.input.pointer.isOutOfBounds
|
||||
import androidx.compose.ui.input.pointer.positionChangeConsumed
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.util.fastAll
|
||||
@@ -45,6 +34,8 @@ import androidx.compose.ui.util.fastForEach
|
||||
import chat.simplex.app.TAG
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
|
||||
@@ -86,11 +77,7 @@ suspend fun PointerInputScope.detectGesture(
|
||||
} else {
|
||||
if (shouldConsume)
|
||||
upOrCancel.consumeDownChange()
|
||||
// If onLongPress event is needed, cancel short press event
|
||||
if (onLongPress != null)
|
||||
pressScope.cancel()
|
||||
else
|
||||
pressScope.release()
|
||||
pressScope.release()
|
||||
}
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
onLongPress?.invoke(down.position)
|
||||
@@ -176,7 +163,7 @@ private class PressGestureScopeImpl(
|
||||
if (!isReleased && !isCanceled) {
|
||||
mutex.lock()
|
||||
}
|
||||
return isCanceled
|
||||
return isReleased && !isCanceled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,3 +212,65 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
|
||||
}
|
||||
return interactionSource
|
||||
}
|
||||
|
||||
suspend fun PointerInputScope.detectTransformGestures(
|
||||
panZoomLock: Boolean = false,
|
||||
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
|
||||
) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
var rotation = 0f
|
||||
var zoom = 1f
|
||||
var pan = Offset.Zero
|
||||
var pastTouchSlop = false
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
var lockedToPanZoom = false
|
||||
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
val canceled = event.changes.fastAny { it.isConsumed }
|
||||
if (!canceled) {
|
||||
val zoomChange = event.calculateZoom()
|
||||
val rotationChange = event.calculateRotation()
|
||||
val panChange = event.calculatePan()
|
||||
|
||||
if (!pastTouchSlop) {
|
||||
zoom *= zoomChange
|
||||
rotation += rotationChange
|
||||
pan += panChange
|
||||
|
||||
val centroidSize = event.calculateCentroidSize(useCurrent = false)
|
||||
val zoomMotion = abs(1 - zoom) * centroidSize
|
||||
val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f)
|
||||
val panMotion = pan.getDistance()
|
||||
|
||||
if (zoomMotion > touchSlop ||
|
||||
rotationMotion > touchSlop ||
|
||||
panMotion > touchSlop
|
||||
) {
|
||||
pastTouchSlop = true
|
||||
lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
|
||||
}
|
||||
}
|
||||
|
||||
if (pastTouchSlop) {
|
||||
val centroid = event.calculateCentroid(useCurrent = false)
|
||||
val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
|
||||
if (effectiveRotation != 0f ||
|
||||
zoomChange != 1f ||
|
||||
panChange != Offset.Zero
|
||||
) {
|
||||
onGesture(centroid, panChange, zoomChange, effectiveRotation)
|
||||
}
|
||||
event.changes.fastForEach {
|
||||
if (it.positionChanged() && zoomChange != 1f) {
|
||||
it.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (!canceled && event.changes.fastAny { it.pressed })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,26 +65,28 @@ fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String {
|
||||
}
|
||||
|
||||
private fun compressImageStr(bitmap: Bitmap): String {
|
||||
return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP)
|
||||
val usePng = bitmap.hasAlpha()
|
||||
val ext = if (usePng) "png" else "jpg"
|
||||
return "data:image/$ext;base64," + Base64.encodeToString(compressImageData(bitmap, usePng).toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream {
|
||||
fun resizeImageToDataSize(image: Bitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream {
|
||||
var img = image
|
||||
var stream = compressImageData(img)
|
||||
var stream = compressImageData(img, usePng)
|
||||
while (stream.size() > maxDataSize) {
|
||||
val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble())
|
||||
val clippedRatio = min(ratio, 2.0)
|
||||
val width = (img.width.toDouble() / clippedRatio).toInt()
|
||||
val height = img.height * width / img.width
|
||||
img = Bitmap.createScaledBitmap(img, width, height, true)
|
||||
stream = compressImageData(img)
|
||||
stream = compressImageData(img, usePng)
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
|
||||
private fun compressImageData(bitmap: Bitmap, usePng: Boolean): ByteArrayOutputStream {
|
||||
val stream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream)
|
||||
bitmap.compress(if (!usePng) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, 85, stream)
|
||||
return stream
|
||||
}
|
||||
|
||||
@@ -157,6 +159,10 @@ fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLaun
|
||||
fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
|
||||
|
||||
@Composable
|
||||
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
|
||||
|
||||
@Composable
|
||||
fun GetImageBottomSheet(
|
||||
imageBitmap: MutableState<Bitmap?>,
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.SimplexService.Companion.showPassphraseNotification
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object MessagesFetcherWorker {
|
||||
private const val UNIQUE_WORK_TAG = BuildConfig.APPLICATION_ID + ".UNIQUE_MESSAGES_FETCHER"
|
||||
|
||||
fun scheduleWork(intervalSec: Int = 600, durationSec: Int = 60) {
|
||||
val initialDelaySec = intervalSec.toLong()
|
||||
Log.d(TAG, "Worker: scheduling work to run at ${Date(System.currentTimeMillis() + initialDelaySec * 1000)} for $durationSec sec")
|
||||
val periodicWorkRequest = OneTimeWorkRequest.Builder(MessagesFetcherWork::class.java)
|
||||
.setInitialDelay(initialDelaySec, TimeUnit.SECONDS)
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putInt(MessagesFetcherWork.INPUT_DATA_INTERVAL, intervalSec)
|
||||
.putInt(MessagesFetcherWork.INPUT_DATA_DURATION, durationSec)
|
||||
.build()
|
||||
)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(SimplexApp.context).enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest)
|
||||
}
|
||||
|
||||
fun cancelAll() {
|
||||
Log.d(TAG, "Worker: canceled all tasks")
|
||||
WorkManager.getInstance(SimplexApp.context).cancelUniqueWork(UNIQUE_WORK_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
class MessagesFetcherWork(
|
||||
context: Context,
|
||||
workerParams: WorkerParameters
|
||||
): CoroutineWorker(context, workerParams) {
|
||||
companion object {
|
||||
const val INPUT_DATA_INTERVAL = "interval"
|
||||
const val INPUT_DATA_DURATION = "duration"
|
||||
private const val WAIT_AFTER_LAST_MESSAGE: Long = 10_000
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
// Skip when Simplex service is currently working
|
||||
if (SimplexService.getServiceState(SimplexApp.context) == SimplexService.ServiceState.STARTED) {
|
||||
reschedule()
|
||||
return Result.success()
|
||||
}
|
||||
val durationSeconds = inputData.getInt(INPUT_DATA_DURATION, 60)
|
||||
var shouldReschedule = true
|
||||
try {
|
||||
withTimeout(durationSeconds * 1000L) {
|
||||
val chatController = (applicationContext as SimplexApp).chatController
|
||||
val chatDbStatus = chatController.chatModel.chatDbStatus.value
|
||||
if (chatDbStatus != DBMigrationResult.OK) {
|
||||
Log.w(TAG, "Worker: problem with the database: $chatDbStatus")
|
||||
showPassphraseNotification(chatDbStatus)
|
||||
shouldReschedule = false
|
||||
return@withTimeout
|
||||
}
|
||||
Log.w(TAG, "Worker: starting work")
|
||||
// Give some time to start receiving messages
|
||||
delay(10_000)
|
||||
while (!isStopped) {
|
||||
if (chatController.lastMsgReceivedTimestamp + WAIT_AFTER_LAST_MESSAGE < System.currentTimeMillis()) {
|
||||
Log.d(TAG, "Worker: work is done")
|
||||
break
|
||||
}
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) { // When timeout happens
|
||||
Log.d(TAG, "Worker: work is done (took $durationSeconds sec)")
|
||||
} catch (_: CancellationException) { // When user opens the app while the worker is still working
|
||||
Log.d(TAG, "Worker: interrupted")
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Worker: unexpected exception: ${e.stackTraceToString()}")
|
||||
}
|
||||
|
||||
if (shouldReschedule) reschedule()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun reschedule() = MessagesFetcherWorker.scheduleWork()
|
||||
}
|
||||
@@ -2,22 +2,24 @@ package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.ui.theme.SettingsBackgroundLight
|
||||
import chat.simplex.app.ui.theme.isInDarkTheme
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@Composable
|
||||
fun ModalView(
|
||||
close: () -> Unit,
|
||||
background: Color = MaterialTheme.colors.background,
|
||||
modifier: Modifier = Modifier.padding(horizontal = 16.dp),
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
@@ -30,35 +32,106 @@ fun ModalView(
|
||||
}
|
||||
|
||||
class ModalManager {
|
||||
private val modalViews = arrayListOf<(@Composable (close: () -> Unit) -> Unit)?>()
|
||||
private val modalViews = arrayListOf<Pair<Boolean, (@Composable (close: () -> Unit) -> Unit)>>()
|
||||
private val modalCount = mutableStateOf(0)
|
||||
private val toRemove = mutableSetOf<Int>()
|
||||
private var oldViewChanging = AtomicBoolean(false)
|
||||
|
||||
fun showModal(content: @Composable () -> Unit) {
|
||||
showCustomModal { close -> ModalView(close, content = content) }
|
||||
fun showModal(settings: Boolean = false, content: @Composable () -> Unit) {
|
||||
showCustomModal { close ->
|
||||
ModalView(close, if (!settings || isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
|
||||
fun showModalCloseable(settings: Boolean = false, content: @Composable (close: () -> Unit) -> Unit) {
|
||||
showCustomModal { close ->
|
||||
ModalView(close, if (!settings || isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight, content = { content(close) })
|
||||
}
|
||||
}
|
||||
|
||||
fun showCustomModal(animated: Boolean = true, modal: @Composable (close: () -> Unit) -> Unit) {
|
||||
Log.d(TAG, "ModalManager.showModal")
|
||||
modalViews.add(modal)
|
||||
modalCount.value = modalViews.count()
|
||||
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
|
||||
// This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view
|
||||
if (toRemove.isNotEmpty()) {
|
||||
runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } }
|
||||
}
|
||||
modalViews.add(animated to modal)
|
||||
modalCount.value = modalViews.size - toRemove.size
|
||||
}
|
||||
|
||||
fun closeModal() {
|
||||
if (modalViews.isNotEmpty()) {
|
||||
modalViews.removeAt(modalViews.count() - 1)
|
||||
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
|
||||
else runAtomically { toRemove.add(modalViews.lastIndex - toRemove.size) }
|
||||
}
|
||||
modalCount.value = modalViews.count()
|
||||
modalCount.value = modalViews.size - toRemove.size
|
||||
}
|
||||
|
||||
fun closeModals() {
|
||||
while (modalViews.isNotEmpty()) closeModal()
|
||||
while (modalCount.value > 0) closeModal()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun showInView() {
|
||||
if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal)
|
||||
// Without animation
|
||||
if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) {
|
||||
modalViews.lastOrNull()?.second?.invoke(::closeModal)
|
||||
return
|
||||
}
|
||||
AnimatedContent(targetState = modalCount.value,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
fromEndToStartTransition()
|
||||
} else {
|
||||
fromStartToEndTransition()
|
||||
}.using(SizeTransform(clip = false))
|
||||
}
|
||||
) {
|
||||
modalViews.getOrNull(it - 1)?.second?.invoke(::closeModal)
|
||||
// This is needed because if we delete from modalViews immediately on request, animation will be bad
|
||||
if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) {
|
||||
runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows to modify a list without getting [ConcurrentModificationException]
|
||||
* */
|
||||
private fun runAtomically(atomicBoolean: AtomicBoolean = oldViewChanging, block: () -> Unit) {
|
||||
while (!atomicBoolean.compareAndSet(false, true)) {
|
||||
Thread.sleep(10)
|
||||
}
|
||||
block()
|
||||
atomicBoolean.set(false)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
private fun fromStartToEndTransition() =
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { fullWidth -> -fullWidth },
|
||||
animationSpec = animationSpec()
|
||||
) with slideOutHorizontally(
|
||||
targetOffsetX = { fullWidth -> fullWidth },
|
||||
animationSpec = animationSpec()
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
private fun fromEndToStartTransition() =
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { fullWidth -> fullWidth },
|
||||
animationSpec = animationSpec()
|
||||
) with slideOutHorizontally(
|
||||
targetOffsetX = { fullWidth -> -fullWidth },
|
||||
animationSpec = animationSpec()
|
||||
)
|
||||
|
||||
private fun <T> animationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||
// private fun <T> animationSpecFromStart() = tween<T>(durationMillis = 150, easing = FastOutLinearInEasing)
|
||||
// private fun <T> animationSpecFromEnd() = tween<T>(durationMillis = 100, easing = FastOutSlowInEasing)
|
||||
|
||||
companion object {
|
||||
val shared = ModalManager()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -20,8 +21,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
@@ -30,7 +30,7 @@ import kotlinx.coroutines.delay
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) {
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
@@ -61,7 +61,7 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str
|
||||
),
|
||||
onValueChange = {
|
||||
searchText = it
|
||||
onValueChange(it)
|
||||
onValueChange(it.text)
|
||||
},
|
||||
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
@@ -75,13 +75,13 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = searchText,
|
||||
value = searchText.text,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = {
|
||||
Text(placeholder)
|
||||
},
|
||||
trailingIcon = if (searchText.isNotEmpty()) {{
|
||||
IconButton({ searchText = ""; onValueChange("") }) {
|
||||
trailingIcon = if (searchText.text.isNotEmpty()) {{
|
||||
IconButton({ searchText = TextFieldValue(""); onValueChange("") }) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
|
||||
}
|
||||
}} else null,
|
||||
|
||||
@@ -1,57 +1,147 @@
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.ui.theme.GroupDark
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ValueTitleDesc
|
||||
import chat.simplex.app.views.helpers.ValueTitle
|
||||
|
||||
@Composable
|
||||
fun SectionView(title: String? = null, content: (@Composable () -> Unit)) {
|
||||
fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(), content: (@Composable ColumnScope.() -> Unit)) {
|
||||
Column {
|
||||
if (title != null) {
|
||||
Text(
|
||||
title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
|
||||
modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
||||
Column(Modifier.padding(horizontal = 6.dp).fillMaxWidth()) { content() }
|
||||
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
||||
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) {
|
||||
fun <T> SectionViewSelectable(
|
||||
title: String?,
|
||||
currentValue: State<T>,
|
||||
values: List<ValueTitleDesc<T>>,
|
||||
onSelected: (T) -> Unit,
|
||||
) {
|
||||
SectionView(title) {
|
||||
LazyColumn {
|
||||
items(values.size) { index ->
|
||||
val item = values[index]
|
||||
SectionItemViewSpaceBetween({ onSelected(item.value) }) {
|
||||
Text(item.title)
|
||||
if (currentValue.value == item.value) {
|
||||
Icon(Icons.Outlined.Check, item.title, tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(values.first { it.value == currentValue.value }.description)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionItemView(click: (() -> Unit)? = null, minHeight: Dp = 46.dp, disabled: Boolean = false, content: (@Composable RowScope.() -> Unit)) {
|
||||
val modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.sizeIn(minHeight = minHeight)
|
||||
Row(
|
||||
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
|
||||
if (click == null || disabled) modifier.padding(horizontal = DEFAULT_PADDING) else modifier.clickable(onClick = click).padding(horizontal = DEFAULT_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionItemViewSpaceBetween(
|
||||
click: (() -> Unit)? = null,
|
||||
minHeight: Dp = 46.dp,
|
||||
padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING),
|
||||
disabled: Boolean = false,
|
||||
content: (@Composable RowScope.() -> Unit)
|
||||
) {
|
||||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = minHeight)
|
||||
Row(
|
||||
if (click == null || disabled) modifier.padding(padding) else modifier.clickable(onClick = click).padding(padding),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> SectionItemWithValue(
|
||||
title: String,
|
||||
currentValue: State<T>,
|
||||
values: List<ValueTitle<T>>,
|
||||
label: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color = HighOrLowlight,
|
||||
enabled: State<Boolean> = mutableStateOf(true),
|
||||
onSelected: () -> Unit
|
||||
) {
|
||||
SectionItemView(click = if (enabled.value) onSelected else null) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
icon,
|
||||
title,
|
||||
Modifier.padding(end = 8.dp),
|
||||
tint = iconTint
|
||||
)
|
||||
}
|
||||
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
||||
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
|
||||
Row(
|
||||
Modifier.padding(start = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(
|
||||
values.first { it.value == currentValue.value }.title + (if (label != null) " $label" else ""),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionTextFooter(text: String) {
|
||||
Text(
|
||||
text,
|
||||
Modifier.padding(horizontal = 16.dp).padding(top = 5.dp).fillMaxWidth(0.9F),
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 12.sp
|
||||
lineHeight = 18.sp,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionCustomFooter(content: (@Composable () -> Unit)) {
|
||||
fun SectionCustomFooter(padding: PaddingValues = PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = 5.dp), content: (@Composable () -> Unit)) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp).padding(top = 5.dp)
|
||||
Modifier.padding(padding)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
@@ -69,14 +159,25 @@ fun SectionSpacer() {
|
||||
|
||||
@Composable
|
||||
fun InfoRow(title: String, value: String) {
|
||||
SectionItemView {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(title)
|
||||
Text(value, color = HighOrLowlight)
|
||||
}
|
||||
SectionItemViewSpaceBetween {
|
||||
Text(title)
|
||||
Text(value, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) {
|
||||
SectionItemViewSpaceBetween(onClick) {
|
||||
val configuration = LocalConfiguration.current
|
||||
Text(title)
|
||||
Text(
|
||||
value,
|
||||
Modifier
|
||||
.padding(start = 10.dp)
|
||||
.widthIn(max = (configuration.screenWidthDp / 2).dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ package chat.simplex.app.views.helpers
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.app.R
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.CIFile
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
@@ -24,6 +26,22 @@ fun shareText(cxt: Context, text: String) {
|
||||
cxt.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
fun shareFile(cxt: Context, text: String, filePath: String) {
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
val ext = filePath.substringAfterLast(".")
|
||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
/*if (text.isNotEmpty()) {
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}*/
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
type = mimeType
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
cxt.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
fun copyText(cxt: Context, text: String) {
|
||||
val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java)
|
||||
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
|
||||
@@ -52,13 +70,25 @@ fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResu
|
||||
}
|
||||
)
|
||||
|
||||
fun imageMimeType(fileName: String): String {
|
||||
val lowercaseName = fileName.lowercase()
|
||||
return when {
|
||||
lowercaseName.endsWith(".png") -> "image/png"
|
||||
lowercaseName.endsWith(".gif") -> "image/gif"
|
||||
lowercaseName.endsWith(".webp") -> "image/webp"
|
||||
lowercaseName.endsWith(".avif") -> "image/avif"
|
||||
lowercaseName.endsWith(".svg") -> "image/svg+xml"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImage(cxt: Context, ciFile: CIFile?) {
|
||||
val filePath = getLoadedFilePath(cxt, ciFile)
|
||||
val fileName = ciFile?.fileName
|
||||
if (filePath != null && fileName != null) {
|
||||
val values = ContentValues()
|
||||
values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
values.put(MediaStore.Images.Media.MIME_TYPE, imageMimeType(fileName))
|
||||
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
values.put(MediaStore.MediaColumns.TITLE, fileName)
|
||||
val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.R.attr.factor
|
||||
import android.R.color
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.*
|
||||
@@ -10,6 +12,7 @@ import android.provider.OpenableColumns
|
||||
import android.text.Spanned
|
||||
import android.text.SpannedString
|
||||
import android.text.style.*
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.annotation.StringRes
|
||||
@@ -23,8 +26,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.text.HtmlCompat
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.CIFile
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
@@ -212,6 +214,7 @@ private fun spannableStringToAnnotatedString(
|
||||
|
||||
// maximum image file size to be auto-accepted
|
||||
const val MAX_IMAGE_SIZE: Long = 236700
|
||||
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
||||
const val MAX_FILE_SIZE: Long = 8000000
|
||||
|
||||
fun getFilesDirectory(context: Context): String {
|
||||
@@ -305,9 +308,10 @@ fun getFileSize(context: Context, uri: Uri): Long? {
|
||||
|
||||
fun saveImage(context: Context, image: Bitmap): String? {
|
||||
return try {
|
||||
val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
|
||||
val ext = if (image.hasAlpha()) "png" else "jpg"
|
||||
val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE)
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.jpg")
|
||||
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext")
|
||||
val file = File(getAppFilePath(context, fileToSave))
|
||||
val output = FileOutputStream(file)
|
||||
dataResized.writeTo(output)
|
||||
@@ -320,6 +324,32 @@ fun saveImage(context: Context, image: Bitmap): String? {
|
||||
}
|
||||
}
|
||||
|
||||
fun saveAnimImage(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
val filename = getFileName(context, uri)?.lowercase()
|
||||
var ext = when {
|
||||
// remove everything but extension
|
||||
filename?.contains(".") == true -> filename.replaceBeforeLast('.', "").replace(".", "")
|
||||
else -> "gif"
|
||||
}
|
||||
// Just in case the image has a strange extension
|
||||
if (ext.length < 3 || ext.length > 4) ext = "gif"
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext")
|
||||
val file = File(getAppFilePath(context, fileToSave))
|
||||
val output = FileOutputStream(file)
|
||||
context.contentResolver.openInputStream(uri)!!.use { input ->
|
||||
output.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
fileToSave
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveAnimImage error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveFileFromUri(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
@@ -376,3 +406,35 @@ fun removeFile(context: Context, fileName: String): Boolean {
|
||||
}
|
||||
return fileDeleted
|
||||
}
|
||||
|
||||
fun deleteAppFiles(context: Context) {
|
||||
val dir = File(getAppFilesDirectory(context))
|
||||
try {
|
||||
dir.list()?.forEach {
|
||||
removeFile(context, it)
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e(TAG, "Util deleteAppFiles error: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in bytes
|
||||
var fileCount = 0
|
||||
var bytes = 0L
|
||||
try {
|
||||
File(dir).listFiles()?.forEach {
|
||||
fileCount++
|
||||
bytes += it.length()
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e(TAG, "Util directoryFileCountAndSize error: ${e.stackTraceToString()}")
|
||||
}
|
||||
return fileCount to bytes
|
||||
}
|
||||
|
||||
fun Color.darker(factor: Float = 0.1f): Color =
|
||||
Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha)
|
||||
|
||||
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
||||
|
||||
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
||||
|
||||
@@ -2,71 +2,130 @@ package chat.simplex.app.views.newchat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
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.TheaterComedy
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.shareText
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun AddContactView(chatModel: ChatModel) {
|
||||
val connReq = chatModel.connReqInvitation
|
||||
if (connReq != null) {
|
||||
val cxt = LocalContext.current
|
||||
AddContactLayout(
|
||||
connReq = connReq,
|
||||
share = { shareText(cxt, connReq) }
|
||||
)
|
||||
fun AddContactView(chatModel: ChatModel, connReqInvitation: String, connIncognito: Boolean) {
|
||||
val cxt = LocalContext.current
|
||||
LaunchedEffect(connReqInvitation) {
|
||||
if (connReqInvitation.isNotEmpty()) {
|
||||
chatModel.connReqInv.value = connReqInvitation
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { chatModel.connReqInv.value = null }
|
||||
}
|
||||
AddContactLayout(
|
||||
connReq = connReqInvitation,
|
||||
connIncognito = connIncognito,
|
||||
share = { shareText(cxt, connReqInvitation) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddContactLayout(connReq: String, connIncognito: Boolean, share: () -> Unit) {
|
||||
BoxWithConstraints {
|
||||
val screenHeight = maxHeight
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.add_contact), false)
|
||||
Text(
|
||||
stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
|
||||
)
|
||||
Row {
|
||||
InfoAboutIncognito(
|
||||
connIncognito,
|
||||
true,
|
||||
generalGetString(R.string.incognito_random_profile_description),
|
||||
generalGetString(R.string.your_profile_will_be_sent)
|
||||
)
|
||||
}
|
||||
if (connReq.isNotEmpty()) {
|
||||
QRCode(
|
||||
connReq, Modifier
|
||||
.aspectRatio(1f)
|
||||
.padding(vertical = 3.dp)
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
Text(
|
||||
annotatedStringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
|
||||
lineHeight = 22.sp,
|
||||
modifier = Modifier
|
||||
.padding(bottom = if (screenHeight > 600.dp) 8.dp else 0.dp)
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddContactLayout(connReq: String, share: () -> Unit) {
|
||||
BoxWithConstraints {
|
||||
val screenHeight = maxHeight
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String) {
|
||||
if (chatModelIncognito) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.add_contact),
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
Icon(
|
||||
if (supportedIncognito) Icons.Filled.TheaterComedy else Icons.Outlined.Info,
|
||||
stringResource(R.string.incognito),
|
||||
tint = if (supportedIncognito) Indigo else WarningOrange,
|
||||
modifier = Modifier.padding(end = 10.dp).size(20.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
|
||||
style = MaterialTheme.typography.h3,
|
||||
textAlign = TextAlign.Center,
|
||||
Text(onText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Info,
|
||||
stringResource(R.string.incognito),
|
||||
tint = HighOrLowlight,
|
||||
modifier = Modifier.padding(end = 10.dp).size(20.dp)
|
||||
)
|
||||
QRCode(
|
||||
connReq, Modifier
|
||||
.weight(1f, fill = false)
|
||||
.aspectRatio(1f)
|
||||
.padding(vertical = 3.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 22.sp,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp)
|
||||
)
|
||||
SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(offText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +141,7 @@ fun PreviewAddContactView() {
|
||||
SimpleXTheme {
|
||||
AddContactLayout(
|
||||
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
|
||||
connIncognito = false,
|
||||
share = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,16 +24,17 @@ import chat.simplex.app.views.chat.group.AddGroupMembersView
|
||||
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import chat.simplex.app.views.usersettings.DeleteImageButton
|
||||
import chat.simplex.app.views.usersettings.EditImageButton
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
|
||||
AddGroupLayout(
|
||||
chatModel.incognito.value,
|
||||
createGroup = { groupProfile ->
|
||||
withApi {
|
||||
val groupInfo = chatModel.controller.apiNewGroup(groupProfile)
|
||||
@@ -43,13 +44,8 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
|
||||
chatModel.chatId.value = groupInfo.id
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
close.invoke()
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
}
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +55,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
|
||||
fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
val displayName = remember { mutableStateOf("") }
|
||||
@@ -83,66 +79,67 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
|
||||
sheetState = bottomSheetModalState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
ModalView(close) {
|
||||
Surface(Modifier.background(MaterialTheme.colors.onBackground).fillMaxSize()) {
|
||||
Column(
|
||||
ModalView(close = close) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.create_secret_group_title), false)
|
||||
Text(stringResource(R.string.group_is_decentralized))
|
||||
InfoAboutIncognito(
|
||||
chatModelIncognito,
|
||||
false,
|
||||
generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
|
||||
generalGetString(R.string.group_main_profile_sent)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.create_secret_group_title),
|
||||
style = MaterialTheme.typography.h4,
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
)
|
||||
ReadableText(R.string.group_is_decentralized)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(size = 192.dp, image = profileImage.value)
|
||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||
}
|
||||
if (profileImage.value != null) {
|
||||
DeleteImageButton { profileImage.value = null }
|
||||
}
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(size = 192.dp, image = 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)
|
||||
}
|
||||
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(8.dp))
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
if (enabled) {
|
||||
CreateGroupButton(MaterialTheme.colors.primary, Modifier
|
||||
.clickable { createGroup(GroupProfile(displayName.value, fullName.value, profileImage.value)) }
|
||||
.padding(8.dp))
|
||||
} else {
|
||||
CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp))
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
if (enabled) {
|
||||
CreateGroupButton(MaterialTheme.colors.primary, Modifier
|
||||
.clickable { createGroup(GroupProfile(displayName.value, fullName.value, profileImage.value)) }
|
||||
.padding(8.dp))
|
||||
} else {
|
||||
CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp))
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +167,7 @@ fun CreateGroupButton(color: Color, modifier: Modifier) {
|
||||
fun PreviewAddGroupLayout() {
|
||||
SimpleXTheme {
|
||||
AddGroupLayout(
|
||||
chatModelIncognito = false,
|
||||
createGroup = {},
|
||||
close = {}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
|
||||
enum class ConnectViaLinkTab {
|
||||
SCAN, PASTE
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) {
|
||||
val selection = remember {
|
||||
mutableStateOf(
|
||||
runCatching { ConnectViaLinkTab.valueOf(m.controller.appPrefs.connectViaLinkTab.get()!!) }.getOrDefault(ConnectViaLinkTab.SCAN)
|
||||
)
|
||||
}
|
||||
val tabTitles = ConnectViaLinkTab.values().map {
|
||||
when (it) {
|
||||
ConnectViaLinkTab.SCAN -> stringResource(R.string.scan_QR_code)
|
||||
ConnectViaLinkTab.PASTE -> stringResource(R.string.paste_the_link_you_received)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
when (selection.value) {
|
||||
ConnectViaLinkTab.SCAN -> {
|
||||
ScanToConnectView(m, close)
|
||||
}
|
||||
ConnectViaLinkTab.PASTE -> {
|
||||
PasteToConnectView(m, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
TabRow(
|
||||
selectedTabIndex = selection.value.ordinal,
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
contentColor = MaterialTheme.colors.primary,
|
||||
) {
|
||||
tabTitles.forEachIndexed { index, it ->
|
||||
Tab(
|
||||
selected = selection.value.ordinal == index,
|
||||
onClick = {
|
||||
selection.value = ConnectViaLinkTab.values()[index]
|
||||
m.controller.appPrefs.connectViaLinkTab.set(selection.value .name)
|
||||
},
|
||||
text = { Text(it, fontSize = 13.sp) },
|
||||
icon = {
|
||||
Icon(
|
||||
if (ConnectViaLinkTab.SCAN.ordinal == index) Icons.Outlined.QrCode else Icons.Outlined.Article,
|
||||
it
|
||||
)
|
||||
},
|
||||
selectedContentColor = MaterialTheme.colors.primary,
|
||||
unselectedContentColor = HighOrLowlight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import SectionDivider
|
||||
import SectionView
|
||||
import android.content.res.Configuration
|
||||
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.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
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 chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.LocalAliasEditor
|
||||
import chat.simplex.app.views.chatlist.deleteContactConnectionAlert
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
|
||||
@Composable
|
||||
fun ContactConnectionInfoView(
|
||||
chatModel: ChatModel,
|
||||
connReqInvitation: String?,
|
||||
contactConnection: PendingContactConnection,
|
||||
focusAlias: Boolean,
|
||||
close: () -> Unit
|
||||
) {
|
||||
/** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. It will be managed by [AddContactView] itself
|
||||
* Otherwise it will be called here AFTER [AddContactView] is launched and will clear the value too soon */
|
||||
val allowDispose = remember { mutableStateOf(true) }
|
||||
LaunchedEffect(connReqInvitation) {
|
||||
allowDispose.value = true
|
||||
chatModel.connReqInv.value = connReqInvitation
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (allowDispose.value) {
|
||||
chatModel.connReqInv.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
ContactConnectionInfoLayout(
|
||||
connReq = connReqInvitation,
|
||||
contactConnection.localAlias,
|
||||
contactConnection.initiated,
|
||||
contactConnection.viaContactUri,
|
||||
contactConnection.incognito,
|
||||
focusAlias,
|
||||
deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) },
|
||||
onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) },
|
||||
showQr = {
|
||||
allowDispose.value = false
|
||||
ModalManager.shared.showModal {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
AddContactView(chatModel, connReqInvitation ?: return@showModal, contactConnection.incognito)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactConnectionInfoLayout(
|
||||
connReq: String?,
|
||||
localAlias: String,
|
||||
connectionInitiated: Boolean,
|
||||
connectionViaContactUri: Boolean,
|
||||
connectionIncognito: Boolean,
|
||||
focusAlias: Boolean,
|
||||
deleteConnection: () -> Unit,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
showQr: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(
|
||||
stringResource(
|
||||
if (connectionInitiated) R.string.you_invited_your_contact
|
||||
else R.string.you_accepted_connection
|
||||
)
|
||||
)
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING)) {
|
||||
LocalAliasEditor(localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged)
|
||||
}
|
||||
Text(
|
||||
stringResource(
|
||||
if (connectionViaContactUri) 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
|
||||
),
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
|
||||
)
|
||||
SectionView {
|
||||
if (!connReq.isNullOrEmpty() && connectionInitiated) {
|
||||
ShowQrButton(connectionIncognito, showQr)
|
||||
SectionDivider()
|
||||
}
|
||||
DeleteButton(deleteConnection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShowQrButton(incognito: Boolean, onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.QrCode,
|
||||
stringResource(R.string.show_QR_code),
|
||||
click = onClick,
|
||||
textColor = if (incognito) Indigo else MaterialTheme.colors.primary,
|
||||
iconColor = if (incognito) Indigo else MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.delete_verb),
|
||||
click = onClick,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setContactAlias(contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withApi {
|
||||
chatModel.controller.apiSetConnectionAlias(contactConnection.pccConnId, localAlias)?.let {
|
||||
chatModel.updateContactConnection(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
private fun PreviewContactConnectionInfoView() {
|
||||
SimpleXTheme {
|
||||
ContactConnectionInfoLayout(
|
||||
localAlias = "",
|
||||
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
|
||||
connectionInitiated = true,
|
||||
connectionViaContactUri = true,
|
||||
connectionIncognito = false,
|
||||
focusAlias = false,
|
||||
deleteConnection = {},
|
||||
onLocalAliasChanged = {},
|
||||
showQr = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.usersettings.UserAddressView
|
||||
|
||||
enum class CreateLinkTab {
|
||||
ONE_TIME, LONG_TERM
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
|
||||
val selection = remember { mutableStateOf(initialSelection) }
|
||||
val connReqInvitation = rememberSaveable { mutableStateOf("") }
|
||||
val creatingConnReq = rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(selection.value) {
|
||||
if (selection.value == CreateLinkTab.ONE_TIME && connReqInvitation.value.isEmpty() && !creatingConnReq.value) {
|
||||
createInvitation(m, creatingConnReq, connReqInvitation)
|
||||
}
|
||||
}
|
||||
val tabTitles = CreateLinkTab.values().map {
|
||||
when {
|
||||
it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isEmpty() -> stringResource(R.string.create_one_time_link)
|
||||
it == CreateLinkTab.ONE_TIME -> stringResource(R.string.one_time_link)
|
||||
it == CreateLinkTab.LONG_TERM -> stringResource(R.string.your_contact_address)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
when (selection.value) {
|
||||
CreateLinkTab.ONE_TIME -> {
|
||||
AddContactView(m, connReqInvitation.value, m.incognito.value)
|
||||
}
|
||||
CreateLinkTab.LONG_TERM -> {
|
||||
UserAddressView(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
TabRow(
|
||||
selectedTabIndex = selection.value.ordinal,
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
contentColor = MaterialTheme.colors.primary,
|
||||
) {
|
||||
tabTitles.forEachIndexed { index, it ->
|
||||
Tab(
|
||||
selected = selection.value.ordinal == index,
|
||||
onClick = {
|
||||
selection.value = CreateLinkTab.values()[index]
|
||||
},
|
||||
text = { Text(it, fontSize = 13.sp) },
|
||||
icon = {
|
||||
Icon(
|
||||
if (CreateLinkTab.ONE_TIME.ordinal == index) Icons.Outlined.RepeatOne else Icons.Outlined.AllInclusive,
|
||||
it
|
||||
)
|
||||
},
|
||||
selectedContentColor = MaterialTheme.colors.primary,
|
||||
unselectedContentColor = HighOrLowlight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createInvitation(m: ChatModel, creatingConnReq: MutableState<Boolean>, connReqInvitation: MutableState<String>) {
|
||||
creatingConnReq.value = true
|
||||
withApi {
|
||||
val connReq = m.controller.apiAddContact()
|
||||
if (connReq != null) {
|
||||
connReqInvitation.value = connReq
|
||||
} else {
|
||||
creatingConnReq.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +1,171 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chatlist.ScaffoldController
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<NewChatSheetState>, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) {
|
||||
if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) }
|
||||
NewChatSheetLayout(
|
||||
newChatSheetState,
|
||||
stopped,
|
||||
addContact = {
|
||||
withApi {
|
||||
// show spinner
|
||||
chatModel.connReqInvitation = chatModel.controller.apiAddContact()
|
||||
// hide spinner
|
||||
if (chatModel.connReqInvitation != null) {
|
||||
newChatCtrl.collapse()
|
||||
ModalManager.shared.showModal { AddContactView(chatModel) }
|
||||
}
|
||||
}
|
||||
closeNewChatSheet(false)
|
||||
ModalManager.shared.showModal { CreateLinkView(chatModel, CreateLinkTab.ONE_TIME) }
|
||||
},
|
||||
scanCode = {
|
||||
newChatCtrl.collapse()
|
||||
ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) }
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
},
|
||||
pasteLink = {
|
||||
newChatCtrl.collapse()
|
||||
ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
|
||||
connectViaLink = {
|
||||
closeNewChatSheet(false)
|
||||
ModalManager.shared.showModalCloseable { close -> ConnectViaLinkView(chatModel, close) }
|
||||
},
|
||||
createGroup = {
|
||||
newChatCtrl.collapse()
|
||||
closeNewChatSheet(false)
|
||||
ModalManager.shared.showCustomModal { close -> AddGroupView(chatModel, close) }
|
||||
}
|
||||
},
|
||||
closeNewChatSheet,
|
||||
)
|
||||
}
|
||||
|
||||
private val titles = listOf(R.string.share_one_time_link, R.string.connect_via_link_or_qr, R.string.create_group)
|
||||
private val icons = listOf(Icons.Outlined.AddLink, Icons.Outlined.QrCode, Icons.Outlined.Group)
|
||||
|
||||
@Composable
|
||||
fun NewChatSheetLayout(
|
||||
private fun NewChatSheetLayout(
|
||||
newChatSheetState: StateFlow<NewChatSheetState>,
|
||||
stopped: Boolean,
|
||||
addContact: () -> Unit,
|
||||
scanCode: () -> Unit,
|
||||
pasteLink: () -> Unit,
|
||||
createGroup: () -> Unit
|
||||
connectViaLink: () -> Unit,
|
||||
createGroup: () -> Unit,
|
||||
closeNewChatSheet: (animated: Boolean) -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
stringResource(R.string.add_contact_or_create_group),
|
||||
modifier = Modifier.padding(horizontal = 4.dp).padding(top = 20.dp, bottom = 20.dp),
|
||||
style = MaterialTheme.typography.body2
|
||||
var newChat by remember { mutableStateOf(newChatSheetState.value) }
|
||||
val resultingColor = if (isInDarkTheme()) Color.Black.copy(0.64f) else DrawerDefaults.scrimColor
|
||||
val animatedColor = remember {
|
||||
Animatable(
|
||||
if (newChat.isVisible()) Color.Transparent else resultingColor,
|
||||
Color.VectorConverter(resultingColor.colorSpace)
|
||||
)
|
||||
val boxModifier = Modifier.fillMaxWidth().height(80.dp).padding(horizontal = 0.dp)
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Box(boxModifier) {
|
||||
ActionRowButton(
|
||||
stringResource(R.string.create_one_time_link),
|
||||
stringResource(R.string.to_share_with_your_contact),
|
||||
Icons.Outlined.AddLink,
|
||||
click = addContact
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Box(boxModifier) {
|
||||
ActionRowButton(
|
||||
stringResource(R.string.paste_received_link),
|
||||
stringResource(R.string.paste_received_link_from_clipboard),
|
||||
Icons.Outlined.Article,
|
||||
click = pasteLink
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Box(boxModifier) {
|
||||
ActionRowButton(
|
||||
stringResource(R.string.scan_QR_code),
|
||||
stringResource(R.string.in_person_or_in_video_call__bracketed),
|
||||
Icons.Outlined.QrCode,
|
||||
click = scanCode
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Box(boxModifier) {
|
||||
ActionRowButton(
|
||||
stringResource(R.string.create_group),
|
||||
stringResource(R.string.only_stored_on_members_devices),
|
||||
icon = Icons.Outlined.Group,
|
||||
click = createGroup
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionRowButton(
|
||||
text: String, comment: String? = null, icon: ImageVector, disabled: Boolean = false,
|
||||
click: () -> Unit = {}
|
||||
) {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
Modifier.clickable(onClick = click).size(48.dp).padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
Icon(icon, text, tint = tint, modifier = Modifier.size(48.dp).padding(start = 4.dp, end = 16.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = tint
|
||||
)
|
||||
|
||||
if (comment != null) {
|
||||
Text(
|
||||
comment,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
newChatSheetState.collect {
|
||||
newChat = it
|
||||
launch {
|
||||
animatedColor.animateTo(if (newChat.isVisible()) resultingColor else Color.Transparent, newChatSheetAnimSpec())
|
||||
}
|
||||
launch {
|
||||
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
|
||||
if (newChat.isHiding()) closeNewChatSheet(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density }
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else 0, 0) }
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { closeNewChatSheet(true) }
|
||||
.drawBehind { drawRect(animatedColor.value) },
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
val actions = remember { listOf(addContact, connectViaLink, createGroup) }
|
||||
val backgroundColor = if (isInDarkTheme())
|
||||
Color(ColorUtils.blendARGB(MaterialTheme.colors.primary.toArgb(), Color.Black.toArgb(), 0.7F))
|
||||
else
|
||||
MaterialTheme.colors.background
|
||||
LazyColumn(Modifier
|
||||
.graphicsLayer {
|
||||
alpha = animatedFloat.value
|
||||
translationY = (1 - animatedFloat.value) * 20.dp.toPx()
|
||||
}) {
|
||||
items(actions.size) { index ->
|
||||
Row {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Box(contentAlignment = Alignment.CenterEnd) {
|
||||
Button(
|
||||
actions[index],
|
||||
shape = RoundedCornerShape(21.dp),
|
||||
colors = ButtonDefaults.textButtonColors(backgroundColor = backgroundColor),
|
||||
elevation = null,
|
||||
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF),
|
||||
modifier = Modifier.height(42.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(titles[index]),
|
||||
Modifier.padding(start = DEFAULT_PADDING_HALF),
|
||||
color = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Icon(
|
||||
icons[index],
|
||||
stringResource(titles[index]),
|
||||
Modifier.size(42.dp),
|
||||
tint = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(DEFAULT_PADDING))
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
onClick = { if (!stopped) closeNewChatSheet(true) },
|
||||
Modifier.padding(end = 16.dp, bottom = 16.dp),
|
||||
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(
|
||||
Icons.Default.Edit, stringResource(R.string.add_contact_or_create_group),
|
||||
Modifier.graphicsLayer { alpha = 1 - animatedFloat.value }
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.Close, stringResource(R.string.add_contact_or_create_group),
|
||||
Modifier.graphicsLayer { alpha = animatedFloat.value }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -158,11 +184,13 @@ fun ActionButton(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
Icon(icon, text,
|
||||
Icon(
|
||||
icon, text,
|
||||
tint = tint,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.padding(bottom = 8.dp))
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
if (text != null) {
|
||||
Text(
|
||||
text,
|
||||
@@ -185,13 +213,15 @@ fun ActionButton(
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewNewChatSheet() {
|
||||
private fun PreviewNewChatSheet() {
|
||||
SimpleXTheme {
|
||||
NewChatSheetLayout(
|
||||
MutableStateFlow(NewChatSheetState.VISIBLE),
|
||||
stopped = false,
|
||||
addContact = {},
|
||||
scanCode = {},
|
||||
pasteLink = {},
|
||||
createGroup = {}
|
||||
connectViaLink = {},
|
||||
createGroup = {},
|
||||
closeNewChatSheet = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,43 +3,42 @@ package chat.simplex.app.views.newchat
|
||||
import android.content.ClipboardManager
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val connectionLink = remember { mutableStateOf("")}
|
||||
val connectionLink = remember { mutableStateOf("") }
|
||||
val context = LocalContext.current
|
||||
val clipboard = getSystemService(context, ClipboardManager::class.java)
|
||||
BackHandler(onBack = close)
|
||||
PasteToConnectLayout(
|
||||
chatModel.incognito.value,
|
||||
connectionLink = connectionLink,
|
||||
pasteFromClipboard = {
|
||||
connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as String
|
||||
connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as? String ?: return@PasteToConnectLayout
|
||||
},
|
||||
connectViaLink = { connReqUri ->
|
||||
try {
|
||||
val uri = Uri.parse(connReqUri)
|
||||
withUriAction(uri) { action ->
|
||||
connectViaUri(chatModel, action, uri)
|
||||
if (connectViaUri(chatModel, action, uri)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -47,57 +46,55 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
text = generalGetString(R.string.this_string_is_not_a_connection_link)
|
||||
)
|
||||
}
|
||||
close()
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PasteToConnectLayout(
|
||||
chatModelIncognito: Boolean,
|
||||
connectionLink: MutableState<String>,
|
||||
pasteFromClipboard: () -> Unit,
|
||||
connectViaLink: (String) -> Unit,
|
||||
close: () -> Unit
|
||||
) {
|
||||
ModalView(close) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.connect_via_link),
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
Text(stringResource(R.string.paste_connection_link_below_to_connect))
|
||||
Text(stringResource(R.string.profile_will_be_sent_to_contact_sending_link))
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.connect_via_link), false)
|
||||
Text(stringResource(R.string.paste_connection_link_below_to_connect))
|
||||
|
||||
Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) {
|
||||
TextEditor(Modifier.height(180.dp), text = connectionLink)
|
||||
}
|
||||
InfoAboutIncognito(
|
||||
chatModelIncognito,
|
||||
true,
|
||||
generalGetString(R.string.incognito_random_profile_from_contact_description),
|
||||
generalGetString(R.string.profile_will_be_sent_to_contact_sending_link)
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
if (connectionLink.value == "") {
|
||||
SimpleButton(text = "Paste", icon = Icons.Outlined.ContentPaste) {
|
||||
pasteFromClipboard()
|
||||
}
|
||||
} else {
|
||||
SimpleButton(text = "Clear", icon = Icons.Outlined.Clear) {
|
||||
connectionLink.value = ""
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f).fillMaxWidth())
|
||||
SimpleButton(text = "Connect", icon = Icons.Outlined.Link) {
|
||||
connectViaLink(connectionLink.value)
|
||||
}
|
||||
}
|
||||
|
||||
Text(annotatedStringResource(R.string.you_can_also_connect_by_clicking_the_link))
|
||||
Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) {
|
||||
TextEditor(Modifier.height(180.dp), text = connectionLink)
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
if (connectionLink.value == "") {
|
||||
SimpleButton(text = stringResource(R.string.paste_button), icon = Icons.Outlined.ContentPaste) {
|
||||
pasteFromClipboard()
|
||||
}
|
||||
} else {
|
||||
SimpleButton(text = stringResource(R.string.clear_verb), icon = Icons.Outlined.Clear) {
|
||||
connectionLink.value = ""
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f).fillMaxWidth())
|
||||
SimpleButton(text = stringResource(R.string.connect_button), icon = Icons.Outlined.Link) {
|
||||
connectViaLink(connectionLink.value)
|
||||
}
|
||||
}
|
||||
|
||||
Text(annotatedStringResource(R.string.you_can_also_connect_by_clicking_the_link))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +108,7 @@ fun PasteToConnectLayout(
|
||||
fun PreviewPasteToConnectTextbox() {
|
||||
SimpleXTheme {
|
||||
PasteToConnectLayout(
|
||||
chatModelIncognito = false,
|
||||
connectionLink = remember { mutableStateOf("") },
|
||||
pasteFromClipboard = {},
|
||||
connectViaLink = { link ->
|
||||
@@ -121,7 +119,6 @@ fun PreviewPasteToConnectTextbox() {
|
||||
e.printStackTrace()
|
||||
}
|
||||
},
|
||||
close = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+40
-37
@@ -1,33 +1,42 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.Manifest
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
ConnectContactLayout(
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
qrCodeScanner = {
|
||||
QRCodeScanner { connReqUri ->
|
||||
try {
|
||||
val uri = Uri.parse(connReqUri)
|
||||
withUriAction(uri) { action ->
|
||||
connectViaUri(chatModel, action, uri)
|
||||
if (connectViaUri(chatModel, action, uri)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -35,15 +44,13 @@ fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
text = generalGetString(R.string.this_QR_code_is_not_a_link)
|
||||
)
|
||||
}
|
||||
close()
|
||||
}
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
|
||||
fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
|
||||
val action = uri.path?.drop(1)
|
||||
val action = uri.path?.drop(1)?.replace("/", "")
|
||||
if (action == "contact" || action == "invitation") {
|
||||
withApi { run(action) }
|
||||
} else {
|
||||
@@ -54,7 +61,7 @@ fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
|
||||
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri): Boolean {
|
||||
val r = chatModel.controller.apiConnect(uri.toString())
|
||||
if (r) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -64,35 +71,31 @@ suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
|
||||
else generalGetString(R.string.you_will_be_connected_when_your_contacts_device_is_online)
|
||||
)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
|
||||
ModalView(close) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
generalGetString(R.string.scan_QR_code),
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
)
|
||||
Text(
|
||||
generalGetString(R.string.your_chat_profile_will_be_sent_to_your_contact),
|
||||
style = MaterialTheme.typography.h3,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio = 1F)
|
||||
) { qrCodeScanner() }
|
||||
Text(
|
||||
annotatedStringResource(R.string.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
}
|
||||
fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable () -> Unit) {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.scan_QR_code), false)
|
||||
InfoAboutIncognito(
|
||||
chatModelIncognito,
|
||||
true,
|
||||
generalGetString(R.string.incognito_random_profile_description),
|
||||
generalGetString(R.string.your_profile_will_be_sent)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio = 1F)
|
||||
) { qrCodeScanner() }
|
||||
Text(
|
||||
annotatedStringResource(R.string.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +109,8 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni
|
||||
fun PreviewConnectContactLayout() {
|
||||
SimpleXTheme {
|
||||
ConnectContactLayout(
|
||||
chatModelIncognito = false,
|
||||
qrCodeScanner = { Surface {} },
|
||||
close = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -17,14 +16,18 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.helpers.annotatedStringResource
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = null) {
|
||||
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
|
||||
Text(stringResource(R.string.how_simplex_works), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = 8.dp))
|
||||
Column(Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.how_simplex_works), false)
|
||||
ReadableText(R.string.many_people_asked_how_can_it_deliver)
|
||||
ReadableText(R.string.to_protect_privacy_simplex_has_ids_for_queues)
|
||||
ReadableText(R.string.you_control_servers_to_receive_your_contacts_to_send)
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import android.Manifest
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
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.*
|
||||
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.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
fun MakeConnection(chatModel: ChatModel) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
MakeConnectionLayout(
|
||||
chatModel.currentUser.value,
|
||||
createLink = {
|
||||
withApi {
|
||||
// show spinner
|
||||
chatModel.connReqInvitation = chatModel.controller.apiAddContact()
|
||||
// hide spinner
|
||||
if (chatModel.connReqInvitation != null) {
|
||||
ModalManager.shared.showModal { AddContactView(chatModel) }
|
||||
}
|
||||
}
|
||||
},
|
||||
pasteLink = {
|
||||
ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
|
||||
},
|
||||
scanCode = {
|
||||
ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) }
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
},
|
||||
about = {
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MakeConnectionLayout(
|
||||
user: User?,
|
||||
createLink: () -> Unit,
|
||||
pasteLink: () -> Unit,
|
||||
scanCode: () -> Unit,
|
||||
about: () -> Unit
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = MaterialTheme.colors.background)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
if (user == null) stringResource(R.string.make_private_connection)
|
||||
else String.format(stringResource(R.string.personal_welcome), user.profile.displayName),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(
|
||||
annotatedStringResource(R.string.to_make_your_first_private_connection_choose),
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
ActionRow(
|
||||
Icons.Outlined.AddLink,
|
||||
R.string.create_1_time_link_qr_code,
|
||||
R.string.it_s_secure_to_share__only_one_contact_can_use_it,
|
||||
createLink
|
||||
)
|
||||
ActionRow(
|
||||
Icons.Outlined.Article,
|
||||
R.string.paste_the_link_you_received,
|
||||
R.string.or_open_the_link_in_the_browser_and_tap_open_in_mobile,
|
||||
pasteLink
|
||||
)
|
||||
ActionRow(
|
||||
Icons.Outlined.QrCode,
|
||||
R.string.scan_contact_s_qr_code,
|
||||
R.string.in_person_or_via_a_video_call__the_most_secure_way_to_connect,
|
||||
scanCode
|
||||
)
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.or))
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
ActionRow(
|
||||
Icons.Outlined.Tag,
|
||||
R.string.connect_with_the_developers,
|
||||
R.string.to_ask_any_questions_and_to_receive_simplex_chat_updates,
|
||||
{ uriHandler.openUri(simplexTeamUri) }
|
||||
)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
SimpleButton(
|
||||
text = stringResource(R.string.about_simplex),
|
||||
icon = Icons.Outlined.ArrowBackIosNew,
|
||||
click = about
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionRow(icon: ImageVector, @StringRes titleId: Int, @StringRes textId: Int, action: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.clickable { action() }
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Icon(icon, stringResource(titleId), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(end = 10.dp).size(40.dp))
|
||||
Column {
|
||||
Text(stringResource(titleId), color = MaterialTheme.colors.primary)
|
||||
Text(annotatedStringResource(textId))
|
||||
}
|
||||
}
|
||||
// Button(action: action, label: {
|
||||
// HStack(alignment: .top, spacing: 20) {
|
||||
// Image(systemName: icon)
|
||||
// .resizable()
|
||||
// .scaledToFit()
|
||||
// .frame(width: 30, height: 30)
|
||||
// .padding(.leading, 4)
|
||||
// .padding(.top, 6)
|
||||
// VStack(alignment: .leading) {
|
||||
// Group {
|
||||
// Text(title).font(.headline)
|
||||
// Text(text).foregroundColor(.primary)
|
||||
// }
|
||||
// .multilineTextAlignment(.leading)
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// .padding(.bottom)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewMakeConnection() {
|
||||
SimpleXTheme {
|
||||
MakeConnectionLayout(
|
||||
user = User.sampleData,
|
||||
createLink = {},
|
||||
pasteLink = {},
|
||||
scanCode = {},
|
||||
about = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@ package chat.simplex.app.views.onboarding
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -13,9 +12,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -24,6 +25,7 @@ import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
|
||||
@@ -40,34 +42,36 @@ fun SimpleXInfoLayout(
|
||||
onboardingStage: MutableState<OnboardingStage?>?,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
) {
|
||||
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
|
||||
SimpleXLogo()
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 8.dp), contentAlignment = Alignment.Center) {
|
||||
SimpleXLogo()
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp))
|
||||
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 24.dp), textAlign = TextAlign.Center)
|
||||
|
||||
InfoRow("🎭", R.string.privacy_redefined, R.string.first_platform_without_user_ids)
|
||||
InfoRow("📭", R.string.immune_to_spam_and_abuse, R.string.people_can_connect_only_via_links_you_share)
|
||||
InfoRow("🤝", R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers)
|
||||
InfoRow(painterResource(R.drawable.privacy), R.string.privacy_redefined, R.string.first_platform_without_user_ids)
|
||||
InfoRow(painterResource(R.drawable.shield), R.string.immune_to_spam_and_abuse, R.string.people_can_connect_only_via_links_you_share)
|
||||
InfoRow(painterResource(R.drawable.decentralized), R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers)
|
||||
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f))
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
|
||||
if (onboardingStage != null) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
OnboardingActionButton(user, onboardingStage)
|
||||
}
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f))
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
|
||||
.padding(bottom = 16.dp), contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(text = stringResource(R.string.how_it_works), icon = Icons.Outlined.Info,
|
||||
click = showModal { HowItWorks(user, onboardingStage) })
|
||||
}
|
||||
@@ -77,20 +81,20 @@ fun SimpleXInfoLayout(
|
||||
@Composable
|
||||
fun SimpleXLogo() {
|
||||
Image(
|
||||
painter = painterResource(if (isSystemInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
||||
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
||||
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||
modifier = Modifier
|
||||
.padding(vertical = 20.dp)
|
||||
.padding(vertical = DEFAULT_PADDING)
|
||||
.fillMaxWidth(0.80f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) {
|
||||
private fun InfoRow(icon: Painter, @StringRes titleId: Int, @StringRes textId: Int) {
|
||||
Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) {
|
||||
Text(emoji, fontSize = 36.sp, modifier = Modifier
|
||||
Image(icon, contentDescription = null, modifier = Modifier
|
||||
.width(60.dp)
|
||||
.padding(end = 16.dp))
|
||||
.padding(top = 8.dp, end = 16.dp))
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Text(stringResource(titleId), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h3, lineHeight = 24.sp)
|
||||
Text(stringResource(textId), lineHeight = 24.sp, style = MaterialTheme.typography.caption)
|
||||
@@ -98,7 +102,6 @@ private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: I
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
|
||||
if (user == null) {
|
||||
@@ -139,7 +142,7 @@ fun PreviewSimpleXInfo() {
|
||||
SimpleXInfoLayout(
|
||||
user = null,
|
||||
onboardingStage = null,
|
||||
showModal = {{}}
|
||||
showModal = { {} }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-7
@@ -147,13 +147,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.network_settings_title),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionSpacer()
|
||||
|
||||
AppBarTitle(stringResource(R.string.network_settings_title))
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
ResetToDefaultsButton(reset, disabled = resetDisabled)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionCustomFooter
|
||||
import SectionDivider
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.content.ComponentName
|
||||
import android.content.pm.PackageManager
|
||||
@@ -10,14 +14,18 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.MaterialTheme.colors
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -25,6 +33,8 @@ import androidx.core.graphics.drawable.toBitmap
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.godaddy.android.colorpicker.*
|
||||
|
||||
enum class AppIcon(val resId: Int) {
|
||||
DEFAULT(R.mipmap.icon),
|
||||
@@ -54,29 +64,33 @@ fun AppearanceView() {
|
||||
|
||||
AppearanceLayout(
|
||||
appIcon,
|
||||
changeIcon = ::setAppIcon
|
||||
changeIcon = ::setAppIcon,
|
||||
showThemeSelector = {
|
||||
ModalManager.shared.showModal(true) {
|
||||
ThemeSelectorView()
|
||||
}
|
||||
},
|
||||
editPrimaryColor = { primary ->
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
ColorEditor(primary, close)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun AppearanceLayout(
|
||||
icon: MutableState<AppIcon>,
|
||||
changeIcon: (AppIcon) -> Unit
|
||||
changeIcon: (AppIcon) -> Unit,
|
||||
showThemeSelector: () -> Unit,
|
||||
editPrimaryColor: (Color) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.appearance_settings),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionView(stringResource(R.string.settings_section_title_icon)) {
|
||||
LazyRow(
|
||||
Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.appearance_settings))
|
||||
SectionView(stringResource(R.string.settings_section_title_icon), padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
|
||||
LazyRow {
|
||||
items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index ->
|
||||
val item = AppIcon.values()[index]
|
||||
val mipmap = ContextCompat.getDrawable(LocalContext.current, item.resId)!!
|
||||
@@ -97,9 +111,78 @@ fun AppearanceView() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
val currentTheme by CurrentColors.collectAsState()
|
||||
SectionView(stringResource(R.string.settings_section_title_themes)) {
|
||||
SectionItemViewSpaceBetween(showThemeSelector) {
|
||||
Text(generalGetString(R.string.theme))
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemViewSpaceBetween({ editPrimaryColor(currentTheme.first.primary) }) {
|
||||
val title = generalGetString(R.string.color_primary)
|
||||
Text(title)
|
||||
Icon(Icons.Filled.Circle, title, tint = colors.primary)
|
||||
}
|
||||
}
|
||||
if (currentTheme.first.primary != LightColorPalette.primary) {
|
||||
SectionCustomFooter(PaddingValues(start = 7.dp, end = 7.dp, top = 5.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
ThemeManager.saveAndApplyPrimaryColor(LightColorPalette.primary)
|
||||
},
|
||||
) {
|
||||
Text(generalGetString(R.string.reset_color))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorEditor(
|
||||
initialColor: Color,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.color_primary))
|
||||
var currentColor by remember { mutableStateOf(initialColor) }
|
||||
ColorPicker(initialColor) {
|
||||
currentColor = it
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
ThemeManager.saveAndApplyPrimaryColor(currentColor)
|
||||
close()
|
||||
},
|
||||
Modifier.align(Alignment.CenterHorizontally),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = currentColor)
|
||||
) {
|
||||
Text(generalGetString(R.string.save_color))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorPicker(initialColor: Color, onColorChanged: (Color) -> Unit) {
|
||||
ClassicColorPicker(
|
||||
color = initialColor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
showAlphaBar = false,
|
||||
onColorChanged = { color: HsvColor ->
|
||||
onColorChanged(color.toColor())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon ->
|
||||
SimplexApp.context.packageManager.getComponentEnabledSetting(
|
||||
ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}")
|
||||
@@ -112,7 +195,9 @@ fun PreviewAppearanceSettings() {
|
||||
SimpleXTheme {
|
||||
AppearanceLayout(
|
||||
icon = remember { mutableStateOf(AppIcon.DARK_BLUE) },
|
||||
changeIcon = {}
|
||||
changeIcon = {},
|
||||
showThemeSelector = {},
|
||||
editPrimaryColor = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+68
-16
@@ -3,22 +3,28 @@ package chat.simplex.app.views.usersettings
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionView
|
||||
import androidx.compose.foundation.clickable
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun CallSettingsView(m: ChatModel) {
|
||||
fun CallSettingsView(m: ChatModel,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
) {
|
||||
CallSettingsLayout(
|
||||
webrtcPolicyRelay = m.controller.appPrefs.webrtcPolicyRelay,
|
||||
callOnLockScreen = m.controller.appPrefs.callOnLockScreen
|
||||
callOnLockScreen = m.controller.appPrefs.callOnLockScreen,
|
||||
editIceServers = showModal { RTCServersView(m) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,36 +32,48 @@ fun CallSettingsView(m: ChatModel) {
|
||||
fun CallSettingsLayout(
|
||||
webrtcPolicyRelay: Preference<Boolean>,
|
||||
callOnLockScreen: Preference<CallOnLockScreen>,
|
||||
editIceServers: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.your_calls))
|
||||
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
|
||||
Text(
|
||||
stringResource(R.string.your_calls),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
SectionItemView() {
|
||||
SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay)
|
||||
}
|
||||
SectionDivider()
|
||||
|
||||
Column(Modifier.padding(start = 10.dp, top = 12.dp)) {
|
||||
Text(stringResource(R.string.call_on_lock_screen))
|
||||
Row {
|
||||
SharedPreferenceRadioButton(stringResource(R.string.no_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.DISABLE)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
SharedPreferenceRadioButton(stringResource(R.string.show_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.SHOW)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
SharedPreferenceRadioButton(stringResource(R.string.accept_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.ACCEPT)
|
||||
}
|
||||
val enabled = remember { mutableStateOf(true) }
|
||||
SectionItemView { LockscreenOpts(lockCallState, enabled, onSelected = { callOnLockScreen.set(it); lockCallState.value = it }) }
|
||||
SectionDivider()
|
||||
SectionItemView(editIceServers) { Text(stringResource(R.string.webrtc_ice_servers)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: State<Boolean>, onSelected: (CallOnLockScreen) -> Unit) {
|
||||
val values = remember {
|
||||
CallOnLockScreen.values().map {
|
||||
when (it) {
|
||||
CallOnLockScreen.DISABLE -> it to generalGetString(R.string.no_call_on_lock_screen)
|
||||
CallOnLockScreen.SHOW -> it to generalGetString(R.string.show_call_on_lock_screen)
|
||||
CallOnLockScreen.ACCEPT -> it to generalGetString(R.string.accept_call_on_lock_screen)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.call_on_lock_screen),
|
||||
values,
|
||||
lockscreenOpts,
|
||||
icon = null,
|
||||
enabled = enabled,
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -82,6 +100,40 @@ fun SharedPreferenceToggle(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPreferenceToggleWithIcon(
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
stopped: Boolean = false,
|
||||
onClickInfo: () -> Unit,
|
||||
preference: Preference<Boolean>,
|
||||
preferenceState: MutableState<Boolean>? = null
|
||||
) {
|
||||
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text, Modifier.padding(end = 4.dp))
|
||||
Icon(
|
||||
icon,
|
||||
null,
|
||||
Modifier.clickable(onClick = onClickInfo),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
checked = prefState.value,
|
||||
onCheckedChange = {
|
||||
preference.set(it)
|
||||
prefState.value = it
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
enabled = !stopped
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.*
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
internal class Cryptor {
|
||||
private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
|
||||
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String {
|
||||
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val spec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(alias), spec)
|
||||
return String(cipher.doFinal(data))
|
||||
}
|
||||
|
||||
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
|
||||
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, createSecretKey(alias))
|
||||
return Pair(cipher.doFinal(text.toByteArray(charset("UTF-8"))), cipher.iv)
|
||||
}
|
||||
|
||||
fun deleteKey(alias: String) {
|
||||
if (!keyStore.containsAlias(alias)) return
|
||||
keyStore.deleteEntry(alias)
|
||||
}
|
||||
|
||||
private fun createSecretKey(alias: String): SecretKey {
|
||||
if (keyStore.containsAlias(alias)) return getSecretKey(alias)
|
||||
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, "AndroidKeyStore")
|
||||
keyGenerator.init(
|
||||
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(BLOCK_MODE)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.build()
|
||||
)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
|
||||
private fun getSecretKey(alias: String): SecretKey {
|
||||
return (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
|
||||
private val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
|
||||
private val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,35 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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 chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chatlist.ChatHelpView
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
|
||||
@Composable
|
||||
fun HelpView(chatModel: ChatModel) {
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
HelpLayout(displayName = user.profile.displayName)
|
||||
}
|
||||
fun HelpView(userDisplayName: String) {
|
||||
HelpLayout(userDisplayName)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HelpLayout(displayName: String) {
|
||||
fun HelpLayout(userDisplayName: String) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start
|
||||
){
|
||||
Text(
|
||||
String.format(stringResource(R.string.personal_welcome), displayName),
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
)
|
||||
AppBarTitle(String.format(stringResource(R.string.personal_welcome), userDisplayName), false)
|
||||
ChatHelpView()
|
||||
}
|
||||
}
|
||||
@@ -52,6 +43,6 @@ fun HelpLayout(displayName: String) {
|
||||
@Composable
|
||||
fun PreviewHelpView() {
|
||||
SimpleXTheme {
|
||||
HelpLayout(displayName = "Alice")
|
||||
HelpLayout("Alice")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun IncognitoView() {
|
||||
IncognitoLayout()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncognitoLayout() {
|
||||
Column {
|
||||
AppBarTitle(stringResource(R.string.settings_section_title_incognito))
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Text(generalGetString(R.string.incognito_info_protects))
|
||||
Text(generalGetString(R.string.incognito_info_allows))
|
||||
Text(generalGetString(R.string.incognito_info_share))
|
||||
Text(generalGetString(R.string.incognito_info_find))
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-11
@@ -2,8 +2,9 @@ package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -14,19 +15,22 @@ import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.Format
|
||||
import chat.simplex.app.model.FormatColor
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun MarkdownHelpView() {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.how_to_use_markdown),
|
||||
style = MaterialTheme.typography.h1,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.you_can_use_markdown_to_format_messages__prompt),
|
||||
Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.how_to_use_markdown), false)
|
||||
Text(stringResource(R.string.you_can_use_markdown_to_format_messages__prompt))
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
val bold = stringResource(R.string.bold)
|
||||
val italic = stringResource(R.string.italic)
|
||||
val strikethrough = stringResource(R.string.strikethrough)
|
||||
@@ -85,7 +89,6 @@ fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: St
|
||||
b.append(after)
|
||||
}
|
||||
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
|
||||
+104
-14
@@ -2,7 +2,9 @@ package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemWithValue
|
||||
import SectionView
|
||||
import SectionViewSelectable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -14,8 +16,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.NetCfg
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@@ -23,18 +24,21 @@ import chat.simplex.app.views.helpers.*
|
||||
fun NetworkAndServersView(
|
||||
chatModel: ChatModel,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
) {
|
||||
val netCfg: MutableState<NetCfg> = remember { mutableStateOf(chatModel.controller.getNetCfg()) }
|
||||
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.value.useSocksProxy) }
|
||||
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
|
||||
val netCfg = remember { chatModel.controller.getNetCfg() }
|
||||
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
|
||||
|
||||
NetworkAndServersLayout(
|
||||
developerTools = developerTools,
|
||||
networkUseSocksProxy = networkUseSocksProxy,
|
||||
onionHosts = onionHosts,
|
||||
showModal = showModal,
|
||||
showSettingsModal = showSettingsModal,
|
||||
toggleSocksProxy = { enable ->
|
||||
toggleSocksProxy = { enable ->
|
||||
if (enable) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.network_enable_socks),
|
||||
@@ -45,6 +49,7 @@ fun NetworkAndServersView(
|
||||
chatModel.controller.apiSetNetworkConfig(NetCfg.proxyDefaults)
|
||||
chatModel.controller.setNetCfg(NetCfg.proxyDefaults)
|
||||
networkUseSocksProxy.value = true
|
||||
onionHosts.value = NetCfg.proxyDefaults.onionHosts
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -58,10 +63,35 @@ fun NetworkAndServersView(
|
||||
chatModel.controller.apiSetNetworkConfig(NetCfg.defaults)
|
||||
chatModel.controller.setNetCfg(NetCfg.defaults)
|
||||
networkUseSocksProxy.value = false
|
||||
onionHosts.value = NetCfg.defaults.onionHosts
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
useOnion = {
|
||||
if (onionHosts.value == it) return@NetworkAndServersLayout
|
||||
val prevValue = onionHosts.value
|
||||
onionHosts.value = it
|
||||
val startsWith = when (it) {
|
||||
OnionHosts.NEVER -> generalGetString(R.string.network_use_onion_hosts_no_desc_in_alert)
|
||||
OnionHosts.PREFER -> generalGetString(R.string.network_use_onion_hosts_prefer_desc_in_alert)
|
||||
OnionHosts.REQUIRED -> generalGetString(R.string.network_use_onion_hosts_required_desc_in_alert)
|
||||
}
|
||||
updateOnionHostsDialog(startsWith, onDismiss = {
|
||||
onionHosts.value = prevValue
|
||||
}) {
|
||||
withApi {
|
||||
val newCfg = chatModel.controller.getNetCfg().withOnionHosts(it)
|
||||
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
|
||||
if (res) {
|
||||
chatModel.controller.setNetCfg(newCfg)
|
||||
onionHosts.value = it
|
||||
} else {
|
||||
onionHosts.value = prevValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -69,31 +99,35 @@ fun NetworkAndServersView(
|
||||
@Composable fun NetworkAndServersLayout(
|
||||
developerTools: Boolean,
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
toggleSocksProxy: (Boolean) -> Unit
|
||||
toggleSocksProxy: (Boolean) -> Unit,
|
||||
useOnion: (OnionHosts) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.network_and_servers),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionView {
|
||||
AppBarTitle(stringResource(R.string.network_and_servers))
|
||||
SectionView(generalGetString(R.string.settings_section_title_messages)) {
|
||||
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||
}
|
||||
SectionDivider()
|
||||
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
|
||||
if (developerTools) {
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionView(generalGetString(R.string.settings_section_title_calls)) {
|
||||
SettingsActionItem(Icons.Outlined.ElectricalServices, stringResource(R.string.webrtc_ice_servers), showModal { RTCServersView(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +142,7 @@ fun UseSocksProxySwitch(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
@@ -129,6 +164,59 @@ fun UseSocksProxySwitch(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UseOnionHosts(
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
enabled: State<Boolean>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
useOnion: (OnionHosts) -> Unit,
|
||||
) {
|
||||
val values = remember {
|
||||
OnionHosts.values().map {
|
||||
when (it) {
|
||||
OnionHosts.NEVER -> ValueTitleDesc(OnionHosts.NEVER, generalGetString(R.string.network_use_onion_hosts_no), generalGetString(R.string.network_use_onion_hosts_no_desc))
|
||||
OnionHosts.PREFER -> ValueTitleDesc(OnionHosts.PREFER, generalGetString(R.string.network_use_onion_hosts_prefer), generalGetString(R.string.network_use_onion_hosts_prefer_desc))
|
||||
OnionHosts.REQUIRED -> ValueTitleDesc(OnionHosts.REQUIRED, generalGetString(R.string.network_use_onion_hosts_required), generalGetString(R.string.network_use_onion_hosts_required_desc))
|
||||
}
|
||||
}
|
||||
}
|
||||
val onSelected = showModal {
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.network_use_onion_hosts))
|
||||
SectionViewSelectable(null, onionHosts, values, useOnion)
|
||||
}
|
||||
}
|
||||
|
||||
SectionItemWithValue(
|
||||
generalGetString(R.string.network_use_onion_hosts),
|
||||
onionHosts,
|
||||
values,
|
||||
icon = Icons.Outlined.Security,
|
||||
enabled = enabled,
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateOnionHostsDialog(
|
||||
startsWith: String = "",
|
||||
message: String = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.update_onion_hosts_settings_question),
|
||||
text = startsWith + "\n\n" + message,
|
||||
confirmText = generalGetString(R.string.update_network_settings_confirmation),
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = onConfirm,
|
||||
onDismissRequest = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewNetworkAndServersLayout() {
|
||||
@@ -138,7 +226,9 @@ fun PreviewNetworkAndServersLayout() {
|
||||
networkUseSocksProxy = remember { mutableStateOf(true) },
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
toggleSocksProxy = {}
|
||||
toggleSocksProxy = {},
|
||||
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
|
||||
useOnion = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionView
|
||||
import SectionViewSelectable
|
||||
import android.os.Build
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
enum class NotificationsMode(private val requiresIgnoringBatterySinceSdk: Int) {
|
||||
OFF(Int.MAX_VALUE), PERIODIC(Build.VERSION_CODES.M), SERVICE(Build.VERSION_CODES.S), /*INSTANT(Int.MAX_VALUE) - for Firebase notifications */;
|
||||
|
||||
val requiresIgnoringBattery
|
||||
get() = requiresIgnoringBatterySinceSdk <= Build.VERSION.SDK_INT
|
||||
|
||||
companion object {
|
||||
val default: NotificationsMode = SERVICE
|
||||
}
|
||||
}
|
||||
|
||||
enum class NotificationPreviewMode {
|
||||
MESSAGE, CONTACT, HIDDEN;
|
||||
|
||||
companion object {
|
||||
val default: NotificationPreviewMode = MESSAGE
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationsSettingsView(
|
||||
chatModel: ChatModel,
|
||||
) {
|
||||
val onNotificationsModeSelected = { mode: NotificationsMode ->
|
||||
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
|
||||
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
|
||||
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
|
||||
}
|
||||
chatModel.notificationsMode.value = mode
|
||||
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
if (mode == NotificationsMode.SERVICE)
|
||||
SimplexService.start(SimplexApp.context)
|
||||
else
|
||||
SimplexService.stop(SimplexApp.context)
|
||||
}
|
||||
|
||||
if (mode != NotificationsMode.PERIODIC) {
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
}
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
val onNotificationPreviewModeSelected = { mode: NotificationPreviewMode ->
|
||||
chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name)
|
||||
chatModel.notificationPreviewMode.value = mode
|
||||
}
|
||||
|
||||
NotificationsSettingsLayout(
|
||||
notificationsMode = chatModel.notificationsMode,
|
||||
notificationPreviewMode = chatModel.notificationPreviewMode,
|
||||
showPage = { page ->
|
||||
ModalManager.shared.showModalCloseable(true) {
|
||||
when (page) {
|
||||
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode, onNotificationsModeSelected)
|
||||
CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
enum class CurrentPage {
|
||||
NOTIFICATIONS_MODE, NOTIFICATION_PREVIEW_MODE
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationsSettingsLayout(
|
||||
notificationsMode: State<NotificationsMode>,
|
||||
notificationPreviewMode: State<NotificationPreviewMode>,
|
||||
showPage: (CurrentPage) -> Unit,
|
||||
) {
|
||||
val modes = remember { notificationModes() }
|
||||
val previewModes = remember { notificationPreviewModes() }
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.notifications))
|
||||
SectionView(null) {
|
||||
SectionItemViewSpaceBetween({ showPage(CurrentPage.NOTIFICATIONS_MODE) }) {
|
||||
Text(stringResource(R.string.settings_notifications_mode_title))
|
||||
Spacer(Modifier.padding(horizontal = 10.dp))
|
||||
Text(
|
||||
modes.first { it.value == notificationsMode.value }.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemViewSpaceBetween({ showPage(CurrentPage.NOTIFICATION_PREVIEW_MODE) }) {
|
||||
Text(stringResource(R.string.settings_notification_preview_mode_title))
|
||||
Spacer(Modifier.padding(horizontal = 10.dp))
|
||||
Text(
|
||||
previewModes.first { it.value == notificationPreviewMode.value }.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationsModeView(
|
||||
notificationsMode: State<NotificationsMode>,
|
||||
onNotificationsModeSelected: (NotificationsMode) -> Unit,
|
||||
) {
|
||||
val modes = remember { notificationModes() }
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.settings_notifications_mode_title).lowercase().capitalize(Locale.current))
|
||||
SectionViewSelectable(null, notificationsMode, modes, onNotificationsModeSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationPreviewView(
|
||||
notificationPreviewMode: State<NotificationPreviewMode>,
|
||||
onNotificationPreviewModeSelected: (NotificationPreviewMode) -> Unit,
|
||||
) {
|
||||
val previewModes = remember { notificationPreviewModes() }
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.settings_notification_preview_title))
|
||||
SectionViewSelectable(null, notificationPreviewMode, previewModes, onNotificationPreviewModeSelected)
|
||||
}
|
||||
}
|
||||
|
||||
// mode, name, description
|
||||
fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
|
||||
val res = ArrayList<ValueTitleDesc<NotificationsMode>>()
|
||||
res.add(
|
||||
ValueTitleDesc(
|
||||
NotificationsMode.OFF,
|
||||
generalGetString(R.string.notifications_mode_off),
|
||||
generalGetString(R.string.notifications_mode_off_desc),
|
||||
)
|
||||
)
|
||||
res.add(
|
||||
ValueTitleDesc(
|
||||
NotificationsMode.PERIODIC,
|
||||
generalGetString(R.string.notifications_mode_periodic),
|
||||
generalGetString(R.string.notifications_mode_periodic_desc),
|
||||
)
|
||||
)
|
||||
res.add(
|
||||
ValueTitleDesc(
|
||||
NotificationsMode.SERVICE,
|
||||
generalGetString(R.string.notifications_mode_service),
|
||||
generalGetString(R.string.notifications_mode_service_desc),
|
||||
)
|
||||
)
|
||||
return res
|
||||
}
|
||||
|
||||
// preview mode, name, description
|
||||
fun notificationPreviewModes(): List<ValueTitleDesc<NotificationPreviewMode>> {
|
||||
val res = ArrayList<ValueTitleDesc<NotificationPreviewMode>>()
|
||||
res.add(
|
||||
ValueTitleDesc(
|
||||
NotificationPreviewMode.MESSAGE,
|
||||
generalGetString(R.string.notification_preview_mode_message),
|
||||
generalGetString(R.string.notification_preview_mode_message_desc),
|
||||
)
|
||||
)
|
||||
res.add(
|
||||
ValueTitleDesc(
|
||||
NotificationPreviewMode.CONTACT,
|
||||
generalGetString(R.string.notification_preview_mode_contact),
|
||||
generalGetString(R.string.notification_preview_mode_contact_desc),
|
||||
)
|
||||
)
|
||||
res.add(
|
||||
ValueTitleDesc(
|
||||
NotificationPreviewMode.HIDDEN,
|
||||
generalGetString(R.string.notification_preview_mode_hidden),
|
||||
generalGetString(R.string.notification_display_mode_hidden_desc),
|
||||
)
|
||||
)
|
||||
return res
|
||||
}
|
||||
+3
-5
@@ -15,6 +15,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
@@ -22,11 +24,7 @@ fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.your_privacy),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||
)
|
||||
AppBarTitle(stringResource(R.string.your_privacy))
|
||||
SectionView(stringResource(R.string.settings_section_title_device)) {
|
||||
ChatLockItem(chatModel.performLA, setPerformLA)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionItemViewSpaceBetween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.OpenInNew
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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.ui.theme.*
|
||||
import chat.simplex.app.views.call.parseRTCIceServers
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun RTCServersView(
|
||||
chatModel: ChatModel
|
||||
) {
|
||||
var userRTCServers by remember {
|
||||
mutableStateOf(chatModel.controller.appPrefs.webrtcIceServers.get()?.split("\n") ?: listOf())
|
||||
}
|
||||
var isUserRTCServers by remember { mutableStateOf(userRTCServers.isNotEmpty()) }
|
||||
var editRTCServers by remember { mutableStateOf(!isUserRTCServers) }
|
||||
val userRTCServersStr = remember { mutableStateOf(if (isUserRTCServers) userRTCServers.joinToString(separator = "\n") else "") }
|
||||
fun saveUserRTCServers() {
|
||||
val srvs = userRTCServersStr.value.split("\n")
|
||||
if (srvs.isNotEmpty() && srvs.toSet().size == srvs.size && parseRTCIceServers(srvs) != null) {
|
||||
userRTCServers = srvs
|
||||
chatModel.controller.appPrefs.webrtcIceServers.set(srvs.joinToString(separator = "\n"))
|
||||
editRTCServers = false
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.error_saving_ICE_servers),
|
||||
generalGetString(R.string.ensure_ICE_server_address_are_correct_format_and_unique)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetRTCServers() {
|
||||
isUserRTCServers = false
|
||||
userRTCServers = listOf()
|
||||
chatModel.controller.appPrefs.webrtcIceServers.set(null)
|
||||
}
|
||||
|
||||
RTCServersLayout(
|
||||
isUserRTCServers = isUserRTCServers,
|
||||
editRTCServers = editRTCServers,
|
||||
userRTCServersStr = userRTCServersStr,
|
||||
isUserRTCServersOnOff = { switch ->
|
||||
if (switch) {
|
||||
isUserRTCServers = true
|
||||
} else if (userRTCServers.isNotEmpty()) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.use_simplex_chat_servers__question),
|
||||
text = generalGetString(R.string.saved_ICE_servers_will_be_removed),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
onConfirm = {
|
||||
resetRTCServers()
|
||||
isUserRTCServers = false
|
||||
userRTCServersStr.value = ""
|
||||
}
|
||||
)
|
||||
} else {
|
||||
isUserRTCServers = false
|
||||
userRTCServersStr.value = ""
|
||||
}
|
||||
},
|
||||
cancelEdit = {
|
||||
isUserRTCServers = userRTCServers.isNotEmpty()
|
||||
editRTCServers = !isUserRTCServers
|
||||
userRTCServersStr.value = if (isUserRTCServers) userRTCServers.joinToString(separator = "\n") else ""
|
||||
},
|
||||
saveRTCServers = ::saveUserRTCServers,
|
||||
editOn = { editRTCServers = true },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RTCServersLayout(
|
||||
isUserRTCServers: Boolean,
|
||||
editRTCServers: Boolean,
|
||||
userRTCServersStr: MutableState<String>,
|
||||
isUserRTCServersOnOff: (Boolean) -> Unit,
|
||||
cancelEdit: () -> Unit,
|
||||
saveRTCServers: () -> Unit,
|
||||
editOn: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
AppBarTitle(stringResource(R.string.your_ICE_servers))
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
SectionItemViewSpaceBetween(padding = PaddingValues()) {
|
||||
Text(stringResource(R.string.configure_ICE_servers), Modifier.padding(end = 24.dp))
|
||||
Switch(
|
||||
checked = isUserRTCServers,
|
||||
onCheckedChange = isUserRTCServersOnOff,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!isUserRTCServers) {
|
||||
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
|
||||
} else {
|
||||
Text(stringResource(R.string.enter_one_ICE_server_per_line))
|
||||
if (editRTCServers) {
|
||||
TextEditor(Modifier.height(160.dp), text = userRTCServersStr)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(R.string.cancel_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = cancelEdit)
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
Text(
|
||||
stringResource(R.string.save_servers_button),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
saveRTCServers()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.height(160.dp)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
||||
) {
|
||||
SelectionContainer(
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
userRTCServersStr.value,
|
||||
Modifier
|
||||
.padding(vertical = 5.dp, horizontal = 7.dp),
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Text(
|
||||
stringResource(R.string.edit_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = editOn)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun howToButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/WEBRTC.md#configure-mobile-apps") }
|
||||
) {
|
||||
Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary)
|
||||
Icon(
|
||||
Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(horizontal = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionItemViewSpaceBetween
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -19,8 +20,7 @@ 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.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
@@ -58,7 +58,7 @@ fun SMPServersView(chatModel: ChatModel) {
|
||||
if (userSMPServers.isNotEmpty()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.use_simplex_chat_servers__question),
|
||||
text = generalGetString(R.string.saved_SMP_servers_will_br_removed),
|
||||
text = generalGetString(R.string.saved_SMP_servers_will_be_removed),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
onConfirm = {
|
||||
saveSMPServers(listOf())
|
||||
@@ -97,99 +97,96 @@ fun SMPServersLayout(
|
||||
saveSMPServers: (List<String>) -> Unit,
|
||||
editOn: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.your_SMP_servers),
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Column {
|
||||
AppBarTitle(stringResource(R.string.your_SMP_servers))
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
|
||||
Switch(
|
||||
checked = isUserSMPServers,
|
||||
onCheckedChange = isUserSMPServersOnOff,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
)
|
||||
}
|
||||
SectionItemViewSpaceBetween(padding = PaddingValues()) {
|
||||
Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
|
||||
Switch(
|
||||
checked = isUserSMPServers,
|
||||
onCheckedChange = isUserSMPServersOnOff,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!isUserSMPServers) {
|
||||
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
|
||||
} else {
|
||||
Text(stringResource(R.string.enter_one_SMP_server_per_line))
|
||||
if (editSMPServers) {
|
||||
TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
|
||||
if (!isUserSMPServers) {
|
||||
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
|
||||
} else {
|
||||
Text(stringResource(R.string.enter_one_SMP_server_per_line))
|
||||
if (editSMPServers) {
|
||||
TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Row {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(R.string.cancel_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = cancelEdit)
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
Text(
|
||||
stringResource(R.string.save_servers_button),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
val servers = userSMPServersStr.value.split("\n")
|
||||
saveSMPServers(servers)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.height(160.dp)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
||||
) {
|
||||
SelectionContainer(
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.cancel_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = cancelEdit)
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
Text(
|
||||
stringResource(R.string.save_servers_button),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
val servers = userSMPServersStr.value.split("\n")
|
||||
saveSMPServers(servers)
|
||||
})
|
||||
userSMPServersStr.value,
|
||||
Modifier
|
||||
.padding(vertical = 5.dp, horizontal = 7.dp),
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.height(160.dp)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
||||
) {
|
||||
SelectionContainer(
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
userSMPServersStr.value,
|
||||
Modifier
|
||||
.padding(vertical = 5.dp, horizontal = 7.dp),
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Text(
|
||||
stringResource(R.string.edit_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = editOn)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Text(
|
||||
stringResource(R.string.edit_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = editOn)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,7 +195,7 @@ fun SMPServersLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun howToButton() {
|
||||
private fun howToButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user