Compare commits
311 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
331269a186 | ||
|
|
c14f692b68 | ||
|
|
4247dc4271 | ||
|
|
7f945d2530 | ||
|
|
3dc9eded54 | ||
|
|
5c13267b47 | ||
|
|
e10c8c7234 | ||
|
|
052963f19e | ||
|
|
c7d7c6c608 | ||
|
|
6c4c097150 | ||
|
|
3eb4d5efdd | ||
|
|
ea95912bd5 | ||
|
|
d080a3a87b | ||
|
|
54b501913c | ||
|
|
7f7abe1c62 | ||
|
|
f1492f8889 | ||
|
|
4c6800f1ff | ||
|
|
b6c578ca77 | ||
|
|
f388512592 | ||
|
|
8574674c2d | ||
|
|
1b7cee9fcf | ||
|
|
5e964cf7e9 | ||
|
|
8768d03e57 | ||
|
|
75dfd725f4 | ||
|
|
ea343b634d | ||
|
|
41a2e0b1d5 | ||
|
|
e52e516f5c | ||
|
|
ea29f93fb6 | ||
|
|
eaa2f4cf04 | ||
|
|
954f729a30 | ||
|
|
d35e7da3e4 | ||
|
|
692f37daa2 | ||
|
|
e0f4855d0d | ||
|
|
a11784c615 | ||
|
|
922ec2c045 | ||
|
|
262c999e5c | ||
|
|
14a5b680d7 | ||
|
|
a316a95754 | ||
|
|
a81de493fe | ||
|
|
bdb3bc0bd7 | ||
|
|
8b2ae2d426 | ||
|
|
013a7322d2 | ||
|
|
e92f960a87 | ||
|
|
0b45ddfc79 | ||
|
|
897c64e0ba | ||
|
|
26558dfaca | ||
|
|
ff32a44345 | ||
|
|
d4925b7cdd | ||
|
|
3c81a44273 | ||
|
|
319b4dc841 | ||
|
|
71483b0fc4 | ||
|
|
366b84d3fa | ||
|
|
22dc68ff4e | ||
|
|
4903966bea | ||
|
|
f43c462907 | ||
|
|
490dc17571 | ||
|
|
b57a77c8f0 | ||
|
|
fe0e5e8b89 | ||
|
|
3340bea150 | ||
|
|
0e73697ea4 | ||
|
|
4fcbec49c9 | ||
|
|
01994d8c6a | ||
|
|
31de7fd0ee | ||
|
|
744c451927 | ||
|
|
148474e1ba | ||
|
|
d4765bcfec | ||
|
|
e4ea2035ff | ||
|
|
3a28bacf14 | ||
|
|
6ba7d208c8 | ||
|
|
102fdf3b18 | ||
|
|
1f539fc8be | ||
|
|
806f417e99 | ||
|
|
6c04184a9c | ||
|
|
22ff17aec9 | ||
|
|
b2650947a9 | ||
|
|
604bf0c485 | ||
|
|
b7bf3678e5 | ||
|
|
b0430f7eee | ||
|
|
7d3e440a47 | ||
|
|
6877261b9c | ||
|
|
eef45a6015 | ||
|
|
0aee431527 | ||
|
|
90a18186d9 | ||
|
|
38aea7c455 | ||
|
|
e272048f24 | ||
|
|
6aa9f208ee | ||
|
|
b749bf7b08 | ||
|
|
ff3daed4c6 | ||
|
|
e90e10bd26 | ||
|
|
c6a49b048f | ||
|
|
29af079a8f | ||
|
|
9bb6be8e60 | ||
|
|
226daa990f | ||
|
|
b8e3809452 | ||
|
|
47881f77d9 | ||
|
|
69d0a5286e | ||
|
|
ebdd78edea | ||
|
|
eff7c363d4 | ||
|
|
44cd482695 | ||
|
|
a801e0c5e9 | ||
|
|
722f836714 | ||
|
|
1dd62be4ef | ||
|
|
33f731e247 | ||
|
|
7cf139f856 | ||
|
|
fd28c939f5 | ||
|
|
1ab68172cb | ||
|
|
87e9ae5a3e | ||
|
|
c47a7d78fe | ||
|
|
b10b3a3434 | ||
|
|
9d4de4b295 | ||
|
|
2e5b123749 | ||
|
|
3a6eaa3ddd | ||
|
|
eb42d739cb | ||
|
|
e5d5bd5ec8 | ||
|
|
24166a4271 | ||
|
|
232149817e | ||
|
|
a286834eb5 | ||
|
|
c500616bb4 | ||
|
|
42faa2e75b | ||
|
|
b19cf35d28 | ||
|
|
4585c7b649 | ||
|
|
326a2a1877 | ||
|
|
8d057613f5 | ||
|
|
0b00c2ad76 | ||
|
|
310f56a9b3 | ||
|
|
0a94e740d2 | ||
|
|
3f3a503def | ||
|
|
0413865a3b | ||
|
|
98268a95c2 | ||
|
|
1110a78e06 | ||
|
|
6086f76d95 | ||
|
|
268eaaa9ca | ||
|
|
ad1612d84a | ||
|
|
0389a58f64 | ||
|
|
6ee2f334f6 | ||
|
|
a5afdf4e91 | ||
|
|
ecaa570ff3 | ||
|
|
1d2d1e6df7 | ||
|
|
c242f0079c | ||
|
|
727c533f93 | ||
|
|
5961b7d951 | ||
|
|
bbab069bcd | ||
|
|
1cf3b776d7 | ||
|
|
1aa2643c18 | ||
|
|
1150c04298 | ||
|
|
9c57ab5221 | ||
|
|
3e61b8c21a | ||
|
|
99bed645f3 | ||
|
|
51f5982205 | ||
|
|
b7a06dd0cf | ||
|
|
e071e4cdbf | ||
|
|
470b18786e | ||
|
|
8f21453e82 | ||
|
|
fb76917ec3 | ||
|
|
c53500812c | ||
|
|
5e6b9e578b | ||
|
|
f9c495a596 | ||
|
|
e4ec8cccfd | ||
|
|
3be350786d | ||
|
|
7cd43de5d5 | ||
|
|
f698a05d53 | ||
|
|
518a15934f | ||
|
|
48dbd079cf | ||
|
|
efa22715d5 | ||
|
|
0d88fcc758 | ||
|
|
353e04bddd | ||
|
|
0a6c03079c | ||
|
|
a0a4549045 | ||
|
|
69c79c5e0a | ||
|
|
1edf60362e | ||
|
|
739990c732 | ||
|
|
c9cfead9bc | ||
|
|
d37f493c6a | ||
|
|
b3153ae0fd | ||
|
|
7fc5b833aa | ||
|
|
d48d4ed8f9 | ||
|
|
f57a7009a3 | ||
|
|
6c4888d275 | ||
|
|
3820d08af8 | ||
|
|
bba2783aa4 | ||
|
|
f650308986 | ||
|
|
bd13181042 | ||
|
|
6daad10210 | ||
|
|
52f758c6e1 | ||
|
|
290a88fd90 | ||
|
|
423f54e95d | ||
|
|
9e46b5117d | ||
|
|
e8ff6f509b | ||
|
|
e7e777ec7b | ||
|
|
f74f932dcd | ||
|
|
7fafb25821 | ||
|
|
dd256be4ec | ||
|
|
d743804b1d | ||
|
|
f8951b44fc | ||
|
|
ec70670630 | ||
|
|
ee07921d42 | ||
|
|
5548494a44 | ||
|
|
7c8ad4aee4 | ||
|
|
12b4325435 | ||
|
|
241d02584a | ||
|
|
0f450fd9bf | ||
|
|
ce02c514cf | ||
|
|
322ab9d854 | ||
|
|
d40ee71a2c | ||
|
|
c81bb0f15d | ||
|
|
b7fda194c8 | ||
|
|
c37f41c171 | ||
|
|
ced8d2a45f | ||
|
|
c580c34a35 | ||
|
|
fdf312d9e1 | ||
|
|
44d8b549c4 | ||
|
|
928dd27043 | ||
|
|
4419051347 | ||
|
|
8cf88019e5 | ||
|
|
710971a0cd | ||
|
|
dc306dfcd0 | ||
|
|
e90520a5ec | ||
|
|
7805bd1e45 | ||
|
|
c1c55ca700 | ||
|
|
8e34d2fbbc | ||
|
|
61afb64dd7 | ||
|
|
aa2bc545db | ||
|
|
067f122b05 | ||
|
|
9d9bb68d50 | ||
|
|
af5abae558 | ||
|
|
c59caa5d7f | ||
|
|
0ea8705014 | ||
|
|
92409820fb | ||
|
|
98fc6c6adf | ||
|
|
771bc6a14d | ||
|
|
86c36f53e4 | ||
|
|
5c24089f9f | ||
|
|
516c8d79ad | ||
|
|
ff7a8cade1 | ||
|
|
7af4cdffee | ||
|
|
b06838b651 | ||
|
|
b3a4c21c4b | ||
|
|
855881094b | ||
|
|
82d02e923a | ||
|
|
d11d66fa90 | ||
|
|
f5507436f3 | ||
|
|
eeea33c7cb | ||
|
|
7883ca7657 | ||
|
|
8efb8b2f86 | ||
|
|
408a30c25b | ||
|
|
9b67aa537a | ||
|
|
5aabf87898 | ||
|
|
67dbdcd257 | ||
|
|
3d137995d8 | ||
|
|
e424e9328b | ||
|
|
214ecf605b | ||
|
|
7d06d0660d | ||
|
|
c34eddb82a | ||
|
|
9969606432 | ||
|
|
d8abdb7927 | ||
|
|
71a60795cf | ||
|
|
d07ce0b8f4 | ||
|
|
565bc70843 | ||
|
|
7924861810 | ||
|
|
08dd92b726 | ||
|
|
dca5dc4fce | ||
|
|
24f3637199 | ||
|
|
4dd95c1639 | ||
|
|
4724669bce | ||
|
|
292c334460 | ||
|
|
dafdf66ada | ||
|
|
38424af48e | ||
|
|
88a33990b7 | ||
|
|
7ce305e16f | ||
|
|
1d1ba8607e | ||
|
|
9f6385f763 | ||
|
|
a68b591029 | ||
|
|
711207743b | ||
|
|
a8a7bb3c99 | ||
|
|
228c118714 | ||
|
|
0b86402ce3 | ||
|
|
2295f7a92b | ||
|
|
8e03eefa9b | ||
|
|
53040dbe1d | ||
|
|
6d5b5ab44f | ||
|
|
0a18985e68 | ||
|
|
047aa7deef | ||
|
|
945ed3f7cb | ||
|
|
e29ea99d2c | ||
|
|
3b19aaf1d1 | ||
|
|
15a91278d6 | ||
|
|
cb602dd377 | ||
|
|
7e2f365c1c | ||
|
|
8425be0612 | ||
|
|
c0199a38fd | ||
|
|
d97a8c1934 | ||
|
|
7c36ee7955 | ||
|
|
55dde3531e | ||
|
|
c3a8ae1eb5 | ||
|
|
edc9560d36 | ||
|
|
37cfb93217 | ||
|
|
28ee40074a | ||
|
|
0ba4598ca2 | ||
|
|
ecb5b0fdeb | ||
|
|
6cf23f1fd1 | ||
|
|
b86f034c0b | ||
|
|
ce3d7f21b0 | ||
|
|
b38d5f3465 | ||
|
|
a5ad0b185c | ||
|
|
4f5e135992 | ||
|
|
50d83d2374 | ||
|
|
64381be91d | ||
|
|
f47494e5c8 | ||
|
|
32a105bac8 | ||
|
|
65b17c9d18 | ||
|
|
aef159b097 |
62
.github/workflows/build.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- v4
|
||||
- stable
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
@@ -32,6 +32,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: true
|
||||
files: |
|
||||
LICENSE
|
||||
fail_on_unmatched_files: true
|
||||
@@ -49,24 +50,15 @@ jobs:
|
||||
include:
|
||||
- os: ubuntu-20.04
|
||||
cache_path: ~/.stack
|
||||
stack_args: "--test"
|
||||
artifact_rel_path: /bin/simplex-chat
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
- os: ubuntu-18.04
|
||||
cache_path: ~/.stack
|
||||
stack_args: "--test"
|
||||
artifact_rel_path: /bin/simplex-chat
|
||||
asset_name: simplex-chat-ubuntu-18_04-x86-64
|
||||
- os: macos-latest
|
||||
cache_path: ~/.stack
|
||||
stack_args: "--test"
|
||||
artifact_rel_path: /bin/simplex-chat
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
# TODO enable tests for windows once fixed (remove stack_args altogether)
|
||||
- os: windows-latest
|
||||
cache_path: C:/sr
|
||||
stack_args: ""
|
||||
artifact_rel_path: /bin/simplex-chat.exe
|
||||
asset_name: simplex-chat-windows-x86-64
|
||||
steps:
|
||||
- name: Clone project
|
||||
@@ -85,17 +77,51 @@ jobs:
|
||||
path: ${{ matrix.cache_path }}
|
||||
key: ${{ matrix.os }}-${{ hashFiles('stack.yaml') }}
|
||||
|
||||
- name: Build & test
|
||||
id: build_test
|
||||
run: |
|
||||
stack build ${{ matrix.stack_args }}
|
||||
echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)"
|
||||
# / Unix
|
||||
|
||||
- name: Upload binaries to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
- 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)"
|
||||
|
||||
- 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.build_test.outputs.LOCAL_INSTALL_ROOT }}${{ matrix.artifact_rel_path }}
|
||||
file: ${{ steps.unix_build.outputs.local_install_root }}/bin/simplex-chat
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
# Unix /
|
||||
|
||||
# / Windows
|
||||
|
||||
# * 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%
|
||||
|
||||
- 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
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
# Windows /
|
||||
|
||||
14
.gitignore
vendored
@@ -5,12 +5,6 @@
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
@@ -42,9 +36,9 @@ cabal.project.local~
|
||||
.ghc.environment.*
|
||||
stack.yaml.lock
|
||||
|
||||
# Idris
|
||||
*.ibc
|
||||
|
||||
# chat database
|
||||
# Chat database
|
||||
*.db
|
||||
*.db.bak
|
||||
|
||||
# Temporary test files
|
||||
tests/tmp
|
||||
|
||||
90
PRIVACY.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# SimpleX Chat Terms & Privacy Policy
|
||||
|
||||
SimpleX Chat is the first chat platform that is 100% private by design - not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we do not have access to your connections graph.
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||
|
||||
### Information you provide
|
||||
|
||||
We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
||||
|
||||
Messages. SimpleX Chat cannot decrypt or otherwise access the content or size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are temporarily offline. Your message history is stored only on your own devices.
|
||||
|
||||
Connections with other users. When you create a connection with another user, two messaging queues are created on our servers (we use separate queues for direct and response messages, that can be on two different servers), or on the servers that you configured in the app, in case it allows such configuration. At the time of updating this document only our terminal app allows configuring the servers, our mobile apps will allow such configuration in the near future. Our servers do not store information about which queues are linked to your profile on the device, and they do not have any information in common that allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of unique encryption keys, different for each queue, and separate for sender and recipient of the messages that are transmitted through the queue.
|
||||
|
||||
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
|
||||
|
||||
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support via chat, when it is possible.
|
||||
|
||||
### Information we may share
|
||||
|
||||
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
|
||||
|
||||
We use Third party to provide email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according their privacy policies and terms of service.
|
||||
|
||||
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
|
||||
|
||||
- To meet any applicable law, regulation, legal process or enforceable governmental request.
|
||||
- To enforce applicable Terms, including investigation of potential violations.
|
||||
- To detect, prevent, or otherwise address fraud, security, or technical issues.
|
||||
- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law.
|
||||
|
||||
### Updates
|
||||
|
||||
We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
||||
|
||||
Please also read our Terms of Service.
|
||||
|
||||
If you have questions about our Privacy Policy please contact us at chat@simplex.chat.
|
||||
|
||||
## Terms of Service
|
||||
|
||||
You accept to our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
||||
|
||||
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
|
||||
|
||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we do cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or cyphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||
|
||||
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per users - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
|
||||
|
||||
**Operating our services**. For the purpose of operating our Services, you agree that your end-to-end encrypted messages are transferred via our servers in the United Kingdom, the United States and other countries where we have or use facilities and service providers or partners.
|
||||
|
||||
**Software**. You agree to downloading and installing updates to our Services when they are available; they would only be automatic if you configure your devices in this way.
|
||||
|
||||
**Traffic and device costs**. You are solely responsible for the traffic and device costs on which you use our Services, and any associated taxes.
|
||||
|
||||
**Legal and acceptable usage**. You agree to use our Services only for legal and acceptable purposes. You will not use (or assist others in using) our Services in ways that: 1) violate or infringe the rights of SimpleX Chat, our users, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam.
|
||||
|
||||
**Damage to SimpleX Chat**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Services in unauthorized manners, or in ways that harm SimpleX Chat, our Services, or systems. For example, you must not 1) access our Services or systems without authorization, other than by using the apps; 2) disrupt the integrity or performance of our Services; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Services.
|
||||
|
||||
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
|
||||
|
||||
**Storing the messages on the device**. Currently the messages are stored in the database on your device without encryption. It means that if you make a backup of the app and store it unecrypted, the backup provider may be able to access the messages.
|
||||
|
||||
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
|
||||
|
||||
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
|
||||
|
||||
**Your Rights**. You own the mesasges and information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices.
|
||||
|
||||
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 licence](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
||||
|
||||
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
|
||||
|
||||
**Disclaimers**. YOU USE OUR SERVICES AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR SERVICES ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR SERVICES WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR SERVICES WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR SERVICES. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
|
||||
|
||||
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR TERMS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||
|
||||
**Availability**. Our Services may be interrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Services, including certain features and the support for certain devices and platforms, at any time.
|
||||
|
||||
**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Terms, us, or our Services in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Terms, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat and you, without regard to conflict of law provisions.
|
||||
|
||||
**Changes to the terms**. SimpleX Chat may update the Terms from time to time. Your continued use of our Services confirms your acceptance of our updated Terms and supersedes any prior Terms. You will comply with all applicable export control and trade sanctions laws. Our Terms cover the entire agreement between you and SimpleX Chat regarding our Services. If you do not agree with our Terms, you should stop using our Services.
|
||||
|
||||
**Enforcing the terms**. If we fail to enforce any of our Terms, that does not mean we waive the right to enforce them. If any provision of the Terms is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Terms and shall not affect the enforceability of the remaining provisions. Our Services are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Services in any country. If you have specific questions about these Terms, please contact us at chat@simplex.chat.
|
||||
|
||||
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
|
||||
|
||||
Updated March 1, 2022
|
||||
32
README.md
@@ -1,22 +1,32 @@
|
||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||
|
||||
# SimpleX Chat
|
||||
|
||||
SimpleX - the most private and secure open-source chat and applications platform - now with double-ratchet E2E encryption.
|
||||
# SimpleX - the first chat platform that is 100% private by design - it has no access to your connection graph!
|
||||
|
||||
[](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://twitter.com/simplexchat)
|
||||
[](https://twitter.com/SimpleXChat)
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
|
||||
SimpleX Chat is a terminal (command line) UI using [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker.
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||
|
||||
[](https://play.google.com/store/apps/details?id=chat.simplex.app)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
|
||||
|
||||
- 🖲 Protects your messages and metadata - who you talk to and when.
|
||||
- 🔐 Double ratchet encryption.
|
||||
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). [See the announcement here](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220308-simplex-chat-mobile-apps.md).
|
||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||
- 🖥 Available as a [terminal (console) app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
|
||||
|
||||
See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
|
||||
|
||||
**v1.0.0 is released: [read announcement here](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220112-simplex-chat-v1-released.md)**
|
||||
|
||||
### :zap: Quick installation
|
||||
### :zap: Quick installation of a terminal app
|
||||
|
||||
```sh
|
||||
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
|
||||
@@ -79,7 +89,7 @@ The routing of messages relies on the knowledge of client devices how user conta
|
||||
- Two layers of E2E encryption (double-ratchet for duplex connections, using X3DH key agreement with ephemeral Curve448 keys, and NaCl crypto_box for SMP queues, using Curve25519 keys) and out-of-band passing of recipient keys (see [How to use SimpleX chat](#how-to-use-simplex-chat)).
|
||||
- Message integrity validation (via including the digests of the previous messages).
|
||||
- Authentication of each command/message by SMP servers with automatically generated Ed448 keys.
|
||||
- TLS 1.2 transport encryption.
|
||||
- TLS 1.3 transport encryption.
|
||||
- Additional encryption of messages from SMP server to recipient to reduce traffic correlation.
|
||||
|
||||
Public keys involved in key exchange are not used as identity, they are randomly generated for each contact.
|
||||
@@ -125,6 +135,8 @@ move <binary> %APPDATA%/local/bin/simplex-chat.exe
|
||||
|
||||
### Build from source
|
||||
|
||||
> **Please note:** to build the app use source code from [stable branch](https://github.com/simplex-chat/simplex-chat/tree/stable).
|
||||
|
||||
#### Using Docker
|
||||
|
||||
On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs):
|
||||
@@ -132,6 +144,7 @@ On Linux, you can build the chat executable using [docker build with custom outp
|
||||
```shell
|
||||
$ git clone git@github.com:simplex-chat/simplex-chat.git
|
||||
$ cd simplex-chat
|
||||
$ git checkout stable
|
||||
$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
|
||||
```
|
||||
|
||||
@@ -150,6 +163,7 @@ and build the project:
|
||||
```shell
|
||||
$ git clone git@github.com:simplex-chat/simplex-chat.git
|
||||
$ cd simplex-chat
|
||||
$ git checkout stable
|
||||
$ stack install
|
||||
```
|
||||
|
||||
|
||||
17
apps/android/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/misc.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
1
apps/android/.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
SimpleX
|
||||
144
apps/android/.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,144 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="SPACE_BEFORE_EXTEND_COLON" value="false" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="3" />
|
||||
<option name="WRAP_EXPRESSION_BODY_FUNCTIONS" value="0" />
|
||||
<option name="WRAP_ELVIS_EXPRESSIONS" value="0" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
<option name="RIGHT_MARGIN" value="140" />
|
||||
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
|
||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
|
||||
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
||||
<option name="CALL_PARAMETERS_WRAP" value="0" />
|
||||
<option name="METHOD_PARAMETERS_WRAP" value="0" />
|
||||
<option name="EXTENDS_LIST_WRAP" value="0" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="0" />
|
||||
<option name="ASSIGNMENT_WRAP" value="0" />
|
||||
<option name="METHOD_ANNOTATION_WRAP" value="0" />
|
||||
<option name="CLASS_ANNOTATION_WRAP" value="0" />
|
||||
<option name="FIELD_ANNOTATION_WRAP" value="0" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
6
apps/android/.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
6
apps/android/.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
</component>
|
||||
</project>
|
||||
20
apps/android/.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveModulePerSourceSet" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
20
apps/android/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
apps/android/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
1
apps/android/app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
104
apps/android/app/build.gradle
Normal file
@@ -0,0 +1,104 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 32
|
||||
|
||||
defaultConfig {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 22
|
||||
versionName "1.5"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.material.ExperimentalMaterialApi"
|
||||
freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets"
|
||||
freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi"
|
||||
freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi"
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file('src/main/cpp/CMakeLists.txt')
|
||||
version '3.10.2'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion compose_version
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation "androidx.compose.ui:ui:$compose_version"
|
||||
implementation "androidx.compose.material:material:$compose_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
|
||||
implementation 'androidx.activity:activity-compose:1.4.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||
implementation "androidx.navigation:navigation-compose:2.4.1"
|
||||
implementation "com.google.accompanist:accompanist-insets:0.23.0"
|
||||
|
||||
def work_version = "2.7.1"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.work:work-multiprocess:$work_version"
|
||||
|
||||
def camerax_version = "1.1.0-beta01"
|
||||
implementation "androidx.camera:camera-core:${camerax_version}"
|
||||
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
||||
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
||||
implementation "androidx.camera:camera-view:${camerax_version}"
|
||||
//Barcode
|
||||
implementation 'com.google.zxing:core:3.4.0'
|
||||
implementation 'com.google.mlkit:barcode-scanning:17.0.2'
|
||||
//Camera Permission
|
||||
implementation "com.google.accompanist:accompanist-permissions:0.23.0"
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||
}
|
||||
21
apps/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,22 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("chat.simplex.app", appContext.packageName)
|
||||
}
|
||||
}
|
||||
79
apps/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="chat.simplex.app">
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name="SimplexApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/icon"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
|
||||
<!-- Main activity -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- open simplex:/ connection URI -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="simplex" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="chat.simplex.app.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
|
||||
<!-- SimplexService foreground service -->
|
||||
<service
|
||||
android:name=".SimplexService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false">
|
||||
</service>
|
||||
|
||||
<!-- SimplexService restart on reboot -->
|
||||
<receiver
|
||||
android:name=".SimplexService$StartReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- SimplexService restart on destruction -->
|
||||
<receiver
|
||||
android:name=".SimplexService$AutoRestartReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false"/>
|
||||
</application>
|
||||
</manifest>
|
||||
68
apps/android/app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,68 @@
|
||||
# For more information about using CMake with Android Studio, read the
|
||||
# documentation: https://d.android.com/studio/projects/add-native-code.html
|
||||
|
||||
# Sets the minimum version of CMake required to build the native library.
|
||||
|
||||
cmake_minimum_required(VERSION 3.10.2)
|
||||
|
||||
# Declares and names the project.
|
||||
|
||||
project("app")
|
||||
|
||||
# Creates and names a library, sets it as either STATIC
|
||||
# or SHARED, and provides the relative paths to its source code.
|
||||
# You can define multiple libraries, and CMake builds them for you.
|
||||
# Gradle automatically packages shared libraries with your APK.
|
||||
|
||||
add_library( # Sets the name of the library.
|
||||
app-lib
|
||||
|
||||
# Sets the library as a shared library.
|
||||
SHARED
|
||||
|
||||
# Provides a relative path to your source file(s).
|
||||
simplex-api.c)
|
||||
|
||||
# Searches for a specified prebuilt library and stores the path as a
|
||||
# variable. Because CMake includes system libraries in the search path by
|
||||
# default, you only need to specify the name of the public NDK library
|
||||
# you want to add. CMake verifies that the library exists before
|
||||
# completing its build.
|
||||
|
||||
find_library( # Sets the name of the path variable.
|
||||
log-lib
|
||||
|
||||
# Specifies the name of the NDK library that
|
||||
# you want CMake to locate.
|
||||
log)
|
||||
|
||||
find_library( # Sets the name of the path variable.
|
||||
c-lib
|
||||
|
||||
# Specifies the name of the NDK library that
|
||||
# you want CMake to locate.
|
||||
c
|
||||
NAMES libc.so
|
||||
REQUIRED)
|
||||
|
||||
add_library( simplex SHARED IMPORTED )
|
||||
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsimplex.so)
|
||||
|
||||
add_library( support SHARED IMPORTED )
|
||||
set_target_properties( support PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
|
||||
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
# can link multiple libraries, such as libraries you define in this
|
||||
# build script, prebuilt third-party libraries, or system libraries.
|
||||
|
||||
target_link_libraries( # Specifies the target library.
|
||||
app-lib
|
||||
|
||||
simplex support
|
||||
|
||||
# Links the target library to the log library
|
||||
# included in the NDK.
|
||||
${log-lib})
|
||||
50
apps/android/app/src/main/cpp/simplex-api.c
Normal file
@@ -0,0 +1,50 @@
|
||||
#include <jni.h>
|
||||
|
||||
// from the RTS
|
||||
void hs_init(int * argc, char **argv[]);
|
||||
|
||||
// from android-support
|
||||
void setLineBuffering(void);
|
||||
int pipe_std_to_socket(const char * name);
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
||||
int ret = pipe_std_to_socket(name);
|
||||
(*env)->ReleaseStringUTFChars(env, socket_name, name);
|
||||
return ret;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
|
||||
hs_init(NULL, NULL);
|
||||
setLineBuffering();
|
||||
}
|
||||
|
||||
// from simplex-chat
|
||||
typedef void* chat_ctrl;
|
||||
|
||||
extern chat_ctrl chat_init(const char * path);
|
||||
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
||||
extern char *chat_recv_msg(chat_ctrl ctrl);
|
||||
|
||||
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 jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
|
||||
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
|
||||
(*env)->ReleaseStringUTFChars(env, msg, _msg);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
|
||||
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
|
||||
}
|
||||
BIN
apps/android/app/src/main/icon-playstore.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
140
apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
Normal file
@@ -0,0 +1,140 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.SplashView
|
||||
import chat.simplex.app.views.WelcomeView
|
||||
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.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
//import kotlinx.serialization.decodeFromString
|
||||
|
||||
class MainActivity: ComponentActivity() {
|
||||
private val vm by viewModels<SimplexViewModel>()
|
||||
private val chatController by lazy { (application as SimplexApp).chatController }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// testJson()
|
||||
processIntent(intent, vm.chatModel)
|
||||
setContent {
|
||||
SimpleXTheme {
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
MainPage(vm.chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
schedulePeriodicServiceRestartWorker()
|
||||
}
|
||||
|
||||
private fun schedulePeriodicServiceRestartWorker() {
|
||||
val workerVersion = chatController.getAutoRestartWorkerVersion()
|
||||
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
||||
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
chatController.setAutoRestartWorkerVersion(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
.addTag(SimplexService.TAG)
|
||||
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
.build()
|
||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||
WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
}
|
||||
|
||||
class SimplexViewModel(application: Application): AndroidViewModel(application) {
|
||||
val app = getApplication<SimplexApp>()
|
||||
val chatModel = app.chatModel
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainPage(chatModel: ChatModel) {
|
||||
Box {
|
||||
when (chatModel.userCreated.value) {
|
||||
null -> SplashView()
|
||||
false -> WelcomeView(chatModel)
|
||||
true ->
|
||||
if (chatModel.chatId.value == null) ChatListView(chatModel)
|
||||
else ChatView(chatModel)
|
||||
}
|
||||
ModalManager.shared.showInView()
|
||||
AlertManager.shared.showInView()
|
||||
}
|
||||
}
|
||||
|
||||
fun processIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
when (intent?.action) {
|
||||
NtfManager.OpenChatAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
Log.d(TAG, "processIntent: OpenChatAction $chatId")
|
||||
if (chatId != null) {
|
||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||
if (cInfo != null) withApi { openChat(chatModel, cInfo) }
|
||||
}
|
||||
}
|
||||
"android.intent.action.VIEW" -> {
|
||||
val uri = intent.data
|
||||
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
// TODO open from chat list view
|
||||
chatModel.appOpenUrl.value = uri
|
||||
} else {
|
||||
withUriAction(uri) { action ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = "Connect via $action link?",
|
||||
text = "Your profile will be sent to the contact that you received this link from.",
|
||||
confirmText = "Connect",
|
||||
onConfirm = {
|
||||
withApi {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: connecting")
|
||||
connectViaUri(chatModel, action, uri)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//fun testJson() {
|
||||
// val str: String = """
|
||||
// """.trimIndent()
|
||||
//
|
||||
// println(json.decodeFromString<APIResponse>(str))
|
||||
//}
|
||||
108
apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.Application
|
||||
import android.net.LocalServerSocket
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.*
|
||||
import java.util.concurrent.Semaphore
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
const val TAG = "SIMPLEX"
|
||||
|
||||
// ghc's rts
|
||||
external fun initHS()
|
||||
// android-support
|
||||
external fun pipeStdOutToSocket(socketName: String) : Int
|
||||
|
||||
// SimpleX API
|
||||
typealias ChatCtrl = Long
|
||||
external fun chatInit(path: String): ChatCtrl
|
||||
external fun chatSendCmd(ctrl: ChatCtrl, msg: String) : String
|
||||
external fun chatRecvMsg(ctrl: ChatCtrl) : String
|
||||
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
val chatController: ChatController by lazy {
|
||||
val ctrl = chatInit(applicationContext.filesDir.toString())
|
||||
ChatController(ctrl, ntfManager, applicationContext)
|
||||
}
|
||||
|
||||
val chatModel: ChatModel by lazy {
|
||||
chatController.chatModel
|
||||
}
|
||||
|
||||
private val ntfManager: NtfManager by lazy {
|
||||
NtfManager(applicationContext)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
withApi {
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user != null) {
|
||||
chatController.startChat(user)
|
||||
SimplexService.start(applicationContext)
|
||||
chatController.showBackgroundServiceNotice()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
Log.d(TAG, "onStateChanged: $event")
|
||||
withApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP ->
|
||||
if (!chatController.getRunServiceInBackground()) SimplexService.stop(applicationContext)
|
||||
Lifecycle.Event.ON_START ->
|
||||
SimplexService.start(applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
init {
|
||||
val socketName = "local.socket.address.listen.native.cmd2"
|
||||
|
||||
val s = Semaphore(0)
|
||||
thread(name="stdout/stderr pipe") {
|
||||
Log.d(TAG, "starting server")
|
||||
val server = LocalServerSocket(socketName)
|
||||
Log.d(TAG, "started server")
|
||||
s.release()
|
||||
val receiver = server.accept()
|
||||
Log.d(TAG, "started receiver")
|
||||
val logbuffer = FifoQueue<String>(500)
|
||||
if (receiver != null) {
|
||||
val inStream = receiver.inputStream
|
||||
val inStreamReader = InputStreamReader(inStream)
|
||||
val input = BufferedReader(inStreamReader)
|
||||
|
||||
while(true) {
|
||||
val line = input.readLine() ?: break
|
||||
Log.w("$TAG (stdout/stderr)", line)
|
||||
logbuffer.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
System.loadLibrary("app-lib")
|
||||
|
||||
s.acquire()
|
||||
pipeStdOutToSocket(socketName)
|
||||
|
||||
initHS()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
|
||||
override fun add(element: E): Boolean {
|
||||
if(size > capacity) removeFirst()
|
||||
return super.add(element)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.os.*
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// based on:
|
||||
// https://robertohuertas.com/2019/06/29/android_foreground_services/
|
||||
// https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt
|
||||
|
||||
class SimplexService: Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var isServiceStarted = false
|
||||
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")
|
||||
if (intent != null) {
|
||||
val action = intent.action
|
||||
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 {
|
||||
Log.d(TAG, "null intent. Probably restarted by the system.")
|
||||
}
|
||||
return START_STICKY // to restart if killed
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Simplex service created")
|
||||
val title = getString(R.string.simplex_service_notification_title)
|
||||
val text = getString(R.string.simplex_service_notification_text)
|
||||
notificationManager = createNotificationChannel()
|
||||
serviceNotification = createNotification(title, text)
|
||||
|
||||
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Simplex service destroyed")
|
||||
stopService()
|
||||
sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart if necessary!
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startService() {
|
||||
Log.d(TAG, "SimplexService startService")
|
||||
if (isServiceStarted || isStartingService) return
|
||||
val self = this
|
||||
isStartingService = true
|
||||
withApi {
|
||||
try {
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user != null) {
|
||||
Log.w(TAG, "Starting foreground service")
|
||||
chatController.startChat(user)
|
||||
chatController.startReceiver()
|
||||
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 {
|
||||
isStartingService = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopService() {
|
||||
Log.d(TAG, "Stopping foreground service")
|
||||
try {
|
||||
wakeLock?.let {
|
||||
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||
}
|
||||
wakeLock = null
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Service stopped without being started: ${e.message}")
|
||||
}
|
||||
|
||||
isServiceStarted = false
|
||||
saveServiceState(this, ServiceState.STOPPED)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(): NotificationManager? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).let {
|
||||
it.setShowBadge(false) // no long-press badge
|
||||
it
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
return notificationManager
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun createNotification(title: String, text: String): Notification {
|
||||
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
|
||||
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setColor(0x88FFFF)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setSound(null)
|
||||
.setShowWhen(false) // no date/time
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null // no binding
|
||||
}
|
||||
|
||||
// re-schedules the task when "Clear recent apps" is pressed
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
|
||||
it.setPackage(packageName)
|
||||
};
|
||||
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE);
|
||||
applicationContext.getSystemService(Context.ALARM_SERVICE);
|
||||
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
|
||||
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
|
||||
}
|
||||
|
||||
// restart on reboot
|
||||
class StartReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "StartReceiver: onReceive called")
|
||||
scheduleStart(context)
|
||||
}
|
||||
}
|
||||
|
||||
// restart on destruction
|
||||
class AutoRestartReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "AutoRestartReceiver: onReceive called")
|
||||
scheduleStart(context)
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceStartWorker(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val id = this.id
|
||||
if (context.applicationContext !is Application) {
|
||||
Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: $id)")
|
||||
return Result.failure()
|
||||
}
|
||||
if (getServiceState(context) == ServiceState.STARTED) {
|
||||
Log.d(TAG, "ServiceStartWorker: Starting foreground service (work ID: $id)")
|
||||
start(context)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
enum class Action {
|
||||
START,
|
||||
STOP
|
||||
}
|
||||
|
||||
enum class ServiceState {
|
||||
STARTED,
|
||||
STOPPED,
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "SIMPLEX_SERVICE"
|
||||
const val NOTIFICATION_CHANNEL_ID = "chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION"
|
||||
const val NOTIFICATION_CHANNEL_NAME = "SimpleX Chat service"
|
||||
const val SIMPLEX_SERVICE_ID = 6789
|
||||
const val SERVICE_START_WORKER_VERSION = BuildConfig.VERSION_CODE
|
||||
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
|
||||
const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change!
|
||||
|
||||
private const val WAKE_LOCK_TAG = "SimplexService::lock"
|
||||
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS"
|
||||
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
||||
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||
|
||||
fun scheduleStart(context: Context) {
|
||||
Log.d(TAG, "Enqueuing work to start subscriber service")
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
|
||||
workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
|
||||
}
|
||||
|
||||
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
||||
|
||||
suspend fun stop(context: Context) = serviceAction(context, Action.STOP)
|
||||
|
||||
private suspend fun serviceAction(context: Context, action: Action) {
|
||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||
withContext(Dispatchers.IO) {
|
||||
Intent(context, SimplexService::class.java).also {
|
||||
it.action = action.name
|
||||
ContextCompat.startForegroundService(context, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restart(context: Context) {
|
||||
Intent(context, SimplexService::class.java).also { intent ->
|
||||
context.stopService(intent) // Service will auto-restart
|
||||
}
|
||||
}
|
||||
|
||||
fun saveServiceState(context: Context, state: ServiceState) {
|
||||
getPreferences(context).edit()
|
||||
.putString(SHARED_PREFS_SERVICE_STATE, state.name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getServiceState(context: Context): ServiceState {
|
||||
val value = getPreferences(context)
|
||||
.getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name)
|
||||
return ServiceState.valueOf(value!!)
|
||||
}
|
||||
|
||||
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
package chat.simplex.app.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.app.ui.theme.SecretColor
|
||||
import chat.simplex.app.ui.theme.SimplexBlue
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.descriptors.*
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
class ChatModel(val controller: ChatController) {
|
||||
var currentUser = mutableStateOf<User?>(null)
|
||||
var userCreated = mutableStateOf<Boolean?>(null)
|
||||
var chats = mutableStateListOf<Chat>()
|
||||
var chatId = mutableStateOf<String?>(null)
|
||||
var chatItems = mutableStateListOf<ChatItem>()
|
||||
|
||||
var connReqInvitation: String? = null
|
||||
var terminalItems = mutableStateListOf<TerminalItem>()
|
||||
var userAddress = mutableStateOf<String?>(null)
|
||||
var userSMPServers = mutableStateOf<(List<String>)?>(null)
|
||||
// set when app is opened via contact or invitation URI
|
||||
var appOpenUrl = mutableStateOf<Uri?>(null)
|
||||
var runServiceInBackground = mutableStateOf(true)
|
||||
|
||||
fun updateUserProfile(profile: Profile) {
|
||||
val user = currentUser.value
|
||||
if (user != null) {
|
||||
currentUser.value = user.copy(profile = profile)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
|
||||
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
|
||||
private fun getChatIndex(id: String): Int = chats.indexOfFirst { it.id == id }
|
||||
fun addChat(chat: Chat) = chats.add(index = 0, chat)
|
||||
|
||||
fun updateChatInfo(cInfo: ChatInfo) {
|
||||
val i = getChatIndex(cInfo.id)
|
||||
if (i >= 0) chats[i] = chats[i].copy(chatInfo = cInfo)
|
||||
}
|
||||
|
||||
fun updateContact(contact: Contact) {
|
||||
val cInfo = ChatInfo.Direct(contact)
|
||||
if (hasChat(contact.id)) {
|
||||
updateChatInfo(cInfo)
|
||||
} else {
|
||||
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf()))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNetworkStatus(contact: Contact, status: Chat.NetworkStatus) {
|
||||
val i = getChatIndex(contact.id)
|
||||
if (i >= 0) {
|
||||
val chat = chats[i]
|
||||
chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status))
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceChat(id: String, chat: Chat) {
|
||||
val i = getChatIndex(id)
|
||||
if (i >= 0) {
|
||||
chats[i] = chat
|
||||
} else {
|
||||
// invalid state, correcting
|
||||
chats.add(index = 0, chat)
|
||||
}
|
||||
}
|
||||
|
||||
fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
// update previews
|
||||
val i = getChatIndex(cInfo.id)
|
||||
val chat: Chat
|
||||
if (i >= 0) {
|
||||
chat = chats[i]
|
||||
chats[i] = chat.copy(
|
||||
chatItems = arrayListOf(cItem),
|
||||
chatStats =
|
||||
if (cItem.meta.itemStatus is CIStatus.RcvNew) {
|
||||
val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId
|
||||
chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, minUnreadItemId = minUnreadId)
|
||||
}
|
||||
else
|
||||
chat.chatStats
|
||||
)
|
||||
if (i > 0) {
|
||||
popChat_(i)
|
||||
}
|
||||
} else {
|
||||
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
|
||||
}
|
||||
// add to current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
chatItems.add(cItem)
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean {
|
||||
// update previews
|
||||
val i = getChatIndex(cInfo.id)
|
||||
val chat: Chat
|
||||
val res: Boolean
|
||||
if (i >= 0) {
|
||||
chat = chats[i]
|
||||
val pItem = chat.chatItems.last()
|
||||
if (pItem.id == cItem.id) {
|
||||
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
|
||||
}
|
||||
res = false
|
||||
} else {
|
||||
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
|
||||
res = true
|
||||
}
|
||||
// update current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
chatItems[itemIndex] = cItem
|
||||
return false
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
// update previews
|
||||
val i = getChatIndex(cInfo.id)
|
||||
val chat: Chat
|
||||
if (i >= 0) {
|
||||
chat = chats[i]
|
||||
val pItem = chat.chatItems.last()
|
||||
if (pItem.id == cItem.id) {
|
||||
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
|
||||
}
|
||||
}
|
||||
// remove from current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
chatItems.removeAt(itemIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markChatItemsRead(cInfo: ChatInfo) {
|
||||
val chatIdx = getChatIndex(cInfo.id)
|
||||
// update current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
var i = 0
|
||||
while (i < chatItems.count()) {
|
||||
val item = chatItems[i]
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew) {
|
||||
chatItems[i] = item.copy(meta=item.meta.copy(itemStatus = CIStatus.RcvRead()))
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
val chat = chats[chatIdx]
|
||||
chats[chatIdx] = chat.copy(
|
||||
chatItems = chatItems,
|
||||
chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = chat.chatItems.last().id + 1)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// func popChat(_ id: String) {
|
||||
// if let i = getChatIndex(id) {
|
||||
// popChat_(i)
|
||||
// }
|
||||
// }
|
||||
|
||||
private fun popChat_(i: Int) {
|
||||
val chat = chats.removeAt(i)
|
||||
chats.add(index = 0, chat)
|
||||
}
|
||||
|
||||
fun removeChat(id: String) {
|
||||
chats.removeAll { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChatType(val type: String) {
|
||||
Direct("@"),
|
||||
Group("#"),
|
||||
ContactRequest("<@");
|
||||
|
||||
val chatTypeName: String get () =
|
||||
when (this) {
|
||||
Direct -> "contact"
|
||||
Group -> "group"
|
||||
ContactRequest -> "contact request"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class User(
|
||||
val userId: Long,
|
||||
val userContactId: Long,
|
||||
val localDisplayName: String,
|
||||
val profile: Profile,
|
||||
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
|
||||
|
||||
companion object {
|
||||
val sampleData = User(
|
||||
userId = 1,
|
||||
userContactId = 1,
|
||||
localDisplayName = "alice",
|
||||
profile = Profile.sampleData,
|
||||
activeUser = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
typealias ChatId = String
|
||||
|
||||
interface NamedChat {
|
||||
val displayName: String
|
||||
val fullName: String
|
||||
val image: String?
|
||||
val chatViewName: String
|
||||
get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName")
|
||||
}
|
||||
|
||||
interface SomeChat {
|
||||
val chatType: ChatType
|
||||
val localDisplayName: String
|
||||
val id: ChatId
|
||||
val apiId: Long
|
||||
val ready: Boolean
|
||||
val createdAt: Instant
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Chat (
|
||||
val chatInfo: ChatInfo,
|
||||
val chatItems: List<ChatItem>,
|
||||
val chatStats: ChatStats = ChatStats(),
|
||||
val serverInfo: ServerInfo = ServerInfo(NetworkStatus.Unknown())
|
||||
) {
|
||||
val id: String get() = chatInfo.id
|
||||
|
||||
@Serializable
|
||||
data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0)
|
||||
|
||||
@Serializable
|
||||
data class ServerInfo(val networkStatus: NetworkStatus)
|
||||
|
||||
@Serializable
|
||||
sealed class NetworkStatus {
|
||||
val statusString: String get() = if (this is Connected) "Server connected" else "Connecting server…"
|
||||
val statusExplanation: String get() =
|
||||
when {
|
||||
this is Connected -> "You are connected to the server used to receive messages from this contact."
|
||||
this is Error -> "Trying to connect to the server used to receive messages from this contact (error: $error)."
|
||||
else -> "Trying to connect to the server used to receive messages from this contact."
|
||||
}
|
||||
|
||||
@Serializable @SerialName("unknown") class Unknown: NetworkStatus()
|
||||
@Serializable @SerialName("connected") class Connected: NetworkStatus()
|
||||
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
|
||||
@Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val sampleData = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = arrayListOf(ChatItem.getSampleData())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ChatInfo: SomeChat, NamedChat {
|
||||
@Serializable @SerialName("direct")
|
||||
class Direct(val contact: Contact): ChatInfo() {
|
||||
override val chatType get() = ChatType.Direct
|
||||
override val localDisplayName get() = contact.localDisplayName
|
||||
override val id get() = contact.id
|
||||
override val apiId get() = contact.apiId
|
||||
override val ready get() = contact.ready
|
||||
override val createdAt get() = contact.createdAt
|
||||
override val displayName get() = contact.displayName
|
||||
override val fullName get() = contact.fullName
|
||||
override val image get() = contact.image
|
||||
|
||||
companion object {
|
||||
val sampleData = Direct(Contact.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable @SerialName("group")
|
||||
class Group(val groupInfo: GroupInfo): ChatInfo() {
|
||||
override val chatType get() = ChatType.Group
|
||||
override val localDisplayName get() = groupInfo.localDisplayName
|
||||
override val id get() = groupInfo.id
|
||||
override val apiId get() = groupInfo.apiId
|
||||
override val ready get() = groupInfo.ready
|
||||
override val createdAt get() = groupInfo.createdAt
|
||||
override val displayName get() = groupInfo.displayName
|
||||
override val fullName get() = groupInfo.fullName
|
||||
override val image get() = groupInfo.image
|
||||
|
||||
companion object {
|
||||
val sampleData = Group(GroupInfo.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable @SerialName("contactRequest")
|
||||
class ContactRequest(val contactRequest: UserContactRequest): ChatInfo() {
|
||||
override val chatType get() = ChatType.ContactRequest
|
||||
override val localDisplayName get() = contactRequest.localDisplayName
|
||||
override val id get() = contactRequest.id
|
||||
override val apiId get() = contactRequest.apiId
|
||||
override val ready get() = contactRequest.ready
|
||||
override val createdAt get() = contactRequest.createdAt
|
||||
override val displayName get() = contactRequest.displayName
|
||||
override val fullName get() = contactRequest.fullName
|
||||
override val image get() = contactRequest.image
|
||||
|
||||
companion object {
|
||||
val sampleData = ContactRequest(UserContactRequest.sampleData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Contact(
|
||||
val contactId: Long,
|
||||
override val localDisplayName: String,
|
||||
val profile: Profile,
|
||||
val activeConn: Connection,
|
||||
val viaGroup: Long? = null,
|
||||
override val createdAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
override val chatType get() = ChatType.Direct
|
||||
override val id get() = "@$contactId"
|
||||
override val apiId get() = contactId
|
||||
override val ready get() = activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready"
|
||||
override val displayName get() = profile.displayName
|
||||
override val fullName get() = profile.fullName
|
||||
override val image get() = profile.image
|
||||
|
||||
companion object {
|
||||
val sampleData = Contact(
|
||||
contactId = 1,
|
||||
localDisplayName = "alice",
|
||||
profile = Profile.sampleData,
|
||||
activeConn = Connection.sampleData,
|
||||
createdAt = Clock.System.now()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ContactSubStatus(
|
||||
val contact: Contact,
|
||||
val contactError: ChatError? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Connection(val connStatus: String) {
|
||||
companion object {
|
||||
val sampleData = Connection(connStatus = "ready")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Profile(
|
||||
val displayName: String,
|
||||
val fullName: String,
|
||||
val image: String? = null
|
||||
) {
|
||||
companion object {
|
||||
val sampleData = Profile(
|
||||
displayName = "alice",
|
||||
fullName = "Alice"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class GroupInfo (
|
||||
val groupId: Long,
|
||||
override val localDisplayName: String,
|
||||
val groupProfile: GroupProfile,
|
||||
override val createdAt: 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 displayName get() = groupProfile.displayName
|
||||
override val fullName get() = groupProfile.fullName
|
||||
override val image get() = groupProfile.image
|
||||
|
||||
companion object {
|
||||
val sampleData = GroupInfo(
|
||||
groupId = 1,
|
||||
localDisplayName = "team",
|
||||
groupProfile = GroupProfile.sampleData,
|
||||
createdAt = Clock.System.now()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class GroupProfile (
|
||||
override val displayName: String,
|
||||
override val fullName: String,
|
||||
override val image: String? = null
|
||||
): NamedChat {
|
||||
companion object {
|
||||
val sampleData = GroupProfile(
|
||||
displayName = "team",
|
||||
fullName = "My Team"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class GroupMember (
|
||||
val groupMemberId: Long,
|
||||
val memberId: String,
|
||||
// var memberRole: GroupMemberRole
|
||||
// var memberCategory: GroupMemberCategory
|
||||
// var memberStatus: GroupMemberStatus
|
||||
// var invitedBy: InvitedBy
|
||||
val localDisplayName: String,
|
||||
val memberProfile: Profile,
|
||||
val memberContactId: Long?
|
||||
// var activeConn: Connection?
|
||||
) {
|
||||
companion object {
|
||||
val sampleData = GroupMember(
|
||||
groupMemberId = 1,
|
||||
memberId = "abcd",
|
||||
localDisplayName = "alice",
|
||||
memberProfile = Profile.sampleData,
|
||||
memberContactId = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class MemberSubError (
|
||||
val member: GroupMember,
|
||||
val memberError: ChatError
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class UserContactRequest (
|
||||
val contactRequestId: Long,
|
||||
override val localDisplayName: String,
|
||||
val profile: Profile,
|
||||
override val createdAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
override val chatType get() = ChatType.ContactRequest
|
||||
override val id get() = "<@$contactRequestId"
|
||||
override val apiId get() = contactRequestId
|
||||
override val ready get() = true
|
||||
override val displayName get() = profile.displayName
|
||||
override val fullName get() = profile.fullName
|
||||
override val image get() = profile.image
|
||||
|
||||
companion object {
|
||||
val sampleData = UserContactRequest(
|
||||
contactRequestId = 1,
|
||||
localDisplayName = "alice",
|
||||
profile = Profile.sampleData,
|
||||
createdAt = Clock.System.now()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class AChatItem (
|
||||
val chatInfo: ChatInfo,
|
||||
val chatItem: ChatItem
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatItem (
|
||||
val chatDir: CIDirection,
|
||||
val meta: CIMeta,
|
||||
val content: CIContent,
|
||||
val formattedText: List<FormattedText>? = null,
|
||||
val quotedItem: CIQuote? = null
|
||||
) {
|
||||
val id: Long get() = meta.itemId
|
||||
val timestampText: String get() = meta.timestampText
|
||||
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
|
||||
|
||||
val memberDisplayName: String? get() =
|
||||
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName
|
||||
else null
|
||||
|
||||
val isMsgContent: Boolean get() =
|
||||
when (content) {
|
||||
is CIContent.SndMsgContent -> true
|
||||
is CIContent.RcvMsgContent -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
val isDeletedContent: Boolean get() =
|
||||
when (content) {
|
||||
is CIContent.SndDeleted -> true
|
||||
is CIContent.RcvDeleted -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getSampleData(
|
||||
id: Long = 1,
|
||||
dir: CIDirection = CIDirection.DirectSnd(),
|
||||
ts: Instant = Clock.System.now(),
|
||||
text: String = "hello\nthere",
|
||||
status: CIStatus = CIStatus.SndNew(),
|
||||
quotedItem: CIQuote? = null,
|
||||
itemDeleted: Boolean = false,
|
||||
itemEdited: Boolean = false,
|
||||
editable: Boolean = true
|
||||
) =
|
||||
ChatItem(
|
||||
chatDir = dir,
|
||||
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
|
||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
|
||||
quotedItem = quotedItem
|
||||
)
|
||||
|
||||
fun getDeletedContentSampleData(
|
||||
id: Long = 1,
|
||||
dir: CIDirection = CIDirection.DirectRcv(),
|
||||
ts: Instant = Clock.System.now(),
|
||||
text: String = "this item is deleted",
|
||||
status: CIStatus = CIStatus.RcvRead()
|
||||
) =
|
||||
ChatItem(
|
||||
chatDir = dir,
|
||||
meta = CIMeta.getSample(id, ts, text, status, false, false, false),
|
||||
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
|
||||
quotedItem = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CIDirection {
|
||||
abstract val sent: Boolean
|
||||
|
||||
@Serializable @SerialName("directSnd")
|
||||
class DirectSnd: CIDirection() {
|
||||
override val sent get() = true
|
||||
}
|
||||
|
||||
@Serializable @SerialName("directRcv")
|
||||
class DirectRcv: CIDirection() {
|
||||
override val sent get() = false
|
||||
}
|
||||
|
||||
@Serializable @SerialName("groupSnd")
|
||||
class GroupSnd: CIDirection() {
|
||||
override val sent get() = true
|
||||
}
|
||||
|
||||
@Serializable @SerialName("groupRcv")
|
||||
class GroupRcv(val groupMember: GroupMember): CIDirection() {
|
||||
override val sent get() = false
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CIMeta (
|
||||
val itemId: Long,
|
||||
val itemTs: Instant,
|
||||
val itemText: String,
|
||||
val itemStatus: CIStatus,
|
||||
val createdAt: Instant,
|
||||
val itemDeleted: Boolean,
|
||||
val itemEdited: Boolean,
|
||||
val editable: Boolean
|
||||
) {
|
||||
val timestampText: String get() = getTimestampText(itemTs)
|
||||
|
||||
companion object {
|
||||
fun getSample(
|
||||
id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
|
||||
itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true
|
||||
): CIMeta =
|
||||
CIMeta(
|
||||
itemId = id,
|
||||
itemTs = ts,
|
||||
itemText = text,
|
||||
itemStatus = status,
|
||||
createdAt = ts,
|
||||
itemDeleted = itemDeleted,
|
||||
itemEdited = itemEdited,
|
||||
editable = editable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTimestampText(t: Instant): String {
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
|
||||
val time: LocalDateTime = t.toLocalDateTime(tz)
|
||||
val recent = now.date == time.date ||
|
||||
(now.date.minus(time.date).days == 1 && now.hour < 12 && time.hour >= 18 )
|
||||
return if (recent) String.format("%02d:%02d", time.hour, time.minute)
|
||||
else String.format("%02d/%02d", time.dayOfMonth, time.monthNumber)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CIStatus {
|
||||
@Serializable @SerialName("sndNew")
|
||||
class SndNew: CIStatus()
|
||||
|
||||
@Serializable @SerialName("sndSent")
|
||||
class SndSent: CIStatus()
|
||||
|
||||
@Serializable @SerialName("sndErrorAuth")
|
||||
class SndErrorAuth: CIStatus()
|
||||
|
||||
@Serializable @SerialName("sndError")
|
||||
class SndError(val agentError: AgentErrorType): CIStatus()
|
||||
|
||||
@Serializable @SerialName("rcvNew")
|
||||
class RcvNew: CIStatus()
|
||||
|
||||
@Serializable @SerialName("rcvRead")
|
||||
class RcvRead: CIStatus()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CIDeleteMode(val deleteMode: String) {
|
||||
@SerialName("internal") cidmInternal("internal"),
|
||||
@SerialName("broadcast") cidmBroadcast("broadcast");
|
||||
}
|
||||
|
||||
interface ItemContent {
|
||||
val text: String
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CIContent: ItemContent {
|
||||
abstract override val text: String
|
||||
|
||||
@Serializable @SerialName("sndMsgContent")
|
||||
class SndMsgContent(val msgContent: MsgContent): CIContent() {
|
||||
override val text get() = msgContent.text
|
||||
}
|
||||
|
||||
@Serializable @SerialName("rcvMsgContent")
|
||||
class RcvMsgContent(val msgContent: MsgContent): CIContent() {
|
||||
override val text get() = msgContent.text
|
||||
}
|
||||
|
||||
@Serializable @SerialName("sndDeleted")
|
||||
class SndDeleted(val deleteMode: CIDeleteMode): CIContent() {
|
||||
override val text get() = "deleted"
|
||||
}
|
||||
|
||||
@Serializable @SerialName("rcvDeleted")
|
||||
class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() {
|
||||
override val text get() = "deleted"
|
||||
}
|
||||
|
||||
@Serializable @SerialName("sndFileInvitation")
|
||||
class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() {
|
||||
override val text get() = "sending files is not supported yet"
|
||||
}
|
||||
|
||||
@Serializable @SerialName("rcvFileInvitation")
|
||||
class RcvFileInvitation(val rcvFileTransfer: RcvFileTransfer): CIContent() {
|
||||
override val text get() = "receiving files is not supported yet"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class CIQuote (
|
||||
val chatDir: CIDirection? = null,
|
||||
val itemId: Long? = null,
|
||||
val sharedMsgId: String? = null,
|
||||
val sentAt: Instant,
|
||||
val content: MsgContent,
|
||||
val formattedText: List<FormattedText>? = null
|
||||
): ItemContent {
|
||||
override val text: String get() = content.text
|
||||
|
||||
fun sender(user: User): String? = when (chatDir) {
|
||||
is CIDirection.DirectSnd -> "you"
|
||||
is CIDirection.DirectRcv -> null
|
||||
is CIDirection.GroupSnd -> user.displayName
|
||||
is CIDirection.GroupRcv -> chatDir.groupMember.memberProfile.displayName
|
||||
null -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote =
|
||||
CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text))
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable(with = MsgContentSerializer::class)
|
||||
sealed class MsgContent {
|
||||
abstract val text: String
|
||||
|
||||
class MCText(override val text: String): MsgContent()
|
||||
class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
is MCText -> "text $text"
|
||||
is MCUnknown -> "json $json"
|
||||
}
|
||||
}
|
||||
|
||||
object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) {
|
||||
element("MCText", buildClassSerialDescriptor("MCText") {
|
||||
element<String>("text")
|
||||
})
|
||||
element("MCUnknown", buildClassSerialDescriptor("MCUnknown"))
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): MsgContent {
|
||||
require(decoder is JsonDecoder)
|
||||
val json = decoder.decodeJsonElement()
|
||||
return if (json is JsonObject) {
|
||||
if ("type" in json) {
|
||||
val t = json["type"]?.jsonPrimitive?.content ?: ""
|
||||
val text = json["text"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||
when (t) {
|
||||
"text" -> MsgContent.MCText(text)
|
||||
else -> MsgContent.MCUnknown(t, text, json)
|
||||
}
|
||||
} else {
|
||||
MsgContent.MCUnknown(text = "invalid message format", json = json)
|
||||
}
|
||||
} else {
|
||||
MsgContent.MCUnknown(text = "invalid message format", json = json)
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: MsgContent) {
|
||||
require(encoder is JsonEncoder)
|
||||
val json = when (value) {
|
||||
is MsgContent.MCText ->
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", value.text)
|
||||
}
|
||||
is MsgContent.MCUnknown -> value.json
|
||||
}
|
||||
encoder.encodeJsonElement(json)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class FormattedText(val text: String, val format: Format? = null) {
|
||||
val link: String? = when (format) {
|
||||
is Format.Uri -> text
|
||||
is Format.Email -> "mailto:$text"
|
||||
is Format.Phone -> "tel:$text"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class Format {
|
||||
@Serializable @SerialName("bold") class Bold: Format()
|
||||
@Serializable @SerialName("italic") class Italic: Format()
|
||||
@Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
|
||||
@Serializable @SerialName("snippet") class Snippet: Format()
|
||||
@Serializable @SerialName("secret") class Secret: Format()
|
||||
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
|
||||
@Serializable @SerialName("uri") class Uri: Format()
|
||||
@Serializable @SerialName("email") class Email: Format()
|
||||
@Serializable @SerialName("phone") class Phone: Format()
|
||||
|
||||
val style: SpanStyle @Composable get() = when (this) {
|
||||
is Bold -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
is Italic -> SpanStyle(fontStyle = FontStyle.Italic)
|
||||
is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
|
||||
is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
|
||||
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
|
||||
is Colored -> SpanStyle(color = this.color.uiColor)
|
||||
is Uri -> linkStyle
|
||||
is Email -> linkStyle
|
||||
is Phone -> linkStyle
|
||||
}
|
||||
|
||||
companion object {
|
||||
val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class FormatColor(val color: String) {
|
||||
red("red"),
|
||||
green("green"),
|
||||
blue("blue"),
|
||||
yellow("yellow"),
|
||||
cyan("cyan"),
|
||||
magenta("magenta"),
|
||||
black("black"),
|
||||
white("white");
|
||||
|
||||
val uiColor: Color @Composable get() = when (this) {
|
||||
red -> Color.Red
|
||||
green -> Color.Green
|
||||
blue -> SimplexBlue
|
||||
yellow -> Color.Yellow
|
||||
cyan -> Color.Cyan
|
||||
magenta -> Color.Magenta
|
||||
black -> MaterialTheme.colors.onBackground
|
||||
white -> MaterialTheme.colors.onBackground
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class RcvFileTransfer
|
||||
@@ -0,0 +1,90 @@
|
||||
package chat.simplex.app.model
|
||||
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import chat.simplex.app.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class NtfManager(val context: Context) {
|
||||
companion object {
|
||||
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
|
||||
}
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private var prevNtfTime = mutableMapOf<String, Long>()
|
||||
private val msgNtfTimeoutMs = 30000L
|
||||
|
||||
init {
|
||||
manager.createNotificationChannel(NotificationChannel(
|
||||
MessageChannel,
|
||||
"SimpleX Chat messages",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
))
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
Log.d(TAG, "notifyMessageReceived ${cInfo.id}")
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
val recentNotification = (now - prevNtfTime.getOrDefault(cInfo.id, 0) < msgNtfTimeoutMs)
|
||||
prevNtfTime[cInfo.id] = now
|
||||
|
||||
val notification = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setContentTitle(cInfo.displayName)
|
||||
.setContentText(hideSecrets(cItem))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setGroup(MessageGroup)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(getMsgPendingIntent(cInfo))
|
||||
.setSilent(recentNotification)
|
||||
.build()
|
||||
|
||||
val summary = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setColor(0x88FFFF)
|
||||
.setGroup(MessageGroup)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setGroupSummary(true)
|
||||
.build()
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
// using cInfo.id only shows one notification per chat and updates it when the message arrives
|
||||
notify(cInfo.id.hashCode(), notification)
|
||||
notify(0, summary)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideSecrets(cItem: ChatItem): String {
|
||||
val md = cItem.formattedText
|
||||
return if (md == null) {
|
||||
cItem.content.text
|
||||
} else {
|
||||
var res = ""
|
||||
for (ft in md) {
|
||||
res += if (ft.format is Format.Secret) "..." else ft.text
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMsgPendingIntent(cInfo: ChatInfo) : PendingIntent{
|
||||
Log.d(TAG, "getMsgPendingIntent ${cInfo.id}")
|
||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.putExtra("chatId", cInfo.id)
|
||||
.setAction(OpenChatAction)
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent)
|
||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,901 @@
|
||||
package chat.simplex.app.model
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityManager.RunningAppProcessInfo
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
typealias ChatCtrl = Long
|
||||
|
||||
open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: NtfManager, val appContext: Context) {
|
||||
var chatModel = ChatModel(this)
|
||||
private val sharedPreferences: SharedPreferences = appContext.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
chatModel.runServiceInBackground.value = getRunServiceInBackground()
|
||||
}
|
||||
|
||||
suspend fun startChat(user: User) {
|
||||
Log.d(TAG, "user: $user")
|
||||
try {
|
||||
apiStartChat()
|
||||
chatModel.userAddress.value = apiGetUserAddress()
|
||||
chatModel.userSMPServers.value = getUserSMPServers()
|
||||
val chats = apiGetChats()
|
||||
chatModel.chats.clear()
|
||||
chatModel.chats.addAll(chats)
|
||||
chatModel.currentUser = mutableStateOf(user)
|
||||
chatModel.userCreated.value = true
|
||||
Log.d(TAG, "started chat")
|
||||
} catch(e: Error) {
|
||||
Log.e(TAG, "failed starting chat $e")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
fun startReceiver() {
|
||||
Log.d(TAG, "ChatController startReceiver")
|
||||
thread(name="receiver") {
|
||||
withApi { recvMspLoop() }
|
||||
}
|
||||
}
|
||||
|
||||
open fun isAppOnForeground(context: Context): Boolean {
|
||||
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val appProcesses = activityManager.runningAppProcesses ?: return false
|
||||
val packageName = context.packageName
|
||||
for (appProcess in appProcesses) {
|
||||
if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun sendCmd(cmd: CC): CR {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val c = cmd.cmdString
|
||||
chatModel.terminalItems.add(TerminalItem.cmd(cmd))
|
||||
val json = chatSendCmd(ctrl, c)
|
||||
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
|
||||
val r = APIResponse.decodeStr(json)
|
||||
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
|
||||
if (r.resp is CR.Response || r.resp is CR.Invalid) {
|
||||
Log.d(TAG, "sendCmd response json $json")
|
||||
}
|
||||
chatModel.terminalItems.add(TerminalItem.resp(r.resp))
|
||||
r.resp
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun recvMsg(): CR {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val json = chatRecvMsg(ctrl)
|
||||
val r = APIResponse.decodeStr(json).resp
|
||||
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
|
||||
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun recvMspLoop() {
|
||||
processReceivedMsg(recvMsg())
|
||||
recvMspLoop()
|
||||
}
|
||||
|
||||
suspend fun apiGetActiveUser(): User? {
|
||||
val r = sendCmd(CC.ShowActiveUser())
|
||||
if (r is CR.ActiveUser) return r.user
|
||||
Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}")
|
||||
chatModel.userCreated.value = false
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiCreateActiveUser(p: Profile): User {
|
||||
val r = sendCmd(CC.CreateActiveUser(p))
|
||||
if (r is CR.ActiveUser) return r.user
|
||||
Log.d(TAG, "apiCreateActiveUser: ${r.responseType} ${r.details}")
|
||||
throw Error("user not created ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiStartChat() {
|
||||
val r = sendCmd(CC.StartChat())
|
||||
if (r is CR.ChatStarted || r is CR.ChatRunning) return
|
||||
throw Error("failed starting chat: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiGetChats(): List<Chat> {
|
||||
val r = sendCmd(CC.ApiGetChats())
|
||||
if (r is CR.ApiChats ) return r.chats
|
||||
throw Error("failed getting the list of chats: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiGetChat(type: ChatType, id: Long): Chat? {
|
||||
val r = sendCmd(CC.ApiGetChat(type, id))
|
||||
if (r is CR.ApiChat ) return r.chat
|
||||
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSendMessage(type: ChatType, id: Long, quotedItemId: Long? = null, mc: MsgContent): AChatItem? {
|
||||
val cmd = if (quotedItemId == null) CC.ApiSendMessage(type, id, mc)
|
||||
else CC.ApiSendMessageQuote(type, id, quotedItemId, mc)
|
||||
val r = sendCmd(cmd)
|
||||
if (r is CR.NewChatItem ) return r.chatItem
|
||||
Log.e(TAG, "apiSendMessage bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? {
|
||||
val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc))
|
||||
if (r is CR.ChatItemUpdated) return r.chatItem
|
||||
Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): AChatItem? {
|
||||
val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode))
|
||||
if (r is CR.ChatItemDeleted) return r.toChatItem
|
||||
Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun getUserSMPServers(): List<String>? {
|
||||
val r = sendCmd(CC.GetUserSMPServers())
|
||||
if (r is CR.UserSMPServers) return r.smpServers
|
||||
Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun setUserSMPServers(smpServers: List<String>): Boolean {
|
||||
val r = sendCmd(CC.SetUserSMPServers(smpServers))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
else -> {
|
||||
Log.e(TAG, "setUserSMPServers bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
"Error saving SMP servers",
|
||||
"Make sure SMP server addresses are in correct format, line separated and are not duplicated."
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiAddContact(): String? {
|
||||
val r = sendCmd(CC.AddContact())
|
||||
if (r is CR.Invitation) return r.connReqInvitation
|
||||
Log.e(TAG, "apiAddContact bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiConnect(connReq: String): Boolean {
|
||||
val r = sendCmd(CC.Connect(connReq))
|
||||
when {
|
||||
r is CR.SentConfirmation || r is CR.SentInvitation -> return true
|
||||
r is CR.ContactAlreadyExists -> {
|
||||
AlertManager.shared.showAlertMsg("Contact already exists",
|
||||
"You are already connected to ${r.contact.displayName} via this link."
|
||||
)
|
||||
return false
|
||||
}
|
||||
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
|
||||
&& r.chatError.errorType is ChatErrorType.InvalidConnReq -> {
|
||||
AlertManager.shared.showAlertMsg("Invalid connection link",
|
||||
"Please check that you used the correct link or ask your contact to send you another one."
|
||||
)
|
||||
return false
|
||||
}
|
||||
else -> {
|
||||
apiErrorAlert("apiConnect", "Connection error", r)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean {
|
||||
val r = sendCmd(CC.ApiDeleteChat(type, id))
|
||||
when (r) {
|
||||
is CR.ContactDeleted -> return true // TODO groups
|
||||
is CR.ChatCmdError -> {
|
||||
val e = r.chatError
|
||||
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
"Can't delete contact!",
|
||||
"Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}."
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> apiErrorAlert("apiDeleteChat", "Error deleting ${type.chatTypeName}", r)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun apiUpdateProfile(profile: Profile): Profile? {
|
||||
val r = sendCmd(CC.ApiUpdateProfile(profile))
|
||||
if (r is CR.UserProfileNoChange) return profile
|
||||
if (r is CR.UserProfileUpdated) return r.toProfile
|
||||
Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiCreateUserAddress(): String? {
|
||||
val r = sendCmd(CC.CreateMyAddress())
|
||||
if (r is CR.UserContactLinkCreated) return r.connReqContact
|
||||
Log.e(TAG, "apiCreateUserAddress bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiDeleteUserAddress(): Boolean {
|
||||
val r = sendCmd(CC.DeleteMyAddress())
|
||||
if (r is CR.UserContactLinkDeleted) return true
|
||||
Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}")
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun apiGetUserAddress(): String? {
|
||||
val r = sendCmd(CC.ShowMyAddress())
|
||||
if (r is CR.UserContactLink) return r.connReqContact
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
|
||||
&& r.chatError.storeError is StoreError.UserContactLinkNotFound) {
|
||||
return null
|
||||
}
|
||||
Log.e(TAG, "apiGetUserAddress bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiAcceptContactRequest(contactReqId: Long): Contact? {
|
||||
val r = sendCmd(CC.ApiAcceptContact(contactReqId))
|
||||
if (r is CR.AcceptingContactRequest) return r.contact
|
||||
Log.e(TAG, "apiAcceptContactRequest bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiRejectContactRequest(contactReqId: Long): Boolean {
|
||||
val r = sendCmd(CC.ApiRejectContact(contactReqId))
|
||||
if (r is CR.ContactRequestRejected) return true
|
||||
Log.e(TAG, "apiRejectContactRequest bad response: ${r.responseType} ${r.details}")
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
|
||||
val r = sendCmd(CC.ApiChatRead(type, id, range))
|
||||
if (r is CR.CmdOk) return true
|
||||
Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}")
|
||||
return false
|
||||
}
|
||||
|
||||
fun apiErrorAlert(method: String, title: String, r: CR) {
|
||||
val errMsg = "${r.responseType}: ${r.details}"
|
||||
Log.e(TAG, "$method bad response: $errMsg")
|
||||
AlertManager.shared.showAlertMsg(title, errMsg)
|
||||
}
|
||||
|
||||
fun processReceivedMsg(r: CR) {
|
||||
chatModel.terminalItems.add(TerminalItem.resp(r))
|
||||
when (r) {
|
||||
is CR.ContactConnected -> {
|
||||
chatModel.updateContact(r.contact)
|
||||
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected())
|
||||
// NtfManager.shared.notifyContactConnected(contact)
|
||||
}
|
||||
is CR.ReceivedContactRequest -> {
|
||||
val contactRequest = r.contactRequest
|
||||
val cInfo = ChatInfo.ContactRequest(contactRequest)
|
||||
chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
|
||||
// NtfManager.shared.notifyContactRequest(contactRequest)
|
||||
}
|
||||
is CR.ContactUpdated -> {
|
||||
val cInfo = ChatInfo.Direct(r.toContact)
|
||||
if (chatModel.hasChat(r.toContact.id)) {
|
||||
chatModel.updateChatInfo(cInfo)
|
||||
}
|
||||
}
|
||||
is CR.ContactSubscribed -> processContactSubscribed(r.contact)
|
||||
is CR.ContactDisconnected -> {
|
||||
chatModel.updateContact(r.contact)
|
||||
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected())
|
||||
}
|
||||
is CR.ContactSubError -> processContactSubError(r.contact, r.chatError)
|
||||
is CR.ContactSubSummary -> {
|
||||
for (sub in r.contactSubscriptions) {
|
||||
val err = sub.contactError
|
||||
if (err == null) processContactSubscribed(sub.contact)
|
||||
else processContactSubError(sub.contact, sub.contactError)
|
||||
}
|
||||
}
|
||||
is CR.NewChatItem -> {
|
||||
val cInfo = r.chatItem.chatInfo
|
||||
val cItem = r.chatItem.chatItem
|
||||
chatModel.addChatItem(cInfo, cItem)
|
||||
if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) {
|
||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
is CR.ChatItemStatusUpdated -> {
|
||||
val cInfo = r.chatItem.chatInfo
|
||||
val cItem = r.chatItem.chatItem
|
||||
var res = false
|
||||
if (!cItem.isDeletedContent) {
|
||||
res = chatModel.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
if (res) {
|
||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
is CR.ChatItemUpdated -> {
|
||||
val cInfo = r.chatItem.chatInfo
|
||||
val cItem = r.chatItem.chatItem
|
||||
if (chatModel.upsertChatItem(cInfo, cItem)) {
|
||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
is CR.ChatItemDeleted -> {
|
||||
val cInfo = r.toChatItem.chatInfo
|
||||
val cItem = r.toChatItem.chatItem
|
||||
if (cItem.meta.itemDeleted) {
|
||||
chatModel.removeChatItem(cInfo, cItem)
|
||||
} else {
|
||||
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
||||
chatModel.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
else ->
|
||||
Log.d(TAG , "unsupported event: ${r.responseType}")
|
||||
}
|
||||
}
|
||||
|
||||
fun processContactSubscribed(contact: Contact) {
|
||||
chatModel.updateContact(contact)
|
||||
chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Connected())
|
||||
}
|
||||
|
||||
fun processContactSubError(contact: Contact, chatError: ChatError) {
|
||||
chatModel.updateContact(contact)
|
||||
val e = chatError
|
||||
val err: String =
|
||||
if (e is ChatError.ChatErrorAgent) {
|
||||
val a = e.agentError
|
||||
when {
|
||||
a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network"
|
||||
a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted"
|
||||
else -> e.string
|
||||
}
|
||||
}
|
||||
else e.string
|
||||
chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Error(err))
|
||||
}
|
||||
|
||||
fun showBackgroundServiceNotice() {
|
||||
if (!getBackgroundServiceNoticeShown()) {
|
||||
AlertManager.shared.showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = AlertManager.shared::hideAlert,
|
||||
title = {
|
||||
Row {
|
||||
Icon(
|
||||
Icons.Outlined.Bolt,
|
||||
contentDescription = "Instant notifications",
|
||||
)
|
||||
Text("Private instant notifications!", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append("To preserve your privacy, instead of push notifications the app has a ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
|
||||
append("SimpleX background service")
|
||||
}
|
||||
append(" – it uses a few percent of the battery per day.")
|
||||
},
|
||||
Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
|
||||
append("It can be disabled via settings")
|
||||
}
|
||||
append(" – notifications will still be shown while the app is running.")
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = AlertManager.shared::hideAlert) { Text("Ok") }
|
||||
}
|
||||
)
|
||||
}
|
||||
setBackgroundServiceNoticeShown()
|
||||
}
|
||||
}
|
||||
|
||||
fun getAutoRestartWorkerVersion(): Int = sharedPreferences.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
|
||||
|
||||
fun setAutoRestartWorkerVersion(version: Int) =
|
||||
sharedPreferences.edit()
|
||||
.putInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, version)
|
||||
.apply()
|
||||
|
||||
fun getRunServiceInBackground(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
|
||||
|
||||
fun setRunServiceInBackground(runService: Boolean) =
|
||||
sharedPreferences.edit()
|
||||
.putBoolean(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, runService)
|
||||
.apply()
|
||||
|
||||
fun getBackgroundServiceNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
|
||||
|
||||
fun setBackgroundServiceNoticeShown() =
|
||||
sharedPreferences.edit()
|
||||
.putBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, true)
|
||||
.apply()
|
||||
|
||||
companion object {
|
||||
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
|
||||
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
|
||||
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
|
||||
private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown"
|
||||
}
|
||||
}
|
||||
|
||||
// ChatCommand
|
||||
sealed class CC {
|
||||
class Console(val cmd: String): CC()
|
||||
class ShowActiveUser: CC()
|
||||
class CreateActiveUser(val profile: Profile): CC()
|
||||
class StartChat: CC()
|
||||
class ApiGetChats: CC()
|
||||
class ApiGetChat(val type: ChatType, val id: Long): CC()
|
||||
class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC()
|
||||
class ApiSendMessageQuote(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
|
||||
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
|
||||
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
|
||||
class GetUserSMPServers(): CC()
|
||||
class SetUserSMPServers(val smpServers: List<String>): CC()
|
||||
class AddContact: CC()
|
||||
class Connect(val connReq: String): CC()
|
||||
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
|
||||
class ApiUpdateProfile(val profile: Profile): CC()
|
||||
class CreateMyAddress: CC()
|
||||
class DeleteMyAddress: CC()
|
||||
class ShowMyAddress: CC()
|
||||
class ApiAcceptContact(val contactReqId: Long): CC()
|
||||
class ApiRejectContact(val contactReqId: Long): CC()
|
||||
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
is Console -> cmd
|
||||
is ShowActiveUser -> "/u"
|
||||
is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}"
|
||||
is StartChat -> "/_start"
|
||||
is ApiGetChats -> "/_get chats"
|
||||
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
|
||||
is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
|
||||
is ApiSendMessageQuote -> "/_send_quote ${chatRef(type, id)} $itemId ${mc.cmdString}"
|
||||
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
|
||||
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
|
||||
is GetUserSMPServers -> "/smp_servers"
|
||||
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
|
||||
is AddContact -> "/connect"
|
||||
is Connect -> "/connect $connReq"
|
||||
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
|
||||
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
|
||||
is CreateMyAddress -> "/address"
|
||||
is DeleteMyAddress -> "/delete_address"
|
||||
is ShowMyAddress -> "/show_address"
|
||||
is ApiAcceptContact -> "/_accept $contactReqId"
|
||||
is ApiRejectContact -> "/_reject $contactReqId"
|
||||
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
|
||||
}
|
||||
|
||||
val cmdType: String get() = when (this) {
|
||||
is Console -> "console command"
|
||||
is ShowActiveUser -> "showActiveUser"
|
||||
is CreateActiveUser -> "createActiveUser"
|
||||
is StartChat -> "startChat"
|
||||
is ApiGetChats -> "apiGetChats"
|
||||
is ApiGetChat -> "apiGetChat"
|
||||
is ApiSendMessage -> "apiSendMessage"
|
||||
is ApiSendMessageQuote -> "apiSendMessageQuote"
|
||||
is ApiUpdateChatItem -> "apiUpdateChatItem"
|
||||
is ApiDeleteChatItem -> "apiDeleteChatItem"
|
||||
is GetUserSMPServers -> "getUserSMPServers"
|
||||
is SetUserSMPServers -> "setUserSMPServers"
|
||||
is AddContact -> "addContact"
|
||||
is Connect -> "connect"
|
||||
is ApiDeleteChat -> "apiDeleteChat"
|
||||
is ApiUpdateProfile -> "updateProfile"
|
||||
is CreateMyAddress -> "createMyAddress"
|
||||
is DeleteMyAddress -> "deleteMyAddress"
|
||||
is ShowMyAddress -> "showMyAddress"
|
||||
is ApiAcceptContact -> "apiAcceptContact"
|
||||
is ApiRejectContact -> "apiRejectContact"
|
||||
is ApiChatRead -> "apiChatRead"
|
||||
}
|
||||
|
||||
class ItemRange(val from: Long, val to: Long)
|
||||
|
||||
companion object {
|
||||
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
|
||||
|
||||
fun smpServersStr(smpServers: List<String>) = if (smpServers.isEmpty()) "default" else smpServers.joinToString(separator = ",")
|
||||
}
|
||||
}
|
||||
|
||||
val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class APIResponse(val resp: CR, val corr: String? = null) {
|
||||
companion object {
|
||||
fun decodeStr(str: String): APIResponse {
|
||||
return try {
|
||||
json.decodeFromString(str)
|
||||
} catch(e: Exception) {
|
||||
try {
|
||||
Log.d(TAG, e.localizedMessage)
|
||||
val data = json.parseToJsonElement(str).jsonObject
|
||||
APIResponse(
|
||||
resp = CR.Response(data["resp"]!!.jsonObject["type"]?.toString() ?: "invalid", json.encodeToString(data)),
|
||||
corr = data["corr"]?.toString()
|
||||
)
|
||||
} catch(e: Exception) {
|
||||
APIResponse(CR.Invalid(str))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ChatResponse
|
||||
@Serializable
|
||||
sealed class CR {
|
||||
@Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR()
|
||||
@Serializable @SerialName("chatStarted") class ChatStarted: CR()
|
||||
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
|
||||
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
|
||||
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): CR()
|
||||
@Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR()
|
||||
@Serializable @SerialName("sentConfirmation") class SentConfirmation: CR()
|
||||
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
|
||||
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR()
|
||||
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR()
|
||||
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
|
||||
@Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR()
|
||||
@Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR()
|
||||
@Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR()
|
||||
@Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact): CR()
|
||||
@Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val contactRequest: UserContactRequest): CR()
|
||||
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected: CR()
|
||||
@Serializable @SerialName("contactUpdated") class ContactUpdated(val toContact: Contact): CR()
|
||||
@Serializable @SerialName("contactSubscribed") class ContactSubscribed(val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactDisconnected") class ContactDisconnected(val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactSubError") class ContactSubError(val contact: Contact, val chatError: ChatError): CR()
|
||||
@Serializable @SerialName("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List<ContactSubStatus>): CR()
|
||||
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR()
|
||||
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val memberSubErrors: List<MemberSubError>): CR()
|
||||
@Serializable @SerialName("groupEmpty") class GroupEmpty(val group: GroupInfo): CR()
|
||||
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
|
||||
@Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("cmdOk") class CmdOk: CR()
|
||||
@Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR()
|
||||
@Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR()
|
||||
@Serializable class Response(val type: String, val json: String): CR()
|
||||
@Serializable class Invalid(val str: String): CR()
|
||||
|
||||
val responseType: String get() = when(this) {
|
||||
is ActiveUser -> "activeUser"
|
||||
is ChatStarted -> "chatStarted"
|
||||
is ChatRunning -> "chatRunning"
|
||||
is ApiChats -> "apiChats"
|
||||
is ApiChat -> "apiChat"
|
||||
is UserSMPServers -> "userSMPServers"
|
||||
is Invitation -> "invitation"
|
||||
is SentConfirmation -> "sentConfirmation"
|
||||
is SentInvitation -> "sentInvitation"
|
||||
is ContactAlreadyExists -> "contactAlreadyExists"
|
||||
is ContactDeleted -> "contactDeleted"
|
||||
is UserProfileNoChange -> "userProfileNoChange"
|
||||
is UserProfileUpdated -> "userProfileUpdated"
|
||||
is UserContactLink -> "userContactLink"
|
||||
is UserContactLinkCreated -> "userContactLinkCreated"
|
||||
is UserContactLinkDeleted -> "userContactLinkDeleted"
|
||||
is ContactConnected -> "contactConnected"
|
||||
is ReceivedContactRequest -> "receivedContactRequest"
|
||||
is AcceptingContactRequest -> "acceptingContactRequest"
|
||||
is ContactRequestRejected -> "contactRequestRejected"
|
||||
is ContactUpdated -> "contactUpdated"
|
||||
is ContactSubscribed -> "contactSubscribed"
|
||||
is ContactDisconnected -> "contactDisconnected"
|
||||
is ContactSubError -> "contactSubError"
|
||||
is ContactSubSummary -> "contactSubSummary"
|
||||
is GroupSubscribed -> "groupSubscribed"
|
||||
is MemberSubErrors -> "memberSubErrors"
|
||||
is GroupEmpty -> "groupEmpty"
|
||||
is UserContactLinkSubscribed -> "userContactLinkSubscribed"
|
||||
is NewChatItem -> "newChatItem"
|
||||
is ChatItemStatusUpdated -> "chatItemStatusUpdated"
|
||||
is ChatItemUpdated -> "chatItemUpdated"
|
||||
is ChatItemDeleted -> "chatItemDeleted"
|
||||
is CmdOk -> "cmdOk"
|
||||
is ChatCmdError -> "chatCmdError"
|
||||
is ChatRespError -> "chatError"
|
||||
is Response -> "* $type"
|
||||
is Invalid -> "* invalid json"
|
||||
}
|
||||
|
||||
val details: String get() = when(this) {
|
||||
is ActiveUser -> json.encodeToString(user)
|
||||
is ChatStarted -> noDetails()
|
||||
is ChatRunning -> noDetails()
|
||||
is ApiChats -> json.encodeToString(chats)
|
||||
is ApiChat -> json.encodeToString(chat)
|
||||
is UserSMPServers -> json.encodeToString(smpServers)
|
||||
is Invitation -> connReqInvitation
|
||||
is SentConfirmation -> noDetails()
|
||||
is SentInvitation -> noDetails()
|
||||
is ContactAlreadyExists -> json.encodeToString(contact)
|
||||
is ContactDeleted -> json.encodeToString(contact)
|
||||
is UserProfileNoChange -> noDetails()
|
||||
is UserProfileUpdated -> json.encodeToString(toProfile)
|
||||
is UserContactLink -> connReqContact
|
||||
is UserContactLinkCreated -> connReqContact
|
||||
is UserContactLinkDeleted -> noDetails()
|
||||
is ContactConnected -> json.encodeToString(contact)
|
||||
is ReceivedContactRequest -> json.encodeToString(contactRequest)
|
||||
is AcceptingContactRequest -> json.encodeToString(contact)
|
||||
is ContactRequestRejected -> noDetails()
|
||||
is ContactUpdated -> json.encodeToString(toContact)
|
||||
is ContactSubscribed -> json.encodeToString(contact)
|
||||
is ContactDisconnected -> json.encodeToString(contact)
|
||||
is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}"
|
||||
is ContactSubSummary -> json.encodeToString(contactSubscriptions)
|
||||
is GroupSubscribed -> json.encodeToString(group)
|
||||
is MemberSubErrors -> json.encodeToString(memberSubErrors)
|
||||
is GroupEmpty -> json.encodeToString(group)
|
||||
is UserContactLinkSubscribed -> noDetails()
|
||||
is NewChatItem -> json.encodeToString(chatItem)
|
||||
is ChatItemStatusUpdated -> json.encodeToString(chatItem)
|
||||
is ChatItemUpdated -> json.encodeToString(chatItem)
|
||||
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
|
||||
is CmdOk -> noDetails()
|
||||
is ChatCmdError -> chatError.string
|
||||
is ChatRespError -> chatError.string
|
||||
is Response -> json
|
||||
is Invalid -> str
|
||||
}
|
||||
|
||||
fun noDetails(): String ="${responseType}: no details"
|
||||
}
|
||||
|
||||
abstract class TerminalItem {
|
||||
abstract val id: Long
|
||||
val date: Instant = Clock.System.now()
|
||||
abstract val label: String
|
||||
abstract val details: String
|
||||
|
||||
class Cmd(override val id: Long, val cmd: CC): TerminalItem() {
|
||||
override val label get() = "> ${cmd.cmdString}"
|
||||
override val details get() = cmd.cmdString
|
||||
}
|
||||
|
||||
class Resp(override val id: Long, val resp: CR): TerminalItem() {
|
||||
override val label get() = "< ${resp.responseType}"
|
||||
override val details get() = resp.details
|
||||
}
|
||||
|
||||
companion object {
|
||||
val sampleData = listOf(
|
||||
Cmd(0, CC.ShowActiveUser()),
|
||||
Resp(1, CR.ActiveUser(User.sampleData))
|
||||
)
|
||||
|
||||
fun cmd(c: CC) = Cmd(System.currentTimeMillis(), c)
|
||||
fun resp(r: CR) = Resp(System.currentTimeMillis(), r)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ChatError {
|
||||
val string: String get() = when (this) {
|
||||
is ChatErrorChat -> "chat ${errorType.string}"
|
||||
is ChatErrorAgent -> "agent ${agentError.string}"
|
||||
is ChatErrorStore -> "store ${storeError.string}"
|
||||
}
|
||||
@Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError()
|
||||
@Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError()
|
||||
@Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ChatErrorType {
|
||||
val string: String get() = when (this) {
|
||||
is InvalidConnReq -> "invalidConnReq"
|
||||
is ContactGroups -> "groupNames $groupNames"
|
||||
}
|
||||
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
|
||||
@Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List<String>): ChatErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class StoreError {
|
||||
val string: String get() = when (this) {
|
||||
is UserContactLinkNotFound -> "userContactLinkNotFound"
|
||||
}
|
||||
@Serializable @SerialName("userContactLinkNotFound") class UserContactLinkNotFound: StoreError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class AgentErrorType {
|
||||
val string: String get() = when (this) {
|
||||
is CMD -> "CMD ${cmdErr.string}"
|
||||
is CONN -> "CONN ${connErr.string}"
|
||||
is SMP -> "SMP ${smpErr.string}"
|
||||
is BROKER -> "BROKER ${brokerErr.string}"
|
||||
is AGENT -> "AGENT ${agentErr.string}"
|
||||
is INTERNAL -> "INTERNAL $internalErr"
|
||||
}
|
||||
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType()
|
||||
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType()
|
||||
@Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType()
|
||||
@Serializable @SerialName("BROKER") class BROKER(val brokerErr: BrokerErrorType): AgentErrorType()
|
||||
@Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType()
|
||||
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CommandErrorType {
|
||||
val string: String get() = when (this) {
|
||||
is PROHIBITED -> "PROHIBITED"
|
||||
is SYNTAX -> "SYNTAX"
|
||||
is NO_CONN -> "NO_CONN"
|
||||
is SIZE -> "SIZE"
|
||||
is LARGE -> "LARGE"
|
||||
}
|
||||
@Serializable @SerialName("PROHIBITED") class PROHIBITED: CommandErrorType()
|
||||
@Serializable @SerialName("SYNTAX") class SYNTAX: CommandErrorType()
|
||||
@Serializable @SerialName("NO_CONN") class NO_CONN: CommandErrorType()
|
||||
@Serializable @SerialName("SIZE") class SIZE: CommandErrorType()
|
||||
@Serializable @SerialName("LARGE") class LARGE: CommandErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ConnectionErrorType {
|
||||
val string: String get() = when (this) {
|
||||
is NOT_FOUND -> "NOT_FOUND"
|
||||
is DUPLICATE -> "DUPLICATE"
|
||||
is SIMPLEX -> "SIMPLEX"
|
||||
is NOT_ACCEPTED -> "NOT_ACCEPTED"
|
||||
is NOT_AVAILABLE -> "NOT_AVAILABLE"
|
||||
}
|
||||
@Serializable @SerialName("NOT_FOUND") class NOT_FOUND: ConnectionErrorType()
|
||||
@Serializable @SerialName("DUPLICATE") class DUPLICATE: ConnectionErrorType()
|
||||
@Serializable @SerialName("SIMPLEX") class SIMPLEX: ConnectionErrorType()
|
||||
@Serializable @SerialName("NOT_ACCEPTED") class NOT_ACCEPTED: ConnectionErrorType()
|
||||
@Serializable @SerialName("NOT_AVAILABLE") class NOT_AVAILABLE: ConnectionErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class BrokerErrorType {
|
||||
val string: String get() = when (this) {
|
||||
is RESPONSE -> "RESPONSE ${smpErr.string}"
|
||||
is UNEXPECTED -> "UNEXPECTED"
|
||||
is NETWORK -> "NETWORK"
|
||||
is TRANSPORT -> "TRANSPORT ${transportErr.string}"
|
||||
is TIMEOUT -> "TIMEOUT"
|
||||
}
|
||||
@Serializable @SerialName("RESPONSE") class RESPONSE(val smpErr: SMPErrorType): BrokerErrorType()
|
||||
@Serializable @SerialName("UNEXPECTED") class UNEXPECTED: BrokerErrorType()
|
||||
@Serializable @SerialName("NETWORK") class NETWORK: BrokerErrorType()
|
||||
@Serializable @SerialName("TRANSPORT") class TRANSPORT(val transportErr: SMPTransportError): BrokerErrorType()
|
||||
@Serializable @SerialName("TIMEOUT") class TIMEOUT: BrokerErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class SMPErrorType {
|
||||
val string: String get() = when (this) {
|
||||
is BLOCK -> "BLOCK"
|
||||
is SESSION -> "SESSION"
|
||||
is CMD -> "CMD ${cmdErr.string}"
|
||||
is AUTH -> "AUTH"
|
||||
is QUOTA -> "QUOTA"
|
||||
is NO_MSG -> "NO_MSG"
|
||||
is LARGE_MSG -> "LARGE_MSG"
|
||||
is INTERNAL -> "INTERNAL"
|
||||
}
|
||||
@Serializable @SerialName("BLOCK") class BLOCK: SMPErrorType()
|
||||
@Serializable @SerialName("SESSION") class SESSION: SMPErrorType()
|
||||
@Serializable @SerialName("CMD") class CMD(val cmdErr: SMPCommandError): SMPErrorType()
|
||||
@Serializable @SerialName("AUTH") class AUTH: SMPErrorType()
|
||||
@Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType()
|
||||
@Serializable @SerialName("NO_MSG") class NO_MSG: SMPErrorType()
|
||||
@Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType()
|
||||
@Serializable @SerialName("INTERNAL") class INTERNAL: SMPErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class SMPCommandError {
|
||||
val string: String get() = when (this) {
|
||||
is UNKNOWN -> "UNKNOWN"
|
||||
is SYNTAX -> "SYNTAX"
|
||||
is NO_AUTH -> "NO_AUTH"
|
||||
is HAS_AUTH -> "HAS_AUTH"
|
||||
is NO_QUEUE -> "NO_QUEUE"
|
||||
}
|
||||
@Serializable @SerialName("UNKNOWN") class UNKNOWN: SMPCommandError()
|
||||
@Serializable @SerialName("SYNTAX") class SYNTAX: SMPCommandError()
|
||||
@Serializable @SerialName("NO_AUTH") class NO_AUTH: SMPCommandError()
|
||||
@Serializable @SerialName("HAS_AUTH") class HAS_AUTH: SMPCommandError()
|
||||
@Serializable @SerialName("NO_QUEUE") class NO_QUEUE: SMPCommandError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class SMPTransportError {
|
||||
val string: String get() = when (this) {
|
||||
is BadBlock -> "badBlock"
|
||||
is LargeMsg -> "largeMsg"
|
||||
is BadSession -> "badSession"
|
||||
is Handshake -> "handshake ${handshakeErr.string}"
|
||||
}
|
||||
@Serializable @SerialName("badBlock") class BadBlock: SMPTransportError()
|
||||
@Serializable @SerialName("largeMsg") class LargeMsg: SMPTransportError()
|
||||
@Serializable @SerialName("badSession") class BadSession: SMPTransportError()
|
||||
@Serializable @SerialName("handshake") class Handshake(val handshakeErr: SMPHandshakeError): SMPTransportError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class SMPHandshakeError {
|
||||
val string: String get() = when (this) {
|
||||
is PARSE -> "PARSE"
|
||||
is VERSION -> "VERSION"
|
||||
is IDENTITY -> "IDENTITY"
|
||||
}
|
||||
@Serializable @SerialName("PARSE") class PARSE: SMPHandshakeError()
|
||||
@Serializable @SerialName("VERSION") class VERSION: SMPHandshakeError()
|
||||
@Serializable @SerialName("IDENTITY") class IDENTITY: SMPHandshakeError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class SMPAgentError {
|
||||
val string: String get() = when (this) {
|
||||
is A_MESSAGE -> "A_MESSAGE"
|
||||
is A_PROHIBITED -> "A_PROHIBITED"
|
||||
is A_VERSION -> "A_VERSION"
|
||||
is A_ENCRYPTION -> "A_ENCRYPTION"
|
||||
}
|
||||
@Serializable @SerialName("A_MESSAGE") class A_MESSAGE: SMPAgentError()
|
||||
@Serializable @SerialName("A_PROHIBITED") class A_PROHIBITED: SMPAgentError()
|
||||
@Serializable @SerialName("A_VERSION") class A_VERSION: SMPAgentError()
|
||||
@Serializable @SerialName("A_ENCRYPTION") class A_ENCRYPTION: SMPAgentError()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple200 = Color(0xFFBB86FC)
|
||||
val Purple500 = Color(0xFF6200EE)
|
||||
val Purple700 = Color(0xFF3700B3)
|
||||
val Teal200 = Color(0xFF03DAC5)
|
||||
val Gray = Color(0x22222222)
|
||||
val SimplexBlue = Color(0, 136, 255, 255)
|
||||
val SimplexGreen = Color(98, 196, 103, 255)
|
||||
val SecretColor = Color(0x40808080)
|
||||
val LightGray = Color(241, 242, 246, 255)
|
||||
val DarkGray = Color(43, 44, 46, 255)
|
||||
val HighOrLowlight = Color(134, 135, 139, 255)
|
||||
val ToolbarLight = Color(220, 220, 220, 20)
|
||||
val ToolbarDark = Color(80, 80, 80, 20)
|
||||
@@ -0,0 +1,11 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Shapes = Shapes(
|
||||
small = RoundedCornerShape(4.dp),
|
||||
medium = RoundedCornerShape(4.dp),
|
||||
large = RoundedCornerShape(0.dp)
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
primary = SimplexBlue,
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = DarkGray,
|
||||
// background = Color.Black,
|
||||
// surface = Color.Black,
|
||||
// background = Color(0xFF121212),
|
||||
// surface = Color(0xFF121212),
|
||||
// error = Color(0xFFCF6679),
|
||||
// onPrimary = Color.Black,
|
||||
// onSecondary = Color.Black,
|
||||
// onBackground = Color.White,
|
||||
// onSurface = Color.White,
|
||||
// onError: Color = Color.Black,
|
||||
)
|
||||
private val LightColorPalette = lightColors(
|
||||
primary = SimplexBlue,
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = LightGray,
|
||||
// background = Color.White,
|
||||
// surface = Color.White
|
||||
// onPrimary = Color.White,
|
||||
// onSecondary = Color.Black,
|
||||
// onBackground = Color.Black,
|
||||
// onSurface = Color.Black,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SimpleXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
||||
val colors = if (darkTheme) {
|
||||
DarkColorPalette
|
||||
} else {
|
||||
LightColorPalette
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colors = colors,
|
||||
typography = Typography,
|
||||
shapes = Shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.material.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
|
||||
// https://github.com/rsms/inter
|
||||
val Inter = FontFamily(
|
||||
Font(R.font.inter_regular),
|
||||
Font(R.font.inter_italic, style = FontStyle.Italic),
|
||||
Font(R.font.inter_bold, weight = FontWeight.Bold),
|
||||
Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold),
|
||||
Font(R.font.inter_medium, weight = FontWeight.Medium),
|
||||
)
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
h1 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp,
|
||||
),
|
||||
h2 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp
|
||||
),
|
||||
h3 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 19.sp
|
||||
),
|
||||
body1 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
body2 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
button = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
),
|
||||
caption = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun SplashView() {
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
// Image(
|
||||
// painter = painterResource(R.drawable.logo),
|
||||
// contentDescription = "Simplex Icon",
|
||||
// modifier = Modifier
|
||||
// .height(230.dp)
|
||||
// .align(Alignment.Center)
|
||||
// )
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.SendMsgView
|
||||
import chat.simplex.app.views.helpers.CloseSheetBar
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.newchat.ModalManager
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
TerminalLayout(chatModel.terminalItems, close) { cmd ->
|
||||
withApi {
|
||||
// show "in progress"
|
||||
chatModel.controller.sendCmd(CC.Console(cmd))
|
||||
// hide "in progress"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TerminalLayout(terminalItems: List<TerminalItem>, close: () -> Unit, sendCommand: (String) -> Unit) {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Scaffold(
|
||||
topBar = { CloseSheetBar(close) },
|
||||
bottomBar = { SendMsgView(msg = remember { mutableStateOf("") }, sendCommand) },
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
TerminalLog(terminalItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
LazyColumn(state = listState) {
|
||||
items(terminalItems) { 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)
|
||||
.clickable {
|
||||
ModalManager.shared.showModal {
|
||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(item.details)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val len = terminalItems.count()
|
||||
if (len > 1) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(len - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewTerminalLayout() {
|
||||
SimpleXTheme {
|
||||
TerminalLayout(
|
||||
terminalItems = TerminalItem.sampleData,
|
||||
close = {},
|
||||
sendCommand = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexService
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Profile
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
|
||||
@Composable
|
||||
fun WelcomeView(chatModel: ChatModel) {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = MaterialTheme.colors.background)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxSize()
|
||||
.background(color = MaterialTheme.colors.background)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo),
|
||||
contentDescription = "Simplex Logo",
|
||||
modifier = Modifier.padding(vertical = 15.dp)
|
||||
)
|
||||
Text(
|
||||
"You control your chat!",
|
||||
style = MaterialTheme.typography.h4,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Text(
|
||||
"The messaging and application platform protecting your privacy and security.",
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"We don't store any of your contacts or messages (once delivered) on the servers.",
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
CreateProfilePanel(chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun isValidDisplayName(name: String) : Boolean {
|
||||
return (name.firstOrNull { it.isWhitespace() }) == null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateProfilePanel(chatModel: ChatModel) {
|
||||
var displayName by remember { mutableStateOf("") }
|
||||
var fullName by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier=Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
"Create profile",
|
||||
style = MaterialTheme.typography.h4,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
)
|
||||
Text(
|
||||
"Your profile is stored on your device and shared only with your contacts.",
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"Display Name",
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
BasicTextField(
|
||||
value = displayName,
|
||||
onValueChange = { displayName = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.secondary)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.padding(8.dp)
|
||||
.navigationBarsWithImePadding(),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
val errorText = if(!isValidDisplayName(displayName)) "Display name cannot contain whitespace." else ""
|
||||
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
"Full Name (Optional)",
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
BasicTextField(
|
||||
value = fullName,
|
||||
onValueChange = { fullName = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.secondary)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.padding(8.dp)
|
||||
.navigationBarsWithImePadding(),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(onClick = {
|
||||
withApi {
|
||||
val user = chatModel.controller.apiCreateActiveUser(
|
||||
Profile(displayName, fullName, null)
|
||||
)
|
||||
chatModel.controller.startChat(user)
|
||||
SimplexService.start(chatModel.controller.appContext)
|
||||
chatModel.controller.showBackgroundServiceNotice()
|
||||
}
|
||||
},
|
||||
enabled = (displayName.isNotEmpty() && isValidDisplayName(displayName))
|
||||
) { Text("Create") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Circle
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
if (chat != null) {
|
||||
ChatInfoLayout(chat,
|
||||
close = close,
|
||||
deleteContact = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = "Delete contact?",
|
||||
text = "Contact and all messages will be deleted - this cannot be undone!",
|
||||
confirmText = "Delete",
|
||||
onConfirm = {
|
||||
val cInfo = chat.chatInfo
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
|
||||
if (r) {
|
||||
chatModel.removeChat(cInfo.id)
|
||||
chatModel.chatId.value = null
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
|
||||
Column(Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CloseSheetBar(close)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
ChatInfoImage(chat, size = 192.dp)
|
||||
val cInfo = chat.chatInfo
|
||||
Text(
|
||||
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(top = 32.dp).padding(bottom = 8.dp)
|
||||
)
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(Modifier.padding(horizontal = 32.dp)) {
|
||||
ServerImage(chat)
|
||||
Text(
|
||||
chat.serverInfo.networkStatus.statusString,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
chat.serverInfo.networkStatus.statusExplanation,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1F))
|
||||
|
||||
Box(Modifier.padding(48.dp)) {
|
||||
SimpleButton(
|
||||
"Delete contact", icon = Icons.Outlined.Delete,
|
||||
color = Color.Red,
|
||||
click = deleteContact
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerImage(chat: Chat) {
|
||||
val status = chat.serverInfo.networkStatus
|
||||
when {
|
||||
status is Chat.NetworkStatus.Connected ->
|
||||
Icon(Icons.Filled.Circle, "Connected", tint = MaterialTheme.colors.primaryVariant)
|
||||
status is Chat.NetworkStatus.Disconnected ->
|
||||
Icon(Icons.Filled.Pending, "Disconnected", tint = HighOrLowlight)
|
||||
status is Chat.NetworkStatus.Error ->
|
||||
Icon(Icons.Filled.Error, "Error", tint = HighOrLowlight)
|
||||
else ->
|
||||
Icon(Icons.Outlined.Circle, "Pending", tint = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatInfoLayout() {
|
||||
SimpleXTheme {
|
||||
ChatInfoLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = arrayListOf(),
|
||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
||||
),
|
||||
close = {}, deleteContact = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBackIos
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.mapSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.ChatItemView
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.ModalManager
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatView(chatModel: ChatModel) {
|
||||
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
|
||||
val user = chatModel.currentUser.value
|
||||
if (chat == null || user == null) {
|
||||
chatModel.chatId.value = null
|
||||
} else {
|
||||
val quotedItem = remember { mutableStateOf<ChatItem?>(null) }
|
||||
val editingItem = remember { mutableStateOf<ChatItem?>(null) }
|
||||
var msg = remember { mutableStateOf("") }
|
||||
BackHandler { chatModel.chatId.value = null }
|
||||
// TODO a more advanced version would mark as read only if in view
|
||||
LaunchedEffect(chat.chatItems) {
|
||||
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
|
||||
delay(1000L)
|
||||
if (chat.chatItems.count() > 0) {
|
||||
chatModel.markChatItemsRead(chat.chatInfo)
|
||||
withApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
chat.chatInfo.apiId,
|
||||
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem,
|
||||
back = { chatModel.chatId.value = null },
|
||||
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
|
||||
openDirectChat = { contactId ->
|
||||
val c = chatModel.chats.firstOrNull {
|
||||
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
|
||||
}
|
||||
if (c != null) withApi { openChat(chatModel, c.chatInfo) }
|
||||
},
|
||||
sendMessage = { msg ->
|
||||
withApi {
|
||||
// show "in progress"
|
||||
val cInfo = chat.chatInfo
|
||||
val ei = editingItem.value
|
||||
if (ei != null) {
|
||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = ei.meta.itemId,
|
||||
mc = MsgContent.MCText(msg)
|
||||
)
|
||||
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||
} else {
|
||||
val newItem = chatModel.controller.apiSendMessage(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
quotedItemId = quotedItem.value?.meta?.itemId,
|
||||
mc = MsgContent.MCText(msg)
|
||||
)
|
||||
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
|
||||
}
|
||||
// hide "in progress"
|
||||
editingItem.value = null
|
||||
quotedItem.value = null
|
||||
}
|
||||
},
|
||||
resetMessage = { msg.value = "" },
|
||||
deleteMessage = { itemId, mode ->
|
||||
withApi {
|
||||
val cInfo = chat.chatInfo
|
||||
val toItem = chatModel.controller.apiDeleteChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = itemId,
|
||||
mode = mode
|
||||
)
|
||||
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatLayout(
|
||||
user: User,
|
||||
chat: Chat,
|
||||
chatItems: List<ChatItem>,
|
||||
msg: MutableState<String>,
|
||||
quotedItem: MutableState<ChatItem?>,
|
||||
editingItem: MutableState<ChatItem?>,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
sendMessage: (String) -> Unit,
|
||||
resetMessage: () -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Scaffold(
|
||||
topBar = { ChatInfoToolbar(chat, back, info) },
|
||||
bottomBar = { ComposeView(msg, quotedItem, editingItem, sendMessage, resetMessage) },
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
||||
.padding(horizontal = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
IconButton(onClick = back) {
|
||||
Icon(
|
||||
Icons.Outlined.ArrowBackIos,
|
||||
"Back",
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.padding(horizontal = 68.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = info),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val cInfo = chat.chatInfo
|
||||
ChatInfoImage(chat, size = 40.dp)
|
||||
Column(
|
||||
Modifier.padding(start = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
|
||||
Text(
|
||||
cInfo.fullName,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
|
||||
|
||||
val CIListStateSaver = run {
|
||||
val scrolledKey = "scrolled"
|
||||
val countKey = "itemCount"
|
||||
val keyboardKey = "keyboardState"
|
||||
mapSaver(
|
||||
save = { mapOf(scrolledKey to it.scrolled, countKey to it.itemCount, keyboardKey to it.keyboardState) },
|
||||
restore = { CIListState(it[scrolledKey] as Boolean, it[countKey] as Int, it[keyboardKey] as KeyboardState) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemsList(
|
||||
user: User,
|
||||
chat: Chat,
|
||||
chatItems: List<ChatItem>,
|
||||
msg: MutableState<String>,
|
||||
quotedItem: MutableState<ChatItem?>,
|
||||
editingItem: MutableState<ChatItem?>,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val keyboardState by getKeyboardState()
|
||||
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
|
||||
mutableStateOf(CIListState(false, chatItems.count(), keyboardState))
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val cxt = LocalContext.current
|
||||
LazyColumn(state = listState) {
|
||||
itemsIndexed(chatItems) { i, cItem ->
|
||||
if (i == 0) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
if (chat.chatInfo is ChatInfo.Group) {
|
||||
if (cItem.chatDir is CIDirection.GroupRcv) {
|
||||
val prevItem = if (i > 0) chatItems[i - 1] else null
|
||||
val member = cItem.chatDir.groupMember
|
||||
val showMember = showMemberImage(member, prevItem)
|
||||
Row(Modifier.padding(start = 8.dp, end = 66.dp)) {
|
||||
if (showMember) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId == null) {
|
||||
MemberImage(member)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable { openDirectChat(contactId) }
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
|
||||
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage)
|
||||
}
|
||||
}
|
||||
} 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,
|
||||
)
|
||||
) {
|
||||
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
val len = chatItems.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
|
||||
return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd ||
|
||||
(prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberImage(member: GroupMember) {
|
||||
ProfileImage(38.dp, member.memberProfile.image)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatLayout() {
|
||||
SimpleXTheme {
|
||||
val chatItems = listOf(
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
2, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getDeletedContentSampleData(3),
|
||||
ChatItem.getSampleData(
|
||||
4, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
5, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
ChatLayout(
|
||||
user = User.sampleData,
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = chatItems,
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
chatItems = chatItems,
|
||||
msg = remember { mutableStateOf("") },
|
||||
quotedItem = remember { mutableStateOf(null) },
|
||||
editingItem = remember { mutableStateOf(null) },
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
sendMessage = {},
|
||||
resetMessage = {},
|
||||
deleteMessage = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewGroupChatLayout() {
|
||||
SimpleXTheme {
|
||||
val chatItems = listOf(
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.GroupSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
2, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getDeletedContentSampleData(3),
|
||||
ChatItem.getSampleData(
|
||||
4, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
5, CIDirection.GroupSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
ChatLayout(
|
||||
user = User.sampleData,
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Group.sampleData,
|
||||
chatItems = chatItems,
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
chatItems = chatItems,
|
||||
msg = remember { mutableStateOf("") },
|
||||
quotedItem = remember { mutableStateOf(null) },
|
||||
editingItem = remember { mutableStateOf(null) },
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
sendMessage = {},
|
||||
resetMessage = {},
|
||||
deleteMessage = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import chat.simplex.app.model.ChatItem
|
||||
|
||||
// TODO ComposeState
|
||||
|
||||
@Composable
|
||||
fun ComposeView(
|
||||
msg: MutableState<String>,
|
||||
quotedItem: MutableState<ChatItem?>,
|
||||
editingItem: MutableState<ChatItem?>,
|
||||
sendMessage: (String) -> Unit,
|
||||
resetMessage: () -> Unit
|
||||
) {
|
||||
Column {
|
||||
when {
|
||||
quotedItem.value != null -> {
|
||||
ContextItemView(quotedItem)
|
||||
}
|
||||
editingItem.value != null -> {
|
||||
ContextItemView(editingItem, editing = editingItem.value != null, resetMessage)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
SendMsgView(msg, sendMessage, editing = editingItem.value != null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.CIDirection
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ContextItemView(
|
||||
contextItem: MutableState<ChatItem?>,
|
||||
editing: Boolean = false,
|
||||
resetMessage: () -> Unit = {}
|
||||
) {
|
||||
val cxtItem = contextItem.value
|
||||
if (cxtItem != null) {
|
||||
val sent = cxtItem.chatDir.sent
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.background(if (sent) SentColorLight else ReceivedColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.padding(vertical = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
) {
|
||||
ContextItemText(cxtItem)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
contextItem.value = null
|
||||
if (editing) {
|
||||
resetMessage()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = "Cancel",
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextItemText(cxtItem: ChatItem) {
|
||||
val member = cxtItem.memberDisplayName
|
||||
if (member == null) {
|
||||
Text(cxtItem.content.text, maxLines = 3)
|
||||
} else {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
withStyle(boldFont) { append(member) }
|
||||
append(": ${cxtItem.content.text}")
|
||||
}
|
||||
Text(annotatedText, maxLines = 3)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewContextItemView() {
|
||||
SimpleXTheme {
|
||||
ContextItemView(
|
||||
contextItem = remember {
|
||||
mutableStateOf(
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ArrowUpward
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(msg: MutableState<String>, sendMessage: (String) -> Unit, editing: Boolean = false) {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
var textStyle by remember { mutableStateOf(smallFont) }
|
||||
BasicTextField(
|
||||
value = msg.value,
|
||||
onValueChange = {
|
||||
msg.value = it
|
||||
textStyle = if (isShortEmoji(it)) {
|
||||
if (it.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
|
||||
} else {
|
||||
smallFont
|
||||
}
|
||||
},
|
||||
textStyle = textStyle,
|
||||
maxLines = 16,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
autoCorrect = true
|
||||
),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
cursorBrush = SolidColor(HighOrLowlight),
|
||||
decorationBox = { innerTextField ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
||||
) {
|
||||
Row(
|
||||
Modifier.background(MaterialTheme.colors.background),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 5.dp)
|
||||
.padding(bottom = 7.dp)
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
val color = if (msg.value.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray
|
||||
Icon(
|
||||
if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward,
|
||||
"Send Message",
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.clickable {
|
||||
if (msg.value.isNotEmpty()) {
|
||||
sendMessage(msg.value)
|
||||
msg.value = ""
|
||||
textStyle = smallFont
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSendMsgView() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
msg = remember { mutableStateOf("") },
|
||||
sendMessage = { msg -> println(msg) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSendMsgViewEditing() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
msg = remember { mutableStateOf("") },
|
||||
sendMessage = { msg -> println(msg) },
|
||||
editing = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.CIDirection
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun CIMetaView(chatItem: ChatItem) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (!chatItem.isDeletedContent) {
|
||||
if (chatItem.meta.itemEdited) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
modifier = Modifier.height(12.dp),
|
||||
contentDescription = "Edited",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
}
|
||||
// TODO status
|
||||
}
|
||||
Text(
|
||||
chatItem.timestampText,
|
||||
color = HighOrLowlight,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaView() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewEdited() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
itemEdited = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewDeletedContent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.Context
|
||||
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.Edit
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatItemView(
|
||||
user: User,
|
||||
cItem: ChatItem,
|
||||
msg: MutableState<String>,
|
||||
quotedItem: MutableState<ChatItem?>,
|
||||
editingItem: MutableState<ChatItem?>,
|
||||
cxt: Context,
|
||||
uriHandler: UriHandler? = null,
|
||||
showMember: Boolean = false,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
val sent = cItem.chatDir.sent
|
||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
Column(Modifier.combinedClickable(onLongClick = { showMenu = true }, onClick = {})) {
|
||||
if (cItem.isMsgContent) {
|
||||
if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem)
|
||||
} else {
|
||||
FramedItemView(user, cItem, uriHandler, showMember = showMember)
|
||||
}
|
||||
} else if (cItem.isDeletedContent) {
|
||||
DeletedItemView(cItem, showMember = showMember)
|
||||
}
|
||||
if (cItem.isMsgContent) {
|
||||
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
||||
ItemAction("Reply", Icons.Outlined.Reply, onClick = {
|
||||
editingItem.value = null
|
||||
quotedItem.value = cItem
|
||||
showMenu = false
|
||||
})
|
||||
ItemAction("Share", Icons.Outlined.Share, onClick = {
|
||||
shareText(cxt, cItem.content.text)
|
||||
showMenu = false
|
||||
})
|
||||
ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = {
|
||||
copyText(cxt, cItem.content.text)
|
||||
showMenu = false
|
||||
})
|
||||
if (cItem.chatDir.sent && cItem.meta.editable) {
|
||||
ItemAction("Edit", Icons.Filled.Edit, onClick = {
|
||||
quotedItem.value = null
|
||||
editingItem.value = cItem
|
||||
msg.value = cItem.content.text
|
||||
showMenu = false
|
||||
})
|
||||
}
|
||||
ItemAction(
|
||||
"Delete",
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
showMenu = false
|
||||
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
|
||||
DropdownMenuItem(onClick) {
|
||||
Row {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F),
|
||||
color = color
|
||||
)
|
||||
Icon(icon, text, tint = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = "Delete message?",
|
||||
text = "Message will be deleted - this cannot be undone!",
|
||||
buttons = {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text("For me only") }
|
||||
// if (chatItem.meta.editable) {
|
||||
// Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
// Button(onClick = {
|
||||
// deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
// AlertManager.shared.hideAlert()
|
||||
// }) { Text("For everyone") }
|
||||
// }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemView() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
User.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
msg = remember { mutableStateOf("") },
|
||||
quotedItem = remember { mutableStateOf(null) },
|
||||
editingItem = remember { mutableStateOf(null) },
|
||||
cxt = LocalContext.current,
|
||||
deleteMessage = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemViewDeletedContent() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
User.sampleData,
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
msg = remember { mutableStateOf("") },
|
||||
quotedItem = remember { mutableStateOf(null) },
|
||||
editingItem = remember { mutableStateOf(null) },
|
||||
cxt = LocalContext.current,
|
||||
deleteMessage = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = ReceivedColorLight,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(ci.content.text) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.ChatItem
|
||||
|
||||
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
|
||||
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
|
||||
|
||||
@Composable
|
||||
fun EmojiItemView(chatItem: ChatItem) {
|
||||
Column(
|
||||
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EmojiText(chatItem.content.text)
|
||||
CIMetaView(chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiText(text: String) {
|
||||
val s = text.trim()
|
||||
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
|
||||
}
|
||||
|
||||
private fun isSimpleEmoji(c: Int): Boolean = c > 0x238C
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
val SentColorLight = Color(0x1E45B8FF)
|
||||
val ReceivedColorLight = Color(0x20B1B0B5)
|
||||
val SentQuoteColorLight = Color(0x2545B8FF)
|
||||
val ReceivedQuoteColorLight = Color(0x25B1B0B5)
|
||||
|
||||
@Composable
|
||||
fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, showMember: Boolean = false) {
|
||||
val sent = ci.chatDir.sent
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (sent) SentColorLight else ReceivedColorLight
|
||||
) {
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||
val qi = ci.quotedItem
|
||||
if (qi != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
|
||||
.padding(vertical = 6.dp, horizontal = 12.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
MarkdownText(
|
||||
qi, sender = qi.sender(user), senderBold = true, maxLines = 3,
|
||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
if (ci.formattedText == null && isShortEmoji(ci.content.text)) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(bottom = 2.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EmojiText(ci.content.text)
|
||||
Text("")
|
||||
}
|
||||
} else {
|
||||
MarkdownText(
|
||||
ci.content, ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||
metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||
CIMetaView(ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EditedProvider: PreviewParameterProvider<Boolean> {
|
||||
override val values = listOf(false, true).asSequence()
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Boolean) {
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Boolean) {
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boolean) {
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1,
|
||||
CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
||||
itemEdited = edited
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Boolean) {
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
"https://simplex.chat",
|
||||
CIStatus.SndSent(),
|
||||
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
|
||||
itemEdited = edited
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Boolean) {
|
||||
SimpleXTheme {
|
||||
FramedItemView(
|
||||
User.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
"👍",
|
||||
CIStatus.SndSent(),
|
||||
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
|
||||
itemEdited = edited
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.*
|
||||
|
||||
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
||||
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
||||
|
||||
fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
|
||||
if (chatItem.chatDir is CIDirection.GroupRcv) {
|
||||
val name = chatItem.chatDir.groupMember.memberProfile.displayName
|
||||
if (groupMemberBold) b.withStyle(boldFont) { append(name) }
|
||||
else b.append(name)
|
||||
b.append(": ")
|
||||
}
|
||||
}
|
||||
|
||||
fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) {
|
||||
if (sender != null) {
|
||||
if (senderBold) b.withStyle(boldFont) { append(sender) }
|
||||
else b.append(sender)
|
||||
b.append(": ")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
content: ItemContent,
|
||||
formattedText: List<FormattedText>? = null,
|
||||
sender: String? = null,
|
||||
metaText: String? = null,
|
||||
edited: Boolean = false,
|
||||
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
uriHandler: UriHandler? = null,
|
||||
senderBold: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val reserve = if (edited) " " else " "
|
||||
if (formattedText == null) {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
append(content.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
|
||||
withAnnotation(tag = "URL", annotation = link) {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
}
|
||||
} else {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
}
|
||||
if (hasLinks && uriHandler != null) {
|
||||
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||
onClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
|
||||
val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
||||
|
||||
@Composable
|
||||
fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Text("Thank you for installing SimpleX Chat!")
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append("You can ")
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
|
||||
append("connect to SimpleX Chat founder")
|
||||
}
|
||||
append(".")
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
uriHandler.openUri(simplexTeamUri)
|
||||
})
|
||||
)
|
||||
|
||||
Column(
|
||||
Modifier.padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
"To start a new chat",
|
||||
style = MaterialTheme.typography.h2
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text("Tap button")
|
||||
Icon(
|
||||
Icons.Outlined.PersonAdd,
|
||||
"Add Contact",
|
||||
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
|
||||
)
|
||||
Text("above, then:")
|
||||
}
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(bold) { append("Add new contact") }
|
||||
append(": to create your one-time QR Code for your contact.")
|
||||
}
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(bold) { append("Scan QR code") }
|
||||
append(": to connect to your contact who shows QR code to you.")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text("To connect via link", style = MaterialTheme.typography.h2)
|
||||
Text("If you received SimpleX Chat invitation link you can open it in your browser:")
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append("\uD83D\uDCBB desktop: scan displayed QR code from the app, via ")
|
||||
withStyle(bold) { append("Scan QR code") }
|
||||
append(".")
|
||||
}
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append("\uD83D\uDCF1 mobile: tap ")
|
||||
withStyle(bold) { append("Open in mobile app") }
|
||||
append(", then tap ")
|
||||
withStyle(bold) { append("Connect") }
|
||||
append(" in the app.")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatHelpLayout() {
|
||||
SimpleXTheme {
|
||||
ChatHelpView({})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
ChatListNavLinkLayout(
|
||||
chat = chat,
|
||||
click = {
|
||||
if (chat.chatInfo is ChatInfo.ContactRequest) {
|
||||
contactRequestAlertDialog(chat.chatInfo, chatModel)
|
||||
} else {
|
||||
withApi { openChat(chatModel, chat.chatInfo) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) {
|
||||
val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId)
|
||||
if (chat != null) {
|
||||
chatModel.chatItems = chat.chatItems.toMutableStateList()
|
||||
chatModel.chatId.value = cInfo.id
|
||||
}
|
||||
}
|
||||
|
||||
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = "Accept connection request?",
|
||||
text = "If you choose to reject sender will NOT be notified.",
|
||||
confirmText = "Accept",
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
|
||||
if (contact != null) {
|
||||
val chat = Chat(ChatInfo.Direct(contact), listOf())
|
||||
chatModel.replaceChat(contactRequest.id, chat)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissText = "Reject",
|
||||
onDismiss = {
|
||||
withApi {
|
||||
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
|
||||
chatModel.removeChat(contactRequest.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = click)
|
||||
.height(88.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(start = 8.dp)
|
||||
.padding(end = 12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
if (chat.chatInfo is ChatInfo.ContactRequest) {
|
||||
ContactRequestView(chat)
|
||||
} else {
|
||||
ChatPreviewView(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkDirect() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLinkLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = listOf(
|
||||
ChatItem.getSampleData(
|
||||
1,
|
||||
CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
|
||||
)
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
click = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkGroup() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLinkLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Group.sampleData,
|
||||
chatItems = listOf(
|
||||
ChatItem.getSampleData(
|
||||
1,
|
||||
CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
|
||||
)
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
click = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkContactRequest() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLinkLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.ContactRequest.sampleData,
|
||||
chatItems = listOf(),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
click = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.ToolbarDark
|
||||
import chat.simplex.app.ui.theme.ToolbarLight
|
||||
import chat.simplex.app.views.chat.ChatHelpView
|
||||
import chat.simplex.app.views.newchat.NewChatSheet
|
||||
import chat.simplex.app.views.usersettings.SettingsView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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) {
|
||||
val scaffoldCtrl = scaffoldController()
|
||||
BottomSheetScaffold(
|
||||
scaffoldState = scaffoldCtrl.state,
|
||||
drawerContent = { SettingsView(chatModel) },
|
||||
sheetPeekHeight = 0.dp,
|
||||
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
ChatListToolbar(scaffoldCtrl)
|
||||
Divider()
|
||||
if (chatModel.chats.isNotEmpty()) {
|
||||
ChatList(chatModel)
|
||||
} else {
|
||||
val user = chatModel.currentUser.value
|
||||
Help(scaffoldCtrl, displayName = user?.profile?.displayName)
|
||||
}
|
||||
}
|
||||
if (scaffoldCtrl.expanded.value) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { scaffoldCtrl.collapse() },
|
||||
color = Color.Black.copy(alpha = 0.12F)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (displayName != null) "Welcome ${displayName}!" else "Welcome!",
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
ChatHelpView({ scaffoldCtrl.toggleSheet() })
|
||||
Row(
|
||||
Modifier.padding(top = 30.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"This text is available in settings",
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
Icons.Outlined.Settings,
|
||||
"Settings",
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
|
||||
Icon(
|
||||
Icons.Outlined.Menu,
|
||||
"Settings",
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Your chats",
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(5.dp)
|
||||
)
|
||||
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
|
||||
Icon(
|
||||
Icons.Outlined.PersonAdd,
|
||||
"Add Contact",
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatList(chatModel: ChatModel) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(chatModel.chats) { chat ->
|
||||
ChatListNavLinkView(chat, chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.Chat
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.item.MarkdownText
|
||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||
import chat.simplex.app.views.helpers.badgeLayout
|
||||
|
||||
@Composable
|
||||
fun ChatPreviewView(chat: Chat) {
|
||||
Row {
|
||||
ChatInfoImage(chat, size = 72.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1F)
|
||||
) {
|
||||
Text(
|
||||
chat.chatInfo.chatViewName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
val ci = chat.chatItems.lastOrNull()
|
||||
if (ci != null) {
|
||||
MarkdownText(
|
||||
ci.content, ci.formattedText, ci.memberDisplayName,
|
||||
metaText = ci.timestampText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
|
||||
Column(
|
||||
Modifier.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
ts,
|
||||
color = HighOrLowlight,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
val n = chat.chatStats.unreadCount
|
||||
if (n > 0) {
|
||||
Text(
|
||||
if (n < 1000) "$n" else "${n / 1000}k",
|
||||
color = MaterialTheme.colors.onPrimary,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.align(Alignment.End)
|
||||
.badgeLayout()
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
ChatPreviewView(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Chat
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||
|
||||
@Composable
|
||||
fun ContactRequestView(chat: Chat) {
|
||||
Row {
|
||||
ChatInfoImage(chat, size = 72.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1F)
|
||||
) {
|
||||
Text(
|
||||
chat.chatInfo.chatViewName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
Text(
|
||||
"wants to connect to you!",
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
val ts = getTimestampText(chat.chatInfo.createdAt)
|
||||
Column(
|
||||
Modifier.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
ts,
|
||||
color = HighOrLowlight,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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 chat.simplex.app.TAG
|
||||
|
||||
class AlertManager {
|
||||
var alertView = mutableStateOf<(@Composable () -> Unit)?>(null)
|
||||
var presentAlert = mutableStateOf<Boolean>(false)
|
||||
|
||||
fun showAlert(alert: @Composable () -> Unit) {
|
||||
Log.d(TAG, "AlertManager.showAlert")
|
||||
alertView.value = alert
|
||||
presentAlert.value = true
|
||||
}
|
||||
|
||||
fun hideAlert() {
|
||||
presentAlert.value = false
|
||||
alertView.value = null
|
||||
}
|
||||
|
||||
fun showAlertDialogButtons(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
buttons = buttons
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialog(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
confirmText: String = "Ok",
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = "Cancel",
|
||||
onDismiss: (() -> Unit)? = null
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertMsg(
|
||||
title: String, text: String? = null,
|
||||
confirmText: String = "Ok", onConfirm: (() -> Unit)? = null
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun showInView() {
|
||||
if (presentAlert.value) alertView.value?.invoke()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared = AlertManager()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.SupervisedUserCircle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.Chat
|
||||
import chat.simplex.app.model.ChatInfo
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun ChatInfoImage(chat: Chat, size: Dp) {
|
||||
val icon =
|
||||
if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
|
||||
else Icons.Filled.AccountCircle
|
||||
ProfileImage(size, chat.chatInfo.image, icon)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileImage(
|
||||
size: Dp,
|
||||
image: String? = null,
|
||||
icon: ImageVector = Icons.Filled.AccountCircle
|
||||
) {
|
||||
Box(Modifier.size(size)) {
|
||||
if (image == null) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = "profile image placeholder",
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
val imageBitmap = base64ToBitmap(image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"profile image",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(size).padding(size / 12).clip(CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatInfoImage() {
|
||||
SimpleXTheme {
|
||||
ChatInfoImage(
|
||||
chat = Chat(chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf()),
|
||||
size = 55.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun CloseSheetBar(close: () -> Unit) {
|
||||
Row (
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = close) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
"Close button",
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewCloseSheetBar() {
|
||||
SimpleXTheme {
|
||||
CloseSheetBar(close = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.*
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.views.newchat.ActionButton
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
|
||||
// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
|
||||
|
||||
fun bitmapToBase64(bitmap: Bitmap, squareCrop: Boolean = true): String {
|
||||
val size = 104
|
||||
var height = size
|
||||
var width = size
|
||||
var xOffset = 0
|
||||
var yOffset = 0
|
||||
if (bitmap.height < bitmap.width) {
|
||||
width = height * bitmap.width / bitmap.height
|
||||
xOffset = (width - height) / 2
|
||||
} else {
|
||||
height = width * bitmap.height / bitmap.width
|
||||
yOffset = (height - width) / 2
|
||||
}
|
||||
var image = bitmap
|
||||
while (image.width / 2 > width) {
|
||||
image = Bitmap.createScaledBitmap(image, image.width / 2, image.height / 2, true)
|
||||
}
|
||||
image = Bitmap.createScaledBitmap(image, width, height, true)
|
||||
if (squareCrop) {
|
||||
image = Bitmap.createBitmap(image, xOffset, yOffset, size, size)
|
||||
}
|
||||
val stream = ByteArrayOutputStream()
|
||||
image.compress(Bitmap.CompressFormat.JPEG, 85, stream)
|
||||
return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun base64ToBitmap(base64ImageString: String) : Bitmap {
|
||||
val imageString = base64ImageString
|
||||
.removePrefix("data:image/png;base64,")
|
||||
.removePrefix("data:image/jpg;base64,")
|
||||
val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
|
||||
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
}
|
||||
|
||||
class CustomTakePicturePreview : ActivityResultContract<Void?, Bitmap?>() {
|
||||
private var uri: Uri? = null
|
||||
private var tmpFile: File? = null
|
||||
lateinit var externalContext: Context
|
||||
|
||||
@CallSuper
|
||||
override fun createIntent(context: Context, input: Void?): Intent {
|
||||
externalContext = context
|
||||
tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
|
||||
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
|
||||
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
|
||||
}
|
||||
|
||||
override fun getSynchronousResult(
|
||||
context: Context,
|
||||
input: Void?
|
||||
): SynchronousResult<Bitmap?>? = null
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
|
||||
return if (resultCode == Activity.RESULT_OK && uri != null) {
|
||||
val source = ImageDecoder.createSource(externalContext.contentResolver, uri!!)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
tmpFile?.delete()
|
||||
bitmap
|
||||
} else {
|
||||
Log.e( TAG, "Getting image from camera cancelled or failed.")
|
||||
tmpFile?.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
|
||||
|
||||
@Composable
|
||||
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher<Void?, Bitmap?> =
|
||||
rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
|
||||
|
||||
@Composable
|
||||
fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher<String, Boolean> =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb)
|
||||
|
||||
@Composable
|
||||
fun GetImageBottomSheet(
|
||||
profileImageStr: MutableState<String?>,
|
||||
hideBottomSheet: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isCameraSelected = remember { mutableStateOf (false) }
|
||||
|
||||
val galleryLauncher = rememberGalleryLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
profileImageStr.value = bitmapToBase64(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
|
||||
if (bitmap != null) profileImageStr.value = bitmapToBase64(bitmap)
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
if (isCameraSelected.value) cameraLauncher.launch(null)
|
||||
else galleryLauncher.launch("image/*")
|
||||
hideBottomSheet()
|
||||
} else {
|
||||
Toast.makeText(context, "Permission Denied!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.onFocusChanged { focusState ->
|
||||
if (!focusState.hasFocus) hideBottomSheet()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
ActionButton(null, "Use Camera", icon = Icons.Outlined.PhotoCamera) {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||
cameraLauncher.launch(null)
|
||||
hideBottomSheet()
|
||||
}
|
||||
else -> {
|
||||
isCameraSelected.value = true
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
}
|
||||
ActionButton(null, "From Gallery", icon = Icons.Outlined.Collections) {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> {
|
||||
galleryLauncher.launch("image/*")
|
||||
hideBottomSheet()
|
||||
}
|
||||
else -> {
|
||||
isCameraSelected.value = false
|
||||
permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.views.helpers.CloseSheetBar
|
||||
|
||||
@Composable
|
||||
fun ModalView(close: () -> Unit, content: @Composable () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column {
|
||||
CloseSheetBar(close)
|
||||
Box(Modifier.padding(horizontal = 16.dp)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ModalManager {
|
||||
private val modalViews = arrayListOf<(@Composable (close: () -> Unit) -> Unit)?>()
|
||||
private val modalCount = mutableStateOf(0)
|
||||
|
||||
fun showModal(content: @Composable () -> Unit) {
|
||||
showCustomModal { close -> ModalView(close, content) }
|
||||
}
|
||||
|
||||
fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
|
||||
Log.d(TAG, "ModalManager.showModal")
|
||||
modalViews.add(modal)
|
||||
modalCount.value = modalViews.count()
|
||||
}
|
||||
|
||||
fun closeModal() {
|
||||
if (!modalViews.isEmpty()) {
|
||||
modalViews.removeAt(modalViews.count() - 1)
|
||||
}
|
||||
modalCount.value = modalViews.count()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun showInView() {
|
||||
if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared = ModalManager()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.layout
|
||||
|
||||
fun Modifier.badgeLayout() =
|
||||
layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
|
||||
// based on the expectation of only one line of text
|
||||
val minPadding = placeable.height / 4
|
||||
|
||||
val width = maxOf(placeable.width + minPadding, placeable.height)
|
||||
layout(width, placeable.height) {
|
||||
placeable.place((width - placeable.width) / 2, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.*
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
fun shareText(cxt: Context, text: String) {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
type = "text/plain"
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SimpleButton(text: String, icon: ImageVector,
|
||||
color: Color = MaterialTheme.colors.primary,
|
||||
click: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { click() }
|
||||
) {
|
||||
Icon(icon, text, tint = color,
|
||||
modifier = Modifier.padding(horizontal = 10.dp)
|
||||
)
|
||||
Text(text, style = MaterialTheme.typography.caption, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCloseSheetBar() {
|
||||
SimpleXTheme {
|
||||
SimpleButton(text = "Share", icon = Icons.Outlined.Share, click = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
|
||||
GlobalScope.launch { withContext(Dispatchers.Main, action) }
|
||||
|
||||
enum class KeyboardState {
|
||||
Opened, Closed
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getKeyboardState(): State<KeyboardState> {
|
||||
val keyboardState = remember { mutableStateOf(KeyboardState.Closed) }
|
||||
val view = LocalView.current
|
||||
DisposableEffect(view) {
|
||||
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = view.rootView.height
|
||||
val keypadHeight = screenHeight - rect.bottom
|
||||
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
|
||||
KeyboardState.Opened
|
||||
} else {
|
||||
KeyboardState.Closed
|
||||
}
|
||||
}
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
|
||||
|
||||
onDispose {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
|
||||
}
|
||||
}
|
||||
|
||||
return keyboardState
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.*
|
||||
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.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.shareText
|
||||
|
||||
@Composable
|
||||
fun AddContactView(chatModel: ChatModel) {
|
||||
val connReq = chatModel.connReqInvitation
|
||||
if (connReq != null) {
|
||||
val cxt = LocalContext.current
|
||||
AddContactLayout(
|
||||
connReq = connReq,
|
||||
share = { shareText(cxt, connReq) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddContactLayout(connReq: String, share: () -> Unit) {
|
||||
BoxWithConstraints {
|
||||
val screenHeight = maxHeight
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
"Add contact",
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
)
|
||||
Text(
|
||||
"Show QR code to your contact\nto scan from the app",
|
||||
style = MaterialTheme.typography.h3,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
QRCode(
|
||||
connReq, Modifier
|
||||
.weight(1f, fill = false)
|
||||
.aspectRatio(1f)
|
||||
.padding(vertical = 3.dp)
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append("If you cannot meet in person, you can ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append("scan QR code in the video call")
|
||||
}
|
||||
append(", or you can share the invitation link via any other channel.")
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.caption.copy(fontSize=if(screenHeight > 600.dp) 20.sp else 16.sp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = if(screenHeight > 600.dp) 16.dp else 8.dp)
|
||||
)
|
||||
SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
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",
|
||||
share = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
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 chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
|
||||
@Composable
|
||||
fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
ConnectContactLayout(
|
||||
qrCodeScanner = {
|
||||
QRCodeScanner { connReqUri ->
|
||||
try {
|
||||
val uri = Uri.parse(connReqUri)
|
||||
withUriAction(uri) { action ->
|
||||
connectViaUri(chatModel, action, uri)
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = "Invalid QR code",
|
||||
text = "This QR code is not a link!"
|
||||
)
|
||||
}
|
||||
close()
|
||||
}
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
|
||||
fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
|
||||
val action = uri.path?.drop(1)
|
||||
if (action == "contact" || action == "invitation") {
|
||||
withApi { run(action) }
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = "Invalid link!",
|
||||
text = "This link is not a valid connection link!"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
|
||||
val r = chatModel.controller.apiConnect(uri.toString())
|
||||
if (r) {
|
||||
val whenConnected =
|
||||
if (action == "contact") "your connection request is accepted"
|
||||
else "your contact's device is online"
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = "Connection request sent!",
|
||||
text = "You will be connected when $whenConnected, please wait or check later!"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
|
||||
ModalView(close) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Scan QR code",
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
)
|
||||
Text(
|
||||
"Your chat profile will be sent\nto your contact",
|
||||
style = MaterialTheme.typography.h3,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio = 1F)
|
||||
) { qrCodeScanner() }
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append("If you cannot meet in person, you can ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append("scan QR code in the video call")
|
||||
}
|
||||
append(", or you can create the invitation link.")
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewConnectContactLayout() {
|
||||
SimpleXTheme {
|
||||
ConnectContactLayout(
|
||||
qrCodeScanner = { Surface {} },
|
||||
close = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.Manifest
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.withApi
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
NewChatSheetLayout(
|
||||
addContact = {
|
||||
withApi {
|
||||
// show spinner
|
||||
chatModel.connReqInvitation = chatModel.controller.apiAddContact()
|
||||
// hide spinner
|
||||
if (chatModel.connReqInvitation != null) {
|
||||
newChatCtrl.collapse()
|
||||
ModalManager.shared.showModal { AddContactView(chatModel) }
|
||||
}
|
||||
}
|
||||
},
|
||||
scanCode = {
|
||||
newChatCtrl.collapse()
|
||||
ModalManager.shared.showCustomModal { close -> ConnectContactView(chatModel, close) }
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 48.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1F)
|
||||
.fillMaxWidth()) {
|
||||
ActionButton(
|
||||
"Add contact", "(create QR code\nor link)",
|
||||
Icons.Outlined.PersonAdd, click = addContact
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1F)
|
||||
.fillMaxWidth()) {
|
||||
ActionButton(
|
||||
"Scan QR code", "(in person or in video call)",
|
||||
Icons.Outlined.QrCode, click = scanCode
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1F)
|
||||
.fillMaxWidth()) {
|
||||
ActionButton(
|
||||
"Create Group", "(coming soon!)",
|
||||
Icons.Outlined.GroupAdd, disabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false,
|
||||
click: () -> Unit = {}) {
|
||||
Column(
|
||||
Modifier
|
||||
.clickable(onClick = click)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
Icon(icon, text,
|
||||
tint = tint,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.padding(bottom = 8.dp))
|
||||
if (text != null) {
|
||||
Text(
|
||||
text,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = tint,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
if (comment != null) {
|
||||
Text(
|
||||
comment,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewNewChatSheet() {
|
||||
SimpleXTheme {
|
||||
NewChatSheetLayout(
|
||||
addContact = {},
|
||||
scanCode = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
|
||||
@Composable
|
||||
fun QRCode(connReq: String, modifier: Modifier = Modifier) {
|
||||
Image(
|
||||
bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(),
|
||||
contentDescription = "QR Code",
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
fun qrCodeBitmap(content: String, size: Int): Bitmap {
|
||||
val hints = hashMapOf<EncodeHintType, Int>().also { it[EncodeHintType.MARGIN] = 1 }
|
||||
val bits = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
return Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).also {
|
||||
for (x in 0 until size) {
|
||||
for (y in 0 until size) {
|
||||
it.setPixel(x, y, if (bits[x, y]) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewQRCode() {
|
||||
SimpleXTheme {
|
||||
QRCode(connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.app.TAG
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import java.util.concurrent.*
|
||||
|
||||
// Bar code scanner adapted from https://github.com/MakeItEasyDev/Jetpack-Compose-BarCode-Scanner
|
||||
|
||||
@Composable
|
||||
fun QRCodeScanner(onBarcode: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var preview by remember { mutableStateOf<Preview?>(null) }
|
||||
|
||||
AndroidView(
|
||||
factory = { AndroidViewContext ->
|
||||
PreviewView(AndroidViewContext).apply {
|
||||
this.scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
},
|
||||
// modifier = Modifier.fillMaxSize(),
|
||||
update = { previewView ->
|
||||
val cameraSelector: CameraSelector = CameraSelector.Builder()
|
||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||
.build()
|
||||
val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> =
|
||||
ProcessCameraProvider.getInstance(context)
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
|
||||
val barcodeAnalyser = BarCodeAnalyser { barcodes ->
|
||||
barcodes.firstOrNull()?.rawValue?.let(onBarcode)
|
||||
}
|
||||
val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also { it.setAnalyzer(cameraExecutor, barcodeAnalyser) }
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "CameraPreview: ${e.localizedMessage}")
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class BarCodeAnalyser(
|
||||
private val onBarcodeDetected: (barcodes: List<Barcode>) -> Unit,
|
||||
): ImageAnalysis.Analyzer {
|
||||
private var lastAnalyzedTimeStamp = 0L
|
||||
|
||||
@ExperimentalGetImage
|
||||
override fun analyze(image: ImageProxy) {
|
||||
val currentTimestamp = System.currentTimeMillis()
|
||||
if (currentTimestamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) {
|
||||
image.image?.let { imageToAnalyze ->
|
||||
val options = BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
|
||||
.build()
|
||||
val barcodeScanner = BarcodeScanning.getClient(options)
|
||||
val imageToProcess = InputImage.fromMediaImage(imageToAnalyze, image.imageInfo.rotationDegrees)
|
||||
|
||||
barcodeScanner.process(imageToProcess)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
if (barcodes.isNotEmpty()) {
|
||||
onBarcodeDetected(barcodes)
|
||||
} else {
|
||||
Log.d(TAG, "BarcodeAnalyser: No barcode Scanned")
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { exception ->
|
||||
Log.e(TAG, "BarcodeAnalyser: Something went wrong $exception")
|
||||
}
|
||||
.addOnCompleteListener {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
lastAnalyzedTimeStamp = currentTimestamp
|
||||
} else {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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.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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.ChatHelpView
|
||||
|
||||
@Composable
|
||||
fun HelpView(chatModel: ChatModel) {
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
HelpLayout(displayName = user.profile.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HelpLayout(displayName: String) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Text(
|
||||
"Welcome $displayName!",
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
)
|
||||
ChatHelpView()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewHelpView() {
|
||||
SimpleXTheme {
|
||||
HelpLayout(displayName = "Alice")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.Format
|
||||
import chat.simplex.app.model.FormatColor
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun MarkdownHelpView() {
|
||||
Column {
|
||||
Text(
|
||||
"How to use markdown",
|
||||
style = MaterialTheme.typography.h1,
|
||||
)
|
||||
Text(
|
||||
"You can use markdown to format messages:",
|
||||
Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
MdFormat("*bold*", "bold", Format.Bold())
|
||||
MdFormat("_italic_", "italic", Format.Italic())
|
||||
MdFormat("~strike~", "strike", Format.StrikeThrough())
|
||||
MdFormat("`a + b`", "a + b", Format.Snippet())
|
||||
Row {
|
||||
MdSyntax("!1 colored!")
|
||||
Text(buildAnnotatedString {
|
||||
withStyle(Format.Colored(FormatColor.red).style) { append("colored") }
|
||||
append(" (")
|
||||
appendColor(this, "1", FormatColor.red, ", ")
|
||||
appendColor(this, "2", FormatColor.green, ", ")
|
||||
appendColor(this, "3", FormatColor.blue, ", ")
|
||||
appendColor(this, "4", FormatColor.yellow, ", ")
|
||||
appendColor(this, "5", FormatColor.cyan, ", ")
|
||||
appendColor(this, "6", FormatColor.magenta, ")")
|
||||
})
|
||||
}
|
||||
Row {
|
||||
MdSyntax("#secret#")
|
||||
SelectionContainer {
|
||||
Text(buildAnnotatedString {
|
||||
withStyle(Format.Secret().style) { append("secret") }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MdSyntax(markdown: String) {
|
||||
Text(markdown, Modifier
|
||||
.width(100.dp)
|
||||
.padding(bottom = 4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MdFormat(markdown: String, example: String, format: Format) {
|
||||
Row {
|
||||
MdSyntax(markdown)
|
||||
Text(buildAnnotatedString {
|
||||
withStyle(format.style) { append(example) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: String) {
|
||||
b.withStyle(Format.Colored(c).style) { append(s)}
|
||||
b.append(after)
|
||||
}
|
||||
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewMarkdownHelpView() {
|
||||
SimpleXTheme {
|
||||
MarkdownHelpView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.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.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
|
||||
@Composable
|
||||
fun SMPServersView(chatModel: ChatModel) {
|
||||
val userSMPServers = chatModel.userSMPServers.value
|
||||
if (userSMPServers != null) {
|
||||
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
|
||||
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
|
||||
var userSMPServersStr by remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
|
||||
fun saveSMPServers(smpServers: List<String>) {
|
||||
withApi {
|
||||
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
|
||||
if (r) {
|
||||
chatModel.userSMPServers.value = smpServers
|
||||
if (smpServers.isEmpty()) {
|
||||
isUserSMPServers = false
|
||||
editSMPServers = true
|
||||
} else {
|
||||
editSMPServers = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = isUserSMPServers,
|
||||
editSMPServers = editSMPServers,
|
||||
userSMPServersStr = userSMPServersStr,
|
||||
isUserSMPServersOnOff = { switch ->
|
||||
if (switch) {
|
||||
isUserSMPServers = true
|
||||
} else {
|
||||
val userSMPServers = chatModel.userSMPServers.value
|
||||
if (userSMPServers != null) {
|
||||
if (userSMPServers.isNotEmpty()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = "Use SimpleX Chat servers?",
|
||||
text = "Saved SMP servers will be removed.",
|
||||
confirmText = "Confirm",
|
||||
onConfirm = {
|
||||
saveSMPServers(listOf())
|
||||
isUserSMPServers = false
|
||||
userSMPServersStr = ""
|
||||
}
|
||||
)
|
||||
} else {
|
||||
isUserSMPServers = false
|
||||
userSMPServersStr = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
editUserSMPServersStr = { userSMPServersStr = it },
|
||||
cancelEdit = {
|
||||
val userSMPServers = chatModel.userSMPServers.value
|
||||
if (userSMPServers != null) {
|
||||
isUserSMPServers = userSMPServers.isNotEmpty()
|
||||
editSMPServers = !isUserSMPServers
|
||||
userSMPServersStr = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
|
||||
}
|
||||
},
|
||||
saveSMPServers = { saveSMPServers(it) },
|
||||
editOn = { editSMPServers = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SMPServersLayout(
|
||||
isUserSMPServers: Boolean,
|
||||
editSMPServers: Boolean,
|
||||
userSMPServersStr: String,
|
||||
isUserSMPServersOnOff: (Boolean) -> Unit,
|
||||
editUserSMPServersStr: (String) -> Unit,
|
||||
cancelEdit: () -> Unit,
|
||||
saveSMPServers: (List<String>) -> Unit,
|
||||
editOn: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Your SMP servers",
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("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("Using SimpleX Chat servers.")
|
||||
} else {
|
||||
Text("Enter one SMP server per line:")
|
||||
if (editSMPServers) {
|
||||
BasicTextField(
|
||||
value = userSMPServersStr,
|
||||
onValueChange = editUserSMPServersStr,
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace, fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
modifier = Modifier.height(160.dp),
|
||||
cursorBrush = SolidColor(HighOrLowlight),
|
||||
decorationBox = { innerTextField ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
||||
) {
|
||||
Row(
|
||||
Modifier.background(MaterialTheme.colors.background),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 5.dp, horizontal = 7.dp)
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Row {
|
||||
Text(
|
||||
"Cancel",
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = cancelEdit)
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
Text(
|
||||
"Save",
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
val servers = userSMPServersStr.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(
|
||||
userSMPServersStr,
|
||||
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(
|
||||
"Edit",
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = editOn)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun howToButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
|
||||
) {
|
||||
Text("How to", color = MaterialTheme.colors.primary)
|
||||
Icon(
|
||||
Icons.Outlined.OpenInNew, "How to", tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(horizontal = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewSMPServersLayoutDefaultServers() {
|
||||
SimpleXTheme {
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = false,
|
||||
editSMPServers = true,
|
||||
userSMPServersStr = "",
|
||||
isUserSMPServersOnOff = {},
|
||||
editUserSMPServersStr = {},
|
||||
cancelEdit = {},
|
||||
saveSMPServers = {},
|
||||
editOn = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewSMPServersLayoutUserServersEditOn() {
|
||||
SimpleXTheme {
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = true,
|
||||
editSMPServers = true,
|
||||
userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
|
||||
isUserSMPServersOnOff = {},
|
||||
editUserSMPServersStr = {},
|
||||
cancelEdit = {},
|
||||
saveSMPServers = {},
|
||||
editOn = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewSMPServersLayoutUserServersEditOff() {
|
||||
SimpleXTheme {
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = true,
|
||||
editSMPServers = false,
|
||||
userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
|
||||
isUserSMPServersOnOff = {},
|
||||
editUserSMPServersStr = {},
|
||||
cancelEdit = {},
|
||||
saveSMPServers = {},
|
||||
editOn = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
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.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.R
|
||||
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.SimpleXTheme
|
||||
import chat.simplex.app.views.TerminalView
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.newchat.ModalManager
|
||||
|
||||
@Composable
|
||||
fun SettingsView(chatModel: ChatModel) {
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
SettingsLayout(
|
||||
profile = user.profile,
|
||||
runServiceInBackground = chatModel.runServiceInBackground,
|
||||
setRunServiceInBackground = { on ->
|
||||
chatModel.controller.setRunServiceInBackground(on)
|
||||
chatModel.runServiceInBackground.value = on
|
||||
},
|
||||
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
|
||||
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
|
||||
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val simplexTeamUri =
|
||||
"simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
|
||||
|
||||
@Composable
|
||||
fun SettingsLayout(
|
||||
profile: Profile,
|
||||
runServiceInBackground: MutableState<Boolean>,
|
||||
setRunServiceInBackground: (Boolean) -> Unit,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
showTerminal: () -> Unit
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(8.dp)
|
||||
.padding(top = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
"Your settings",
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
Spacer(Modifier.height(30.dp))
|
||||
|
||||
SettingsSectionView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
|
||||
ProfileImage(size = 60.dp, profile.image)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Column {
|
||||
Text(
|
||||
profile.displayName,
|
||||
style = MaterialTheme.typography.caption,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(profile.fullName)
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView(showModal { UserAddressView(it) }) {
|
||||
Icon(
|
||||
Icons.Outlined.QrCode,
|
||||
contentDescription = "Address",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("Your SimpleX contact address")
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
SettingsSectionView(showModal { HelpView(it) }) {
|
||||
Icon(
|
||||
Icons.Outlined.HelpOutline,
|
||||
contentDescription = "Chat help",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("How to use SimpleX Chat")
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView(showModal { MarkdownHelpView() }) {
|
||||
Icon(
|
||||
Icons.Outlined.TextFormat,
|
||||
contentDescription = "Markdown help",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("Markdown in messages")
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) {
|
||||
Icon(
|
||||
Icons.Outlined.Tag,
|
||||
contentDescription = "SimpleX Team",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
"Chat with the founder",
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) {
|
||||
Icon(
|
||||
Icons.Outlined.Email,
|
||||
contentDescription = "Email",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
"Send us email",
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
SettingsSectionView(showModal { SMPServersView(it) }) {
|
||||
Icon(
|
||||
Icons.Outlined.Dns,
|
||||
contentDescription = "SMP servers",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("SMP servers")
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView() {
|
||||
Icon(
|
||||
Icons.Outlined.Bolt,
|
||||
contentDescription = "Private notifications",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("Private notifications", Modifier
|
||||
.padding(end = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1F))
|
||||
Switch(
|
||||
checked = runServiceInBackground.value,
|
||||
onCheckedChange = { setRunServiceInBackground(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView(showTerminal) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
||||
contentDescription = "Chat console",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("Chat console")
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append("Install ")
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
|
||||
append("SimpleX Chat for terminal")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView() {
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSectionView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) {
|
||||
val modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
Row(
|
||||
if (click == null) modifier else modifier.clickable(onClick = click),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSettingsLayout() {
|
||||
SimpleXTheme {
|
||||
SettingsLayout(
|
||||
profile = Profile.sampleData,
|
||||
runServiceInBackground = remember { mutableStateOf(true) },
|
||||
setRunServiceInBackground = {},
|
||||
showModal = {{}},
|
||||
showCustomModal = {{}},
|
||||
showTerminal = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@Composable
|
||||
fun UserAddressView(chatModel: ChatModel) {
|
||||
val cxt = LocalContext.current
|
||||
UserAddressLayout(
|
||||
userAddress = chatModel.userAddress.value,
|
||||
createAddress = {
|
||||
withApi {
|
||||
chatModel.userAddress.value = chatModel.controller.apiCreateUserAddress()
|
||||
}
|
||||
},
|
||||
share = { userAddress: String -> shareText(cxt, userAddress) },
|
||||
deleteAddress = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = "Delete address?",
|
||||
text = "All your contacts will remain connected.",
|
||||
confirmText = "Delete",
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.apiDeleteUserAddress()
|
||||
chatModel.userAddress.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserAddressLayout(
|
||||
userAddress: String?,
|
||||
createAddress: () -> Unit,
|
||||
share: (String) -> Unit,
|
||||
deleteAddress: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
"Your chat address",
|
||||
Modifier.padding(bottom = 16.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
)
|
||||
Text(
|
||||
"You can share your address as a link or as a QR code - anybody will be able to connect to you, " +
|
||||
"and if you later delete it - you won't lose your contacts.",
|
||||
Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
if (userAddress == null) {
|
||||
SimpleButton("Create address", icon = Icons.Outlined.QrCode, click = createAddress)
|
||||
} else {
|
||||
QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 10.dp)
|
||||
) {
|
||||
SimpleButton(
|
||||
"Share link",
|
||||
icon = Icons.Outlined.Share,
|
||||
click = { share(userAddress) })
|
||||
SimpleButton(
|
||||
"Delete address",
|
||||
icon = Icons.Outlined.Delete,
|
||||
color = Color.Red,
|
||||
click = deleteAddress
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserAddressLayoutNoAddress() {
|
||||
SimpleXTheme {
|
||||
UserAddressLayout(
|
||||
userAddress = null,
|
||||
createAddress = {},
|
||||
share = { _ -> },
|
||||
deleteAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserAddressLayoutAddressCreated() {
|
||||
SimpleXTheme {
|
||||
UserAddressLayout(
|
||||
userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
|
||||
createAddress = {},
|
||||
share = { _ -> },
|
||||
deleteAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
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.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Profile
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.ModalView
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
var editProfile = remember { mutableStateOf(false) }
|
||||
var profile by remember { mutableStateOf(user.profile) }
|
||||
UserProfileLayout(
|
||||
close = close,
|
||||
editProfile = editProfile,
|
||||
profile = profile,
|
||||
saveProfile = { displayName, fullName, image ->
|
||||
withApi {
|
||||
val p = Profile(displayName, fullName, image)
|
||||
val newProfile = chatModel.controller.apiUpdateProfile(p)
|
||||
if (newProfile != null) {
|
||||
chatModel.updateUserProfile(newProfile)
|
||||
profile = newProfile
|
||||
}
|
||||
editProfile.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserProfileLayout(
|
||||
close: () -> Unit,
|
||||
editProfile: MutableState<Boolean>,
|
||||
profile: Profile,
|
||||
saveProfile: (String, String, String?) -> Unit,
|
||||
) {
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val displayName = remember { mutableStateOf(profile.displayName) }
|
||||
val fullName = remember { mutableStateOf(profile.fullName) }
|
||||
val profileImage = remember { mutableStateOf(profile.image) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardState by getKeyboardState()
|
||||
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetContent = {
|
||||
GetImageBottomSheet(profileImage, hideBottomSheet = {
|
||||
scope.launch { bottomSheetModalState.hide() }
|
||||
})
|
||||
},
|
||||
sheetState = bottomSheetModalState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
ModalView(close = close) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
"Your chat profile",
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Text(
|
||||
"Your profile is stored on your device and shared only with your contacts.\n\n" +
|
||||
"SimpleX servers cannot see your profile.",
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
if (editProfile.value) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(192.dp, profileImage.value)
|
||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||
}
|
||||
if (profileImage.value != null) {
|
||||
DeleteImageButton { profileImage.value = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
ProfileNameTextField(displayName)
|
||||
ProfileNameTextField(fullName)
|
||||
Row {
|
||||
TextButton("Cancel") {
|
||||
displayName.value = profile.displayName
|
||||
fullName.value = profile.fullName
|
||||
profileImage.value = profile.image
|
||||
editProfile.value = false
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
TextButton("Save (and notify contacts)") {
|
||||
saveProfile(displayName.value, fullName.value, profileImage.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp), contentAlignment = Alignment.Center
|
||||
) {
|
||||
ProfileImage(192.dp, profile.image)
|
||||
if (profile.image == null) {
|
||||
EditImageButton {
|
||||
editProfile.value = true
|
||||
scope.launch { bottomSheetModalState.show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
ProfileNameRow("Display name:", profile.displayName)
|
||||
ProfileNameRow("Full name:", profile.fullName)
|
||||
TextButton("Edit") { editProfile.value = true }
|
||||
}
|
||||
}
|
||||
if (savedKeyboardState != keyboardState) {
|
||||
LaunchedEffect(keyboardState) {
|
||||
scope.launch {
|
||||
savedKeyboardState = keyboardState
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileNameTextField(name: MutableState<String>) {
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
modifier = Modifier
|
||||
.padding(bottom = 24.dp)
|
||||
.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileNameRow(label: String, text: String) {
|
||||
Row(Modifier.padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
label,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextButton(text: String, click: () -> Unit) {
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable(onClick = click),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditImageButton(click: () -> Unit) {
|
||||
IconButton(
|
||||
onClick = click,
|
||||
modifier = Modifier.background(Color(1f, 1f, 1f, 0.2f), shape = CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.PhotoCamera,
|
||||
contentDescription = "Edit image",
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteImageButton(click: () -> Unit) {
|
||||
IconButton(onClick = click) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = "Delete image",
|
||||
tint = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserProfileLayoutEditOff() {
|
||||
SimpleXTheme {
|
||||
UserProfileLayout(
|
||||
close = {},
|
||||
profile = Profile.sampleData,
|
||||
editProfile = remember { mutableStateOf(false) },
|
||||
saveProfile = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserProfileLayoutEditOn() {
|
||||
SimpleXTheme {
|
||||
UserProfileLayout(
|
||||
close = {},
|
||||
profile = Profile.sampleData,
|
||||
editProfile = remember { mutableStateOf(true) },
|
||||
saveProfile = {_, _, _ ->}
|
||||
)
|
||||
}
|
||||
}
|
||||
BIN
apps/android/app/src/main/res/drawable-hdpi/ntf_icon.png
Normal file
|
After Width: | Height: | Size: 861 B |
BIN
apps/android/app/src/main/res/drawable-mdpi/ntf_icon.png
Normal file
|
After Width: | Height: | Size: 569 B |
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
BIN
apps/android/app/src/main/res/drawable-xhdpi/ntf_icon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/android/app/src/main/res/drawable-xxhdpi/ntf_icon.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
apps/android/app/src/main/res/drawable-xxxhdpi/ntf_icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
22
apps/android/app/src/main/res/drawable/ic_github.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Android drawable generated by fa5ad-free project:
|
||||
https://github.com/diwanoczko/fa5ad-free
|
||||
|
||||
Resource generated base on Font Awesome 5 Free icons set:
|
||||
https://fontawesome.com/
|
||||
|
||||
All brand icons are trademarks of their respective owners.
|
||||
Please do not use brand logos for any purpose except to represent the
|
||||
company, product, or service to which they refer.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="23.25dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="496"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
||||
/>
|
||||
</vector>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,4H4C2.89,4 2,4.9 2,6v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V6C22,4.9 21.11,4 20,4zM20,18H4V8h16V18zM18,17h-6v-2h6V17zM7.5,17l-1.41,-1.41L8.67,13l-2.59,-2.59L7.5,9l4,4L7.5,17z"/>
|
||||
</vector>
|
||||
BIN
apps/android/app/src/main/res/drawable/logo.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/android/app/src/main/res/font/inter_bold.ttf
Normal file
BIN
apps/android/app/src/main/res/font/inter_italic.ttf
Normal file
BIN
apps/android/app/src/main/res/font/inter_medium.ttf
Normal file
BIN
apps/android/app/src/main/res/font/inter_regular.ttf
Normal file
BIN
apps/android/app/src/main/res/font/inter_semi_bold.ttf
Normal file
5
apps/android/app/src/main/res/mipmap-anydpi-v26/icon.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/white"/>
|
||||
<foreground android:drawable="@mipmap/icon_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/white"/>
|
||||
<foreground android:drawable="@color/white"/>
|
||||
</adaptive-icon>
|
||||
BIN
apps/android/app/src/main/res/mipmap-hdpi/icon.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
apps/android/app/src/main/res/mipmap-hdpi/icon_foreground.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/android/app/src/main/res/mipmap-hdpi/icon_round.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
apps/android/app/src/main/res/mipmap-mdpi/icon.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
apps/android/app/src/main/res/mipmap-mdpi/icon_foreground.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
apps/android/app/src/main/res/mipmap-mdpi/icon_round.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
apps/android/app/src/main/res/mipmap-xhdpi/icon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/android/app/src/main/res/mipmap-xhdpi/icon_foreground.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
apps/android/app/src/main/res/mipmap-xhdpi/icon_round.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
apps/android/app/src/main/res/mipmap-xxhdpi/icon.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
apps/android/app/src/main/res/mipmap-xxhdpi/icon_foreground.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/android/app/src/main/res/mipmap-xxhdpi/icon_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/android/app/src/main/res/mipmap-xxxhdpi/icon.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
apps/android/app/src/main/res/mipmap-xxxhdpi/icon_foreground.png
Normal file
|
After Width: | Height: | Size: 18 KiB |