Compare commits

..

1310 Commits

Author SHA1 Message Date
JRoberts
edf217111f 4.0.0 2022-09-20 19:11:52 +04:00
Evgeny Poberezkin
f0e18c62fe code: update simplexmq (async secure) 2022-09-20 15:42:36 +01:00
Evgeny Poberezkin
a615dbec91 support direct file invitations without contact requests (#1076) 2022-09-20 14:46:30 +01:00
JRoberts
a8ef92a933 android: version 4.0-beta.1 (54) 2022-09-20 14:38:05 +04:00
JRoberts
c1cca9385a ios: version 4.0 (73) 2022-09-20 14:13:04 +04:00
Stanislav Dmitrenko
267207cc15 android: Ability to delete app files and media (#1072)
* Ability to delete app files and media

* section title, corrections

* remove icon

* change translation

* revert disabled unless stopped

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-09-20 12:36:11 +04:00
JRoberts
012115b330 ios: disable files deletion unless chat is stopped (#1074) 2022-09-20 12:35:25 +04:00
JRoberts
c4aa988fb3 ios: version 4.0 (72) 2022-09-19 19:49:39 +04:00
JRoberts
c236a759d5 ios: clear storage translations (#1071) 2022-09-19 19:35:59 +04:00
JRoberts
67323a41eb ios: clear storage (#1069)
* wip

* display current storage state

* alert

* fix

* simplify

* remove unused function

* fix log

* replace prints with logger

* Apply suggestions from code review

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* low res will remain text

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-19 19:05:29 +04:00
JRoberts
962166c2ef mobile: prohibit /sql commands if unauthorized (#1068)
* ios: prohibit /sql commands if unauthorized

* refactor

* move check to send command

* revert diff

* refactor

* android

* Apply suggestions from code review

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* fix

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-19 13:02:48 +04:00
Evgeny Poberezkin
b0ed64533f update simplexmq 2022-09-18 13:54:33 +01:00
Evgeny Poberezkin
bc7fe4ec75 ios: update version to v4.0 2022-09-18 10:03:15 +01:00
Evgeny Poberezkin
923f7cbfd8 mobile: v4.0-beta.0 (ios: 71, android: 53) 2022-09-17 23:09:28 +01:00
Evgeny Poberezkin
5d55657186 core: support sql queries (#1066)
* core: support sql queries

* remove gradle change
2022-09-17 16:06:27 +01:00
JRoberts
f2067a047f ios: missing translations (#1064) 2022-09-17 18:57:57 +04:00
JRoberts
7dfd6e9a99 android: copy backup instead of moving (#1067) 2022-09-17 18:36:57 +04:00
JRoberts
2eca3e789c ios: restore db (#1063)
* wip

* wip

* wip

* refactor

* clean up

* simplify

* simplify

* refactor

* rename

* rename consts

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-17 16:41:20 +04:00
Evgeny Poberezkin
3351503744 update simplexmq (fix stopping agent) 2022-09-17 00:30:49 +01:00
JRoberts
107fa37aa6 core: don't check agent msg matches expected response of async cmd if it's ERR (#1062) 2022-09-16 19:41:53 +04:00
JRoberts
e8c14896aa core: process ERR response to async command (#1061) 2022-09-16 19:30:02 +04:00
JRoberts
d5b9f4014e ci: cabal build (#1057) 2022-09-16 17:52:23 +04:00
Evgeny Poberezkin
29b333cf0c update simplexmq 2022-09-16 13:43:37 +01:00
Stanislav Dmitrenko
7e340af48e System theme fix (#1059) 2022-09-15 21:39:19 +01:00
Stanislav Dmitrenko
568c9201d6 android: Restore database from a backup when encryption fails for some reason (#1058)
* Restore database from a backup when encryption fails for some reason

* Removed unused code

* Safer way of doing some things

* Ordering

* Increased possible diff in time to 10 seconds

* update strings

* Alert confirmation

* update strings

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-15 20:59:54 +01:00
Stanislav Dmitrenko
98ccab394a android: require to disable battery optimizations for periodic notifications mode (#1053)
* Changed requirements for ignoring battery for Periodic notifications mode

* Add delay before running ON_RESUME events because some data is not ready yet on that stage

* Better idea of when to show background notice

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-15 16:34:20 +01:00
Stanislav Dmitrenko
392b1028b3 android: Gif file size limit and download button (#1050)
* Gif file size limit and download button

* update icon

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-15 07:39:38 +01:00
Evgeny Poberezkin
d32e0d330f Merge pull request #985 from simplex-chat/sqlcipher
core: switch from SQLite to SQLCipher
2022-09-14 22:46:14 +01:00
Evgeny Poberezkin
d1571798f4 update simplexmq 2022-09-14 21:50:44 +01:00
JRoberts
ff35a3fee5 core: sqlcipher stack build (#991)
* wip

* uncomment

* comment

* ci split build step

* check openssl

* openssl version

* add stack params

* update mac openssl parameters

* ls openssl

* clean stack.yaml

* clean up build.yml

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-14 21:28:21 +01:00
Stanislav Dmitrenko
29b27fa602 android: progress indicator in database related views (#1052)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-14 21:27:17 +01:00
Evgeny Poberezkin
08e0d7339f Merge branch 'master' into sqlcipher 2022-09-14 18:46:03 +01:00
JRoberts
6a05a56e3e mobile: fix group delete alert text for local deletion (#1051) 2022-09-14 21:45:59 +04:00
JRoberts
f1e34531c2 mobile: fix db encryption translations (#1049) 2022-09-14 20:16:13 +04:00
JRoberts
c07d4a5e4e core: use async agent commands when establishing connections w/t user action (#977)
* wip

* wip

* wip

* schema

* schema

* wip

* wip

* rework

* revert

* update simplexmq

* async commands

* corr id wip

* wip

* update simplexmq

* corr id

* wip

* rename variable

* wip

* refactor

* ACK continuation

* wip

* fix queries

* fix queries

* clean up schema

* update simplexmq, do not lock on stopping chat

* clean up

* refactor

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-14 19:45:21 +04:00
Evgeny Poberezkin
76a7dfeabb ios: localize database encryption (#1048)
* ios: localize database encryption

* fix incorrect language in NSE localizations

* corrections

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-09-14 14:04:41 +01:00
Evgeny Poberezkin
63a98fa9d3 Merge pull request #1028 from simplex-chat/sqlcipher-android
android: add SQLCipher
2022-09-14 12:08:06 +01:00
Stanislav Dmitrenko
78f854e2c5 android: database encryption support with a passphrase (#1021)
* Ability to encrypt credentials and to store them securelly

* Don't regenerate key if it exists

* Made code shorter

* Refactoring

* Initial support of encryped database

* Changes in UI and notifications about database problems

* Small changes to how we use chatController instance

* Show unlock view in console automatically

* Fixed wrong place of saving a key

* Fixed a crash

* update icons

* Changing controller correctly

* Enable migration

* fix JNI

* Fixed startup

* Show database error view when password is wrong while enabling a chat

* Chat controller re-init in one more place
- also added one more alert

* Scrollable columns and restarted service and worker

* translations

* database passphrase

* update translations

* translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update translations

* update translations

* update icon colors, show empty passphrase as not stored

* update translation

* update translations

* shared section footer, bigger font, layout, change entropy bounds

* correction

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update translations

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-09-14 12:06:12 +01:00
Evgeny Poberezkin
17f806e7a2 Merge branch 'sqlcipher' into sqlcipher-android 2022-09-13 22:34:45 +01:00
Evgeny Poberezkin
7c9f351849 Merge branch 'master' into sqlcipher 2022-09-13 22:32:43 +01:00
Evgeny Poberezkin
41e5bea8d6 Merge branch 'sqlcipher' into sqlcipher-android 2022-09-13 19:25:28 +01:00
Evgeny Poberezkin
933f2ce614 Merge branch 'sqlcipher' of github.com:simplex-chat/simplex-chat into sqlcipher 2022-09-13 19:20:47 +01:00
JRoberts
5089dfdada mobile: fix quote sender name interaction with incognito membership and alias (#1041) 2022-09-13 20:33:18 +04:00
Evgeny Poberezkin
7f9e68c58d remove duplicate patch 2022-09-13 12:05:18 +01:00
Evgeny Poberezkin
c7dcdb1186 Merge branch 'master' into sqlcipher 2022-09-13 08:39:24 +01:00
Evgeny Poberezkin
69138a24de nix: patch out android logging from sqlcipher 2022-09-13 08:38:31 +01:00
Stanislav Dmitrenko
bf0fdf6d42 android: animated images support (#1038)
* Animated images support

* Provide correct mime type when saving an image

* Higher limit size for images auto download
2022-09-12 22:47:44 +01:00
Evgeny Poberezkin
9ff7dc8977 Typescript chat client SDK (#1036)
* add typescript chat commands

* add chat responses

* add client API

* fix chat bot example, readme

* update readme

* update main readme

* readme

* corrections

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* correction

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-09-12 18:33:34 +01:00
Evgeny Poberezkin
7aedd3d9e9 inline files rfc (#1024) 2022-09-11 19:07:29 +01:00
Evgeny Poberezkin
42088fc78d Merge branch 'master' into sqlcipher 2022-09-11 13:49:44 +01:00
Evgeny Poberezkin
4be50fc923 nix: refactor ios parameters 2022-09-11 09:38:40 +01:00
Evgeny Poberezkin
e080690c2e nix: remove ERR_error_string patch 2022-09-11 09:23:16 +01:00
Evgeny Poberezkin
f6e2f11299 nix: patch to replace ERR_error_string with ERR_func_error_string 2022-09-10 23:02:37 +01:00
Evgeny Poberezkin
9d70cf1e7b nix: update patches 2022-09-10 22:10:26 +01:00
Evgeny Poberezkin
35af0786c0 nix: fix syntax error 2022-09-10 21:55:26 +01:00
Evgeny Poberezkin
2a32810182 nix: android/log.h patch 2022-09-10 21:49:51 +01:00
Evgeny Poberezkin
2bc591783d Merge branch 'sqlcipher' into sqlcipher-android 2022-09-10 18:27:33 +01:00
Evgeny Poberezkin
0c716da346 nix: fix patchelf 2022-09-10 18:18:47 +01:00
Evgeny Poberezkin
ca0a51a485 nix: add commoncrypto flag to tagged json builds 2022-09-10 18:02:19 +01:00
Evgeny Poberezkin
6e8aa5595d Merge branch 'master' into sqlcipher 2022-09-10 17:52:13 +01:00
Evgeny Poberezkin
0af5031790 nix: re-use ios post install script (#1035) 2022-09-10 16:57:40 +01:00
Evgeny Poberezkin
e5e8d95ba4 nix: fix condition syntax 2022-09-10 16:12:34 +01:00
Evgeny Poberezkin
33011b5d48 nix: skip libsimplex.so when patching so name 2022-09-10 14:17:08 +01:00
Evgeny Poberezkin
8085515f56 nix: set -x 2022-09-10 11:08:28 +01:00
Evgeny Poberezkin
f3f661ee40 Merge branch 'master' into sqlcipher 2022-09-10 11:07:50 +01:00
Evgeny Poberezkin
a26f2a58d1 update paths in web.yml 2022-09-10 10:42:04 +01:00
Evgeny Poberezkin
7fa78de6d4 add paths to trigger build in web.yml 2022-09-10 10:35:52 +01:00
Evgeny Poberezkin
46241c31e1 update website on master branch changes 2022-09-10 10:29:16 +01:00
Evgeny Poberezkin
c06cef9727 update CNAME 2022-09-10 10:27:50 +01:00
M Sarmad Qadeer
43adb7de82 migrate website to 11ty site generator (#913)
* readme: fix link

* add 11ty files to website folder

* add web.yml

* add simplex web files

* add font matter to some blogs

* remove unnecessary things

* change few settings

* add a web script

* update web.yml

* update image format & add an image

* add font matter to blogs

* update blog.html

* add article layout & give that layout to blogs

* update the location of _includes

* update article layout

* change original blog links

* add styling to blog

* improve the links of blogs

* update web.sh

* add favicon

* update a tag in a blog

* improve stylings of article page

* improve styling of blog page

* update the theme

* update font matter and update links in new blog.

* add style changes

* apply reverse chronology sort on articles

* shift blogs links back to hashes

* add ids to headers & smooth scrolling

* make all blog links relative

* add smooth scrolling & add relative to absolute links converter

* add navigation

* improve mobile nav

* change desktop header style

* convert blogs link text to "Read More"

* change desktop header style

* style mobile nav

* fix landing page styling

* update web workflow

* update web workflow

* nav setting

* add tailwind links

* update web workflow

* remove app demo folder

* remove special characters from the links

* fix the issue of links

* make web.sh executable

* update blog links

* move web.sh to website folder

* code style

* EOLs

* format index.css & contact.css

* add markdown-it configuration

* add outline none on focus

* remove extra Javascript

* make mobile nav display none by default

* add permalinks to markdown files

* update 11ty config

* update web.sh

* update article

* resolve issue of special characters in header ids
introduce slugify

* add target _blank to whitepaper link

* add last post

* EOLs

* try to resolve bullets issue

* use markdown-it-replace-link
to convert relative .md extension to .html extension

* add missing images, simpligy link parsing

* add CNAME file

* add CNAME file, rename config

* fix jumping table issue

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-10 10:26:21 +01:00
Evgeny Poberezkin
06835ee3fc ios: additional db encryption UX (#1031)
* ios: additional db encryption UX

* typo

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* fixes

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-09-08 17:36:16 +01:00
Evgeny Poberezkin
9eb244f9c1 nix: set so name 2022-09-08 14:04:33 +01:00
Evgeny Poberezkin
f3a3fe0710 Merge branch 'sqlcipher' into sqlcipher-android 2022-09-08 13:31:29 +01:00
Evgeny Poberezkin
22ee465d3b nix: update to rename libs dependencies and to remove libunwind from .so files 2022-09-08 07:35:32 +01:00
Evgeny Poberezkin
8097611207 ios: NSE without passphrase in keychain (#1030) 2022-09-07 20:06:16 +01:00
Evgeny Poberezkin
85e62c4f79 Merge branch 'master' into sqlcipher 2022-09-07 17:26:05 +01:00
Evgeny Poberezkin
05417fd1e8 nix: ls androidPkgs 2022-09-07 17:23:24 +01:00
Evgeny Poberezkin
3f5ca84c84 core: fix error reporting of sqlcipher export errors (#1029) 2022-09-07 17:20:47 +01:00
Evgeny Poberezkin
0fc3453f20 extend JNI bridge 2022-09-07 13:20:28 +01:00
Evgeny Poberezkin
6904ad68d9 android: add libcrypto.so 2022-09-07 12:58:01 +01:00
Evgeny Poberezkin
766009269e ios: use SQLCipher (#1012)
* use sqlcipher build, hardcoded encryption key

* UI for db encryption

* database passphrase UI

* show orange icon when database is not encrypted

* call encrypt

* more ios ux

* basic UX for passphrase complete

* with animation

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* passphrase complexity, fixes

* fix moving entry field

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-09-07 12:49:41 +01:00
Stanislav Dmitrenko
aa79a3058c android: mute/unmute in a chat menu (#1026)
* Mute/unmute in a chat menu

* Better naming
2022-09-07 10:36:00 +01:00
Evgeny Poberezkin
00471a095d update flake.nix to export openssl libs 2022-09-07 10:24:42 +01:00
Evgeny Poberezkin
de2d169fbc core: report passphrase error separately from others (#1027) 2022-09-06 23:14:58 +01:00
Evgeny Poberezkin
7072dd4f7e core: fix api for encryption (#1025) 2022-09-06 21:25:07 +01:00
Stanislav Dmitrenko
65f3fe8afc Fix counter when message is updated (#1023) 2022-09-06 20:26:52 +01:00
Stanislav Dmitrenko
03b4bea82a ci: script for downloading and unpacking prebuilt aarch64 libs for Android (#864)
* Script for downloading and unpacking prebuilt aarch64 libs for Android

* set -e

* Script for downloading libs supports macOs
2022-09-06 19:13:27 +01:00
Evgeny Poberezkin
039f810f4f Merge branch 'master' into sqlcipher 2022-09-06 19:06:42 +01:00
Stanislav Dmitrenko
51bb2fe60b android: onion hosts description (#1020)
* Onion hosts description

* Removed line

* Added text to alert before changing network settings

* Strings

* change alert title

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-06 19:05:57 +01:00
Stanislav Dmitrenko
6586e45d86 android: Option for periodically fetching new messages without starting a service (#1013)
* Option for periodically fetching new messages without starting a service
- also user can hide some content from notification, like it's text or/and author

* More stable notification worker

* Allowed to run periodic notifications when battery optimization is on

* corrections

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* correction

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* correction

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* Changes to notifications flow

* correction

* Made delay for receiving messages in worker longer

* correction

* check interval

* Update SimplexApp.kt, SimplexService.kt, and SimpleXAPI.kt

* update strings

* Strings

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-09-06 16:45:21 +01:00
Evgeny Poberezkin
0d220d63ea update flake.nix 2022-09-05 22:14:35 +01:00
Evgeny Poberezkin
9d009663ba update flake.nix to export libs from androidPkgs.openssl 2022-09-05 15:40:40 +01:00
Evgeny Poberezkin
a611040c41 Merge branch 'master' into sqlcipher 2022-09-05 15:37:08 +01:00
Evgeny Poberezkin
b232b6132f terminal: commands to mute/unmute contacts and groups (#1018)
* terminal: commands to mute/unmute contacts and groups

* tests
2022-09-05 15:23:38 +01:00
Stanislav Dmitrenko
f512298d10 Search in a list of chats (#1009)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-05 15:22:28 +01:00
Evgeny Poberezkin
082e12683b core: change database encryption API to require current passphrase on all changes (#1019) 2022-09-05 14:54:39 +01:00
Evgeny Poberezkin
229f385f42 Merge branch 'master' into sqlcipher 2022-09-04 19:07:19 +01:00
Evgeny Poberezkin
4dd2b1d88b readme, typo 2022-09-04 09:45:34 +01:00
Evgeny Poberezkin
da4e103cec Merge branch 'master' into sqlcipher 2022-09-03 20:52:26 +01:00
Evgeny Poberezkin
619d58900c mobile: enable chat console when chat is stopped (#1017) 2022-09-03 20:51:59 +01:00
Evgeny Poberezkin
a8216bbd54 core: add error strings to SQLCipher encrypt/decrypt commands (#1014) 2022-09-03 19:32:21 +01:00
Evgeny Poberezkin
19f3890bed update flake.nix 2022-09-03 09:22:19 +01:00
Evgeny Poberezkin
4734758be0 fix flake.nix 2022-09-02 22:44:49 +01:00
Evgeny Poberezkin
ed97518a53 Merge branch 'master' into sqlcipher 2022-09-02 22:13:46 +01:00
Evgeny Poberezkin
ddde821064 nix: direct-sqlcipher patch, openssl flag for android (#1011) 2022-09-02 22:03:53 +01:00
Evgeny Poberezkin
7999e88554 core: fix chatInitKey to pass database key to agent store (#1010) 2022-09-02 20:20:43 +01:00
Evgeny Poberezkin
2b5e3a9459 core: C API to migrate and check database (#1008)
* core: C API to migrate and check database

* update simplexmq
2022-09-02 16:38:41 +01:00
Evgeny Poberezkin
38b3965e68 use commoncrypto flag in ios nix build (for sqlcipher) (#1006)
* use commoncrypto flag in ios nix build (for sqlcipher)

* remove openssl flag from cabal.project
2022-09-02 11:26:14 +01:00
Evgeny Poberezkin
f68d5e1e60 android: fix alias layout (#986)
* android: fix alias layout

* Small changes to layout of alias text field

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2022-09-02 10:13:21 +01:00
sh
32c133d6f8 build-android: fix script (#1005) 2022-09-01 22:32:57 +01:00
Stanislav Dmitrenko
5371ad82c2 Reversed terminal layout (#1004) 2022-09-01 21:57:05 +01:00
Stanislav Dmitrenko
6eb6004706 android: pick image from gallery if it exists or from files otherwise (#1003)
* Pick image from gallery if it exists or from files otherwise

* Remove toast if gallery is not found
2022-09-01 21:01:59 +01:00
Stanislav Dmitrenko
fc31b404d7 Swiping over a link will not trigger browser opening (#1002) 2022-09-01 20:28:58 +01:00
Stanislav Dmitrenko
4c60309d1d android: screen lock with rotation (#1001)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-01 20:26:30 +01:00
Evgeny Poberezkin
6597400f61 Merge branch 'master' into sqlcipher 2022-09-01 17:46:56 +01:00
Evgeny Poberezkin
8356d7858f ios: update library 2022-09-01 17:46:41 +01:00
Evgeny Poberezkin
944c502101 update blog 2022-09-01 14:58:31 +01:00
Evgeny Poberezkin
0244f2da9a blog: v3.2 - incognito mode (#996)
* blog: v3.2 - incognito mode

* typo

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* add images

* images

* fix link

* update blog links

* correction

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-09-01 12:42:13 +01:00
JRoberts
79d891e5bc android: version 3.2.1 (52) 2022-09-01 15:06:34 +04:00
JRoberts
313963dab6 android: fix incoming call view (#999)
* different implementation

* layout

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-01 11:54:34 +01:00
Evgeny Poberezkin
6727613dc1 ios: missing localizations (#1000) 2022-09-01 11:43:17 +01:00
Stanislav Dmitrenko
e54688ad89 android: disable compression of res/raw directory to avoid crash on incoming call (opening mp3) (#997)
* Disable compression of `res/raw` directory. Otherwise Android crashes the app when trying to open .mp3 file from the directory

* do not compress all files in res folder

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-01 09:34:56 +01:00
Stanislav Dmitrenko
1a36c88f72 android: prevent orientation change in calls (#995)
- orientation will be locked in calls to portrait and then returned back
- calls ends audio session when they will be ended
2022-08-31 21:50:08 +01:00
Stanislav Dmitrenko
1e587df3d4 State preserving for some UI elements which otherwise would be lost on orientation change (#994)
- restore message text as well as reply state
- restore search view
- don't display blank view on orientation change for a moment
- better saving of local user name while typing. Prevent loosing state on orientation change and hard killing the app
- don't display same messages in MainActivity from old intents on orientation change (no double processing of intent)
2022-08-31 21:49:19 +01:00
Evgeny Poberezkin
3613fc953e core: encrypt chat database (#988)
* core: encrypt chat database

* check DB key error on start

* function to encrypt database

* encrypt database command

* decrypt, rekey

* remove rekey, refactor

* test for db encryption/decryption

* update simplexmq
2022-08-31 18:07:34 +01:00
JRoberts
74b11d1c5d 3.2.1 (#992) 2022-08-31 16:13:40 +04:00
JRoberts
a1562bf0e7 android: restore footer counter (#990) 2022-08-31 12:26:41 +04:00
JRoberts
73447ce22b android: version 3.2 (51) 2022-08-31 09:55:21 +04:00
JRoberts
956cf6b203 android: version 3.2 (50) 2022-08-31 09:45:18 +04:00
Stanislav Dmitrenko
378118b82e Options when using .onion hosts (#989)
* Options when using .onion hosts

* Confirmation alert before applying network settings

* Useless new line was removed

* Different ordering of options in enum
2022-08-30 22:24:33 +01:00
Stanislav Dmitrenko
92abdde69e android: start direct chat button inside MemberInfo (#987)
* Start direct chat button inside GroupInfo

* More code for better understanding
2022-08-30 18:37:44 +01:00
Evgeny Poberezkin
5e5c851173 update simplexmq 2022-08-30 16:35:56 +01:00
Evgeny Poberezkin
ed519a5cfe Merge branch 'master' into sqlcipher 2022-08-30 16:35:12 +01:00
Evgeny Poberezkin
69758971af update simplexmq (to fix network-transport at 0.5.4) 2022-08-30 16:17:48 +01:00
Evgeny Poberezkin
025f838f43 update dependencies 2022-08-30 14:33:43 +01:00
Evgeny Poberezkin
e651952a34 Merge branch 'master' into sqlcipher 2022-08-30 12:52:06 +01:00
Evgeny Poberezkin
02ca7234fb use SQLCipher (#981)
* use SQLCipher

* pass encryption key via CLI options

* update dependencies to use git

* add CONTRIBUTING.md

* move flag, enable build in sqlcipher branch

* update dependencies
2022-08-30 12:49:07 +01:00
JRoberts
5fa2d7f7ce ios: disable incognito toggle when chat is stopped (#983) 2022-08-30 15:33:36 +04:00
Stanislav Dmitrenko
0169589e7c Incognito mode (#974)
* Incognito mode

* Incognito icon color and state applying

* Added a spacer under username

* Local contact aliases support

* Simplified incognito

* update help title

* ChatInfo layout

* corrections

* color

* icon

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-08-30 15:17:28 +04:00
Stanislav Dmitrenko
2d4348c50d Regex for Emoji (#982) 2022-08-30 08:50:44 +01:00
JRoberts
14f6e5c16f ios: version 3.2 (70) 2022-08-29 20:10:27 +04:00
Evgeny Poberezkin
51a2fa8c28 ios: programmatic navigation between list/chat (#980)
* ios: programmatic navigation between list/chat

* prevent chat info sheet from showing when switching conversation

* add direct chat with member to model

* set status to connected

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-08-29 17:08:46 +04:00
JRoberts
7343e4a51a ios: simplify incognito feature (#979) 2022-08-29 14:47:29 +04:00
Evgeny Poberezkin
b4d7afb4c1 ios: dark/light mode toggle (#975) 2022-08-28 09:14:55 +01:00
JRoberts
2fc6873c42 core: simplify incognito feature - remove host/invitee incognito profiles communication; remove incognito mode group creation and join; use same incognito profile known to host when joining (#978) 2022-08-27 19:56:03 +04:00
JRoberts
7683254de2 ios: group member navigation (#973) 2022-08-26 17:27:38 +04:00
JRoberts
3a077d927d ios: contact aliases (#970)
* ios: contact aliases

* wip

* wip

* wip

* move onTapGesture

* revert test

* improve search

* corrections

* font size

* remove parameter

* clear button

* button style

* remove clear button

* ternary

* refactor search

* rename

* ios: contact aliases translations (#972)

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-25 17:36:26 +04:00
Stanislav Dmitrenko
a5e74ea2f0 android: choosing theme and accent color (#967)
* Theme selector
- ability to select from three default choices: system theme, light, dark
- ability to choose color accent (primary color)

* Removed unused code and made small changes to colors and buttons
2022-08-24 21:15:26 +01:00
JRoberts
53a71cf28c core: contact aliases (#968) 2022-08-24 19:03:43 +04:00
JRoberts
e6551abc68 ios: incognito mode (#945)
* ios: incognito types

* wip

* wip

* wip

* wip

* wip

* cleaner interface

* CIGroupInvitationView logic

* masks not filled

* ui improvements

* wip

* wip

* incognito may be compromised alerts

* help

* remove modifier

* Update apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* contact request

* texts

* ;

* prepare for merge

* restore help

* wip

* update help

* wip

* update incognito help

* the

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* wording

* translations

* secondary color

* translations

* translations

* fix Your Chats title

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-23 18:18:12 +04:00
Evgeny Poberezkin
bd7aa81625 core: DB json encoding for group events (platform independent) (#966)
* core: DB json encoding for group events (platform independent)

* comment, migration

* shorter constructors

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-08-23 16:24:43 +04:00
Stanislav Dmitrenko
04592f52de Ability to disable notifications per chat (#964)
* Ability to disable notifications per chat

* All Buttons in AlertDialog replaced with TextButtons

* update icon

* update strings

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-22 21:16:01 +01:00
JRoberts
a06499d710 core: add host_conn_custom_user_profile_id to groups to replace join with connections causing duplicates (avoids complex subqueries) (#965) 2022-08-22 23:12:09 +04:00
Stanislav Dmitrenko
6d1414af71 UI tweaks (#963)
* UI tweaks

* correction
2022-08-22 17:36:39 +01:00
Stanislav Dmitrenko
9cd7a7fdb0 Re-apply new chat instance on chatId changes (#962)
* Re-apply new chat instance on chatId changes

* Fixed incorrectly calculated counters on floating buttons

* Show chat at the bottom of a view instead of at the top
2022-08-22 14:02:46 +01:00
Stanislav Dmitrenko
3c2f5d14f5 Made scrolling faster (#961) 2022-08-22 12:14:46 +01:00
JRoberts
985f3dffd3 core: read host's connection custom_user_profile_id into group info (#960) 2022-08-22 11:04:34 +04:00
Evgeny Poberezkin
0a048eb286 ios: fix typo, add information to settings (#958) 2022-08-20 23:03:56 +01:00
Evgeny Poberezkin
2cffe91e0b ios: choose accent color (#956) 2022-08-20 21:55:06 +01:00
Evgeny Poberezkin
9f94c6f98a 3.2.0 (#957) 2022-08-20 19:52:25 +01:00
Evgeny Poberezkin
164426db49 core: catch error when toggling notifications (#954)
* core: catch error when toggling notifications

* filter current members

* filter active members
2022-08-20 14:47:24 +01:00
Evgeny Poberezkin
307db450d8 ios: mute notifications per chat (#950)
* mute notifications per chat

* toggle notifications

* update settings api

* move model changes to main thread

* add mute indication, remove swipe buttons

* icon

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-08-20 12:47:48 +01:00
JRoberts
e6233722db core: create incognito membership if direct connection with host is incognito when processing invitation instead of on join (#953) 2022-08-20 13:12:20 +04:00
Evgeny Poberezkin
d26083d8b7 core: fix settings api (#952) 2022-08-19 22:44:00 +01:00
Evgeny Poberezkin
f561698fb9 mobile: update version: 3.2, android 49 / iOS 69 2022-08-19 20:36:29 +01:00
Stanislav Dmitrenko
74966b1425 Two fixes, better performance too (#951)
- on orientation change scroll position in chat wasn't preserved
- the app had been making multiple same queries to a database when tried to preload more messages
2022-08-19 20:02:28 +01:00
Stanislav Dmitrenko
51b8ce10ae Better performance in FloatingButtons function (#949) 2022-08-19 17:17:02 +01:00
Stanislav Dmitrenko
8c716962fb Different level of APK compression (#947)
* Different level of APK compression
- can reduce from 200mb to 50mb with level 5 of compression. Supports Intellij IDEA and command line gradle invocation
- by default, this feature is disabled. To enable create a file local.properties in `apps/android` and paste this line: `compression_level=5`
- level can be from 0 (no compression) to 9 (slowest and the must effective)
- automatically enables `extractNativeLibs` AndroidManifest's flag since it's required in this case. Feel free to find an alternative that works with compression of .so libs and without enabling this flag
- Windows is not suppored, of course. Only Unix-like OSes

* script corrections

* Missing JAVA_HOME in some environments

* Rename release apk made by IDEA to simplex.apk

* Enhancements

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-19 17:16:52 +01:00
Evgeny Poberezkin
fee2b247e9 android: update search api (#948) 2022-08-19 16:02:08 +01:00
Evgeny Poberezkin
992ba75306 update simplexmq 2022-08-19 15:32:55 +01:00
Evgeny Poberezkin
70168967a3 core: commands to set chat notification settings (#946)
* core: commands to set chat notification settings

* add API
2022-08-19 15:17:05 +01:00
JRoberts
3221c0abb5 docs: optional profile in groupInvitation and x.grp.acpt (incognito connections) (#944) 2022-08-18 15:17:18 +04:00
Stanislav Dmitrenko
d8049d4bfc Fixes service issues (#942)
* Fixes service issues
- no more crashes after start of a service with battery optimization enabled
- no more service restarts after the app exit with disabled private notifications

* Disable service restart even after reboot if the user didn't allow this

* [Experimental] Disabling logic of start up process from application process. The same is enabled in a service anyway
- every application process creation makes the service running even in situations when it's not needed. For example, RescheduleReceiver from WorkManager receives boot completed event and it triggers service start up

* Returned unneeded part of code. It may be useful (in theory) if user's device doesn't allow any services to be started (it's something that should never happen)

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-18 12:14:22 +01:00
Evgeny Poberezkin
b15d39eb4a android: include default values in JSON encoding (#943) 2022-08-18 09:18:15 +01:00
Evgeny Poberezkin
85e36ac12c update simplexmq (servers update) 2022-08-18 08:43:50 +01:00
JRoberts
5e67654249 core: incognito connections (#926) 2022-08-18 11:35:31 +04:00
Evgeny Poberezkin
404b7093b7 core: update simplexmq (split transaction to fix android crash) 2022-08-17 22:50:46 +01:00
Evgeny Poberezkin
cad1ad87a8 android: search (#940)
* Search in chat messages

* align api with core/swift

* EOLs

* DefaultTopAppBar changes

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2022-08-17 16:58:57 +01:00
Evgeny Poberezkin
fd27839442 ios: search in chat (#938)
* ios: search in chat

* update libraries and search API

* layout
2022-08-17 11:43:18 +01:00
Evgeny Poberezkin
ae6fae5ced core: update simplexmq (servers migration) 2022-08-16 21:45:03 +01:00
Evgeny Poberezkin
e9cddd6ca3 core: add search parameter name to /_get chat api (#939) 2022-08-16 19:56:21 +01:00
Evgeny Poberezkin
76bde53206 ios: scroll buttons and unread counts (#937)
* ios: scroll buttons and unread counts

* floating buttons for unread counts

* remove commented code

* remove prints
2022-08-16 13:13:29 +01:00
Stanislav Dmitrenko
0a2f7681d8 Swipe to reply feature (#936)
* Swipe to reply feature
- ability to reply by swiping on a message from right-to-left or left-to-right
- keyboard will be open automatically

* Only one direction for swipe to reply action
2022-08-16 13:08:15 +01:00
Evgeny Poberezkin
3776e1c29c ios: chat pagination (#910)
* ios: chat pagination

* pagination hack

* rotationEffect

* more rotation

* the least broken context menu

* custom contect menu

* add context item menus

* fix context menu preview size

* fix content menu targeted previews

* subclass context menu view

* remove UIView subclass

* move coordinator class inside view

* context menu and clicks work

* reverse model

* update item view based on viewId

* hide underlying swiftui item

* cover swiftui item with solid color

* remove overlay

* move hostview to async block

* background overlay

* remove async hostview

* clear chat items on back buttom

* update viewId on status changes
2022-08-15 21:07:11 +01:00
Evgeny Poberezkin
2e4ffb7fe9 ios: setting to use .onion hosts (#934) 2022-08-15 08:25:41 +01:00
Evgeny Poberezkin
954338658f core: update simplexmq (fix ntf server hosts) 2022-08-14 21:13:57 +01:00
Evgeny Poberezkin
7f78b08100 mobile: add host mode in NetCfg (#933) 2022-08-14 17:34:11 +01:00
Stanislav Dmitrenko
5d8d636adc Floating button with unread counter and go to bottom action (#929)
* Floating buttons with unread counters and go to bottom action

* Fixed marking read of long chats without preloaded messages

* Apply suggestions from code review

* Counters fix

* update button size/color

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-13 22:00:26 +01:00
Evgeny Poberezkin
aac80dacf7 core: host connection events 2022-08-13 14:18:12 +01:00
Evgeny Poberezkin
e43be1ad8b core: support multiple hostnames in server addresses (#930)
* core: support multiple hostnames in server addresses

* add onion hosts

* update simplexmq, fix test

* fix parsing servers with multiple hostnames
2022-08-13 11:53:53 +01:00
Stanislav Dmitrenko
57000fa3f0 Endless scrolling in a chat view (#925)
* Endless scrolling in a chat view
- scroll position when you open keyboard/change screen orientation will remain the same
- scrolling to top will show messages from history
- unread messages will be positioned at the top of the screen

* Marking chat read message-per-message

* Prevent changing scroll position on orientation change

* Adapted new code to the old code

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-12 20:16:33 +01:00
sh
db367b376b build-android: add bundle script (#931) 2022-08-12 11:30:51 +01:00
JRoberts
cc498572cd core: create indices on chat_items for faster pagination (#927) 2022-08-11 15:48:47 +04:00
Stanislav Dmitrenko
622ab549a3 Debug package suffix and ability to override Gradle variables (#920)
* Debug package suffix and ability to override Gradle variables
- now debug builds will have '.debug' suffix by default. It allows to have multiple app builds (debug and release) on the same device. If you don't need this, create a file local.properties and add `application_id_suffix=` into it
- now everyone can override some variables from top-level build.gradle file. For example, gradle_plugin_version, debuggable manifest attribute, and so on. Overriding Gradle plugin version is useful for those who uses Intellij IDEA with older Gradle plugin than in Android Studio

* Prevent socket name conflict from different packages

* Configurable app name for debug build. By default it's SimpleX Debug

* Changed defaults in build.gradle
2022-08-11 00:53:02 +01:00
JRoberts
f896c4453d mobile: update model on adding group member (#923) 2022-08-10 14:54:15 +04:00
JRoberts
38f65c82c3 core: send notification on XGrpMemFwd (#921) 2022-08-09 21:46:49 +04:00
JRoberts
22733f505d android: update group members in model (#919) 2022-08-09 19:50:29 +04:00
JRoberts
26a019d9d2 ios: update group members in model (#915) 2022-08-09 13:43:19 +04:00
JRoberts
7531791f1b core: chat search (#914) 2022-08-08 22:48:42 +04:00
JRoberts
cd28ba62a1 core: fix chat pagination filtering (#911) 2022-08-08 14:13:51 +04:00
Evgeny Poberezkin
32dff4e1a3 blog: v3.1, groups (#905)
* blog: v3.1, groups

* --amend

* add to blog TOC

* images

* update blog

* update image

* update image

* update blog

* update readme

* readme

* links to the protocol

* update heading
2022-08-08 10:05:43 +01:00
JRoberts
fd85026a0d docs: SimpleX Chat Protocol corrections (#909) 2022-08-08 09:49:40 +01:00
Evgeny Poberezkin
e3f63db5ab mobile: API for chat pagination (#908) 2022-08-07 19:23:33 +01:00
Evgeny Poberezkin
7f959103c1 docs: SimpleX Chat Protocol (#906)
* docs: SimpleX Chat Protocol

* chat message JTD schema, protocol draft

* update protocol, group diagram

* update heading

* add protocol reference to readme

* skip async group test
2022-08-07 16:43:09 +01:00
Evgeny Poberezkin
bd3d4467c7 3.1.0 2022-08-06 16:30:39 +01:00
Evgeny Poberezkin
5345199829 update readme image 2022-08-05 22:32:24 +01:00
Evgeny Poberezkin
481c4c0763 ios: version 3.1 (68) 2022-08-05 13:27:14 +01:00
Evgeny Poberezkin
bf2b3855b7 android: update version 3.1 (48) 2022-08-05 08:15:40 +01:00
sh
a254d5f050 build-android: specify commit (#904) 2022-08-05 08:14:32 +01:00
Evgeny Poberezkin
e8749debec ios: fix notification badge count (#903) 2022-08-04 22:25:52 +01:00
Evgeny Poberezkin
afbc7dd2c1 update f-droid description 2022-08-04 21:07:45 +01:00
Evgeny Poberezkin
7a00a3e324 core: remove logs, remove log for A_DUPLICATE error (#896) 2022-08-04 20:59:05 +01:00
Evgeny Poberezkin
03d9d86aba android: fix crash on invalid base64 image, show placeholder image instead (#902) 2022-08-04 20:32:01 +01:00
Evgeny Poberezkin
13e7925348 core: fully remove invited member (#901)
* core: fully remove invited member

* deleteMemberConnection
2022-08-04 18:39:31 +01:00
Evgeny Poberezkin
46319044f8 core: fix race condition in --execute option, closes #890 (#898) 2022-08-04 17:07:50 +01:00
JRoberts
8a7e320d12 ios: version 3.1 (67) 2022-08-04 19:54:30 +04:00
Evgeny Poberezkin
152ed96ac0 android: static vars for NetCfg (#900) 2022-08-04 16:23:59 +01:00
JRoberts
8dc7bea724 ios: advanced network settings translations (#899) 2022-08-04 19:20:00 +04:00
JRoberts
497cf86eb0 android: advanced network settings (#895) 2022-08-04 18:40:36 +04:00
Stanislav Dmitrenko
9508ea5c97 App icon chooser (#894)
* App icon chooser
- ability to choose an icon from a predefined list

* dark icons

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-04 14:16:35 +01:00
Evgeny Poberezkin
257133db3b ios: remove modal sheets before authentication (#897)
* ios: remove modal sheets before authentication

* line break

* add reference to source
2022-08-04 12:41:05 +01:00
Evgeny Poberezkin
c4bc88b49b Merge branch 'stable' 2022-08-04 12:05:57 +01:00
sh
80389ffe93 android: check nix hash (#893) 2022-08-04 11:20:58 +01:00
sh
e53540f43f android: remove cmake version pin from gradle (#889) 2022-08-04 11:20:37 +01:00
Evgeny Poberezkin
55adbb4692 core: clear group content on deletion, break transaction to prevent error on Android, more logs (#892)
* core: log group deletion

* clear group content, break transaction, add logs
2022-08-04 11:12:50 +01:00
Evgeny Poberezkin
91baf9f362 terminal: update active group when message is updated (#891)
* terminal: update active group when message is updated

* fix
2022-08-04 11:12:37 +01:00
sh
04b9243d7e android: change nix config logic (#888) 2022-08-04 09:36:36 +01:00
sh
b3d74933c2 build-android: fix git compatibility (#884)
* build-android: fix git compatibility

* move to scripts
2022-08-03 21:37:31 +01:00
sh
90ab6f34bf android: add fastlane metadata (#885)
* android: add fastlane metadata

* update fastlane info

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-03 21:04:27 +01:00
Stanislav Dmitrenko
57e7034b4d Update to Compose 1.2.0 beta2 (#874)
- fixes issue with multiple backspaces in a BasicTextField. Before that update text field stops deleting characters after long press on the backspace key
2022-08-03 18:46:38 +01:00
Stanislav Dmitrenko
8455cca9c3 Button in notification that routes to settings for that specific notification channel. Android O+ (#875) 2022-08-03 18:10:36 +01:00
Evgeny Poberezkin
9fdc2a4631 ios: remove option to not show pending contact connections (#883) 2022-08-03 18:02:59 +01:00
Evgeny Poberezkin
a5cdbc90f8 ios: alternative app icon (#881) 2022-08-03 17:46:05 +01:00
Evgeny Poberezkin
a5972c7de1 ios: register group defaults to correctly read network settings in NSE (#882) 2022-08-03 17:39:01 +01:00
Evgeny Poberezkin
c74a4fcbca update logos on SimpleX info page for dark mode (#880) 2022-08-03 15:17:42 +01:00
Stanislav Dmitrenko
4c6ee95eb7 Removed gesture interception while long clicking on a chat bubble (#871)
* Removed gesture interception while long clicking on a chat bubble with a link
- allowed to skip motion event consuming based on touch offset
- long clicking on a link copies it to a clipboard

* Long click on a link shows menu instead of copying to clipboard

* EOLs

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-03 14:33:19 +01:00
Evgeny Poberezkin
9e210256d2 core: add delete group logs (#879) 2022-08-03 16:56:35 +04:00
sh
d67f86ada5 install: add android build script (#877) 2022-08-03 13:52:16 +01:00
JRoberts
7a03f87822 mobile: update logo (#876)
* ios: logo

* logo

* bigger logo
2022-08-03 13:30:29 +01:00
JRoberts
d6a4a245dc update simplexmq (reconnect on network config change) (#878) 2022-08-03 15:49:31 +04:00
Evgeny Poberezkin
0fe7e64989 ios: advanced network settings (#873)
* ios: advanced network settings

* save network config

* update network settins, set in NSE

* update UI, update simplexmq

* show advanced network settings only with dev tools on
2022-08-03 15:36:51 +04:00
Stanislav Dmitrenko
e39f9bc251 QRCodeScanner will close camera on back press (#872) 2022-08-03 08:47:51 +01:00
JRoberts
cbd7882ff4 ios: group ui translations; android: empty lists ui fixes (#870) 2022-08-03 11:40:36 +04:00
Evgeny Poberezkin
4ad1abcbfa core: support passing all network configuration to the agent (#868)
* core: support passing all network configuration to the agent

* update simplexmq
2022-08-02 15:36:12 +01:00
JRoberts
a36c367b81 mobile: filter out members in statuses left and removed (#869) 2022-08-02 18:07:40 +04:00
JRoberts
a14859d8c0 mobile: developer tools (#867) 2022-08-02 17:00:12 +04:00
JRoberts
9e23150938 ios: fix Servers section flickering on info view; android: button text (#866) 2022-08-02 14:48:31 +04:00
JRoberts
35eeac194e core: split group deletion into two transactions to prevent crashes on android (#865) 2022-08-02 14:10:03 +04:00
Evgeny Poberezkin
0b4a6cf9eb readme: add monero wallet for donations (#863) 2022-08-01 21:12:06 +01:00
JRoberts
2422f36d61 android: version 3.1 (47) 2022-08-01 20:54:22 +04:00
JRoberts
60117d0853 ios: version 3.1 (66) 2022-08-01 18:22:37 +04:00
JRoberts
95757ed562 android: edit group profile (#862) 2022-08-01 16:32:42 +04:00
Evgeny Poberezkin
cc0a74fae4 mobile: show errors when joining group (#861)
* mobile: show errors when joining group

* correct titles

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* improvements

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-08-01 08:34:07 +01:00
Evgeny Poberezkin
ce91dcde7f android: save SOCKS setting to preference and enable on start (#848)
* android: save SOCKS setting to preference and enable on start

* use socks proxy preference
2022-07-31 20:46:09 +01:00
Evgeny Poberezkin
999923bcf9 core: allow creating groups with the same display name; mobile: update group status when group deleted by another member or user removed (#859) 2022-07-31 18:54:49 +01:00
JRoberts
30c345933b android: create group view (#855)
* android: create group view wip

* wip

* android: add group view image wip (#856)

* new chat sheet layout

* alternative layout for new chat sheet

* simpler layout for new chat sheet

* fix add image sheet

* fix creating group

* add members when creating a group

* update text

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-31 16:49:32 +01:00
Evgeny Poberezkin
1b8c55a0a3 ios: add group members when group is created (#857)
* ios: add group members when group is created

* refactor

* more refactor
2022-07-30 18:46:10 +01:00
JRoberts
4f4935256c ios: move GroupChatInfoView (#854) 2022-07-30 16:59:06 +04:00
JRoberts
1dd7520bbd mobile: refine allowed group actions; inactive group indicator (#852) 2022-07-30 16:49:34 +04:00
Evgeny Poberezkin
de0f231c60 ios: edit group profile (#853) 2022-07-30 16:03:44 +04:00
Evgeny Poberezkin
0c58adff08 core: editing group profiles (no conflict resolution) (#851)
* core: editing group profiles with conflict resolution

* update group profiles

* fix group update

* add test, add group profile to chat items, update terminal output

* Update apps/android/.idea/gradle.xml
2022-07-29 19:04:32 +01:00
JRoberts
e87c78e997 android: groups ui (#850) 2022-07-29 20:11:00 +04:00
Evgeny Poberezkin
ee6f6462cf ios: create group with profile image (#849)
* ios: create group with profile image

* update libs
2022-07-28 14:49:36 +04:00
Evgeny Poberezkin
7b9164f95a core: allow getting and setting network config when chat is not started (#847) 2022-07-28 11:12:23 +01:00
Evgeny Poberezkin
4a931bc145 ios: only show notification on received messages, do not remove non-current group members from contacts that can be added to the group (#846) 2022-07-28 10:11:16 +01:00
Evgeny Poberezkin
bf4072b365 trigger build 2022-07-28 08:39:19 +01:00
Evgeny Poberezkin
658cc1af56 update readme 2022-07-27 15:07:46 +01:00
Evgeny Poberezkin
68bc572800 trigger build 2022-07-27 14:40:40 +01:00
Evgeny Poberezkin
2286752fe0 core: create group with JSON profile, including image (#845) 2022-07-27 12:15:09 +01:00
JRoberts
9864533dae ios: update chat info view (#844) 2022-07-27 13:40:26 +04:00
JRoberts
aa7e377bce ios: groups miscellaneous (#843)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-27 11:16:07 +04:00
JRoberts
a4aaf36774 ios: group & group member info views (#841)
* ios: group member wip

* wip

* wip

* wip

* wip

* refactor alerts

* .navigationBarHidden(true)

* await MainActor.run

* refactor

* fix

* update layout

* tex

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-26 12:33:10 +04:00
JRoberts
608030dcaf ios: add member ui wip (#834)
* ios: add member ui wip

* AddGroupMembersView

* clean up

* cleanup

* change new chat button

* update adding members

* add group name and image to adding members view

* adjust layout

* layout

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-26 10:55:58 +04:00
Evgeny Poberezkin
6069108bb9 android: UI to access servers via SOCKS proxy (#840)
* android: UI to access servers via SOCKS proxy

* UI to connect via socks

* add server hosts to contact info

* ios: types for network/info commands
2022-07-26 07:29:48 +01:00
Evgeny Poberezkin
e7f3dc3f41 terminal: help for /i and /net commands (#842)
* terminal: help for /i and /net commands

* fix servers output

* update message

* EOL
2022-07-26 07:29:28 +01:00
Evgeny Poberezkin
f150932e44 core: commands to get/set network configuration (#839) 2022-07-25 17:04:27 +04:00
Evgeny Poberezkin
7dcde32680 update readme 2022-07-24 08:34:15 +01:00
Evgeny Poberezkin
552397d938 fix install.sh script 2022-07-23 22:42:07 +01:00
Evgeny Poberezkin
cfa4b44d1f update install.sh 2022-07-23 22:14:05 +01:00
Evgeny Poberezkin
9fcd127c48 update readme link 2022-07-23 21:15:51 +01:00
Evgeny Poberezkin
7c01ad7d4f blog: v3.1-beta release (#838)
* blog: v3.1-beta release

* corrections

* add images

* update post

* update TOC, readme
2022-07-23 21:13:41 +01:00
Evgeny Poberezkin
13b236f754 allow passing version to install.sh (#837)
* allow passing version to install.sh

* add echo
2022-07-23 17:02:05 +01:00
Evgeny Poberezkin
589f560dd6 3.1.0 2022-07-23 14:49:45 +01:00
Evgeny Poberezkin
4fd13c637c core: access messaging servers via SOCKS5 proxy (#835)
* core: access messaging servers via SOCKS5 proxy

* update option info

* update simplexmq
2022-07-23 14:49:04 +01:00
JRoberts
88d1d3448d android: version 3.1 (46) 2022-07-22 20:21:56 +04:00
JRoberts
852f4f25c4 ios: version 3.1 (65) 2022-07-22 20:18:16 +04:00
JRoberts
0da4651c3b android: improve group invitations design (2) (#833)
* android: improve group invitations design (2)

* group item layout

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-22 17:45:05 +04:00
Evgeny Poberezkin
10659c7c82 readme: technical details (#831)
* readme: technical details

* update readme

* corrections

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-07-22 14:38:42 +01:00
Evgeny Poberezkin
ce2e1b9eb9 android: fix contact spinners race condition (#832)
* android: fix contact spinners race condition

* always update

* remove log
2022-07-22 12:56:17 +01:00
JRoberts
b232226590 core: read group chat items in separate queries 2022-07-22 15:48:04 +04:00
JRoberts
348ff612e9 android: improve group invitations design (#830) 2022-07-22 12:28:02 +04:00
Evgeny Poberezkin
8469f921b7 ios: notification actions for calls and contact requests with NSE (#829)
* ios: notification actions for calls and contact requests with NSE

* update contact request if already in the list
2022-07-22 08:10:37 +01:00
Evgeny Poberezkin
e538a9e057 update simplexmq (fix GET for contact requests) 2022-07-21 19:54:53 +01:00
JRoberts
88c1f439c1 ios: version 3.1 (64) 2022-07-21 19:23:06 +04:00
JRoberts
3845904443 android: version 3.1 (45) 2022-07-21 19:16:45 +04:00
JRoberts
6542de619d ios: version 3.1 (63) 2022-07-21 18:56:18 +04:00
JRoberts
6f87a3bdb1 ios: groups ui translations (#828) 2022-07-21 18:16:04 +04:00
Evgeny Poberezkin
26e51a07c5 ios: improve concurrency of NSE, process multiple messages (#827) 2022-07-21 17:26:46 +04:00
JRoberts
4e3d83fe0c mobile: auxiliary group items (#826) 2022-07-21 17:01:13 +04:00
JRoberts
de9c112725 core: correct group event 2022-07-21 11:01:04 +04:00
JRoberts
a509e85195 core: fix group event chat items encoding (#825) 2022-07-20 20:59:09 +04:00
Evgeny Poberezkin
3c03c96a53 core: show contact and group member servers (#824)
* core: show contact and group member servers (WIP)

* contact and member information

* update simplexmq
2022-07-20 14:57:16 +01:00
JRoberts
5e71deaa3d core: auxiliary group chat items (#821) 2022-07-20 16:56:55 +04:00
Evgeny Poberezkin
1cb348c102 core: refactor parser (#823) 2022-07-20 09:36:43 +01:00
Evgeny Poberezkin
252897d0ff ios: notification badge count (#822) 2022-07-20 08:58:53 +01:00
Evgeny Poberezkin
add82d73fa android: fix notification service by partially reverting #790 and #792 (#820) 2022-07-19 16:29:15 +01:00
Evgeny Poberezkin
048387ce88 update core 2022-07-19 16:25:30 +01:00
JRoberts
931a5d928c ios: fix chat info toolbar layout 2022-07-19 18:31:08 +04:00
JRoberts
0e84e131cd mobile: leave & delete group; ios: fix group preview interaction (#819) 2022-07-19 18:21:15 +04:00
Evgeny Poberezkin
cf1f921aed update tls to 1.6.0 and change nix config (#780)
* update tls to 1.6.0 and change nix config

Revert "revert tls to 1.5.7 and nix config changes (#746)"

This reverts commit 976b1c919f.

* nix: update hackage index

* update hackage index
2022-07-19 13:34:03 +01:00
Evgeny Poberezkin
efa79bc1f9 update simplexmq 2022-07-19 09:32:00 +01:00
Evgeny Poberezkin
c7f9262d0e ios: update core 2022-07-18 21:06:06 +01:00
JRoberts
53f3ee1f50 mobile: group invitations ui (#816)
* ios: group invitations ui

* fix

* memberActive (crashes)

* adjustments

* android ui

* android - memberActive

* update group invitation item layout

* update texts

* typo

* update layout

* do not add contacts added via groups

* filter contacts by conn_level

* turn off address sanitizer

* fix layout

* android: filter on update chat

* android adjustments

* divider fix

* android chat previews

* ios previews

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-18 21:58:32 +04:00
Evgeny Poberezkin
54f8dd8a2e update simplexmq (batched resubscriptions) 2022-07-18 08:26:18 +01:00
Evgeny Poberezkin
e28bd907a1 ios: update libary 2022-07-17 18:54:09 +01:00
Evgeny Poberezkin
13fbb66a21 core: use batched subscriptions (#818)
* core: use batched subscriptions

* update simplexmq

* remove comments

* clean up

* refactor

* remove todo

* revert change

* revert change

* remove comment

* add delay to the async group test

* add more delay in test
2022-07-17 15:51:17 +01:00
Evgeny Poberezkin
e8da13c7ca Merge branch 'stable' 2022-07-16 09:18:15 +01:00
Evgeny Poberezkin
66a8267b11 android: version 3.0.1 (44) 2022-07-16 09:13:05 +01:00
JRoberts
fa703d3a1d android: scale bitmap down when loading, closes #805 (#812) 2022-07-16 08:54:25 +01:00
Evgeny Poberezkin
20e3acc7c2 Merge branch 'stable' 2022-07-16 08:51:46 +01:00
Evgeny Poberezkin
3fc64f0f40 ios: v3.0.1 (62) 2022-07-16 08:49:15 +01:00
Evgeny Poberezkin
00ca111be3 ios: version 3.0.1 (61) 2022-07-16 08:41:53 +01:00
JRoberts
eb89eec5b5 core: backend for group invitations UI (status, db, updates) (#815) 2022-07-15 17:49:29 +04:00
JRoberts
8e15460bdc core: use decodeLatin1 in ciGroupInvitationToText 2022-07-14 22:09:20 +04:00
JRoberts
db87984dda core: group invitation chat item (#814) 2022-07-14 22:04:23 +04:00
JRoberts
414b174e32 ios: groups ui wip (#809) 2022-07-14 16:40:32 +04:00
JRoberts
01eff43585 mobile: group types (#808) 2022-07-14 15:55:28 +04:00
JRoberts
9dd5a00a45 android: scale bitmap down when loading, closes #805 (#812) 2022-07-14 12:13:22 +04:00
Evgeny Poberezkin
a7445afbf7 Merge branch 'stable' 2022-07-13 20:12:19 +01:00
Evgeny Poberezkin
2995ecd53d update readme 2022-07-13 20:12:04 +01:00
Evgeny Poberezkin
a6cd3843de readme: fix link 2022-07-12 19:06:05 +01:00
Evgeny Poberezkin
e0be5fda90 readme: fix link 2022-07-12 19:05:26 +01:00
Evgeny Poberezkin
5c394f15a9 ios: version 3.0.1 (61) 2022-07-12 16:36:34 +01:00
JRoberts
ad5edeba6c core: groups api (#806) 2022-07-12 19:20:56 +04:00
JRoberts
494de9bc43 core: test async group connections (#804) 2022-07-12 14:59:53 +04:00
Evgeny Poberezkin
185be526ca ios: fix notification category 2022-07-12 11:11:53 +01:00
Evgeny Poberezkin
fec03c74f4 Merge pull request #803 from simplex-chat/master
Merge master to stable
2022-07-11 14:25:40 +01:00
Evgeny Poberezkin
33646f030e update readme (#798) 2022-07-11 14:22:46 +01:00
JRoberts
df04c4a1ea ios: remove failed authentication alert (#802) 2022-07-11 16:38:21 +04:00
Evgeny Poberezkin
d1bd7fbf4c ios: version 3.0 (60) 2022-07-10 17:18:04 +01:00
Evgeny Poberezkin
e070492d02 Merge branch 'stable' 2022-07-10 17:06:39 +01:00
Evgeny Poberezkin
aef113c8cd ios: version 3.0 (59) 2022-07-10 17:06:19 +01:00
Evgeny Poberezkin
f16d8842b2 iOS: accept images in NSE if enabled, reorder chats when coming from background (#800)
* ios: automatically accept images in NSE, if enabled in settings

* remove unnecessary TODOs

* reorder chat when coming from background
2022-07-10 14:28:00 +01:00
a1lu
4408495cfb Fix typo in blog (#799) 2022-07-10 11:00:20 +01:00
Evgeny Poberezkin
920014a06b update blog 2022-07-10 08:34:16 +01:00
Evgeny Poberezkin
fd9574b5aa android: version 3.0 (43) 2022-07-09 19:23:30 +01:00
Evgeny Poberezkin
890e6abf01 update blog 2022-07-09 16:32:05 +01:00
Evgeny Poberezkin
dc6ce8a2f5 Merge pull request #797 from simplex-chat/master
Merge master to stable
2022-07-09 16:03:58 +01:00
Evgeny Poberezkin
9278234540 android: v3.0 (42) 2022-07-09 16:00:07 +01:00
Evgeny Poberezkin
cf2424f319 android: fix incorrect states in database view (#796) 2022-07-09 15:18:54 +01:00
Evgeny Poberezkin
0797798136 update library, v3.0 (58) 2022-07-09 15:16:07 +01:00
Evgeny Poberezkin
013ee86899 Merge pull request #795 from simplex-chat/master
Merge master to stable
2022-07-09 13:53:13 +01:00
Evgeny Poberezkin
36f97b2ea9 v3.0.0 2022-07-09 13:04:18 +01:00
Evgeny Poberezkin
bf390dabd4 mobile: update version/build v3.0 (ios: 57, android: 41) 2022-07-09 10:32:24 +01:00
Evgeny Poberezkin
c73a28e7de blog: v3 announcement (#791)
* blog: v3 announcement draft

* update blog

* images, corrections

* update images

* update images

* update

* update blog
2022-07-09 10:19:03 +01:00
Evgeny Poberezkin
06cb564eae ios: ensure that device token is registered once (#794) 2022-07-09 09:29:56 +01:00
Evgeny Poberezkin
b1c732f3cc ios: translations (#793) 2022-07-08 22:42:38 +01:00
JRoberts
b58c880d4c android: version 3.0 (40) 2022-07-08 20:03:21 +04:00
JRoberts
ba9a6f3ab6 android: database export & import (#787)
* android: database export & import wip

* fix import

* import, delete

* disabled during in progress

* footer

* ChatArchiveView

* refactor

* disable settings

* more chat running interactions

* more chat running interactions

* fixes

* rename

* fixes

* fix

* change ts format

* chatWasStopped model variable

* remove logs

* reset chatWasStopped

* chat was stopped preference

* fixes

* unconditional chatRunning

* remove intermediary view

* refactor

* mkInstantPreference

* refactor

* refactor

* refactor

* DatabaseItem

* remove todos

* refactor

* refactor

* refactor

* translations

* translations

* refactor import

* refactor import
2022-07-08 17:16:28 +04:00
Evgeny Poberezkin
7ad173c5dc android: version 3.0 (39) 2022-07-08 14:03:24 +01:00
Evgeny Poberezkin
fdad58b0ee android: update service (#792) 2022-07-07 23:00:42 +01:00
Evgeny Poberezkin
2b7de2a7a6 android: only start service when app is in the background, change service icon (#790)
* android: only start service when app is in the background, change service icon

* update version v3.0 (38)

* set flag
2022-07-07 19:05:03 +01:00
Evgeny Poberezkin
2ae3748489 android: update version v3.0 (37) 2022-07-06 22:10:14 +01:00
Evgeny Poberezkin
36aae92c55 android: toggle speaker in audio call (#789) 2022-07-06 21:33:32 +01:00
JRoberts
e290309cd1 core: add optional parentTempDirectory to ArchiveConfig (#788)
* core: add optional parentTempDirectory to ArchiveConfig

* swift

* brackets

* Update src/Simplex/Chat/Archive.hs

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* logs

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-06 21:45:29 +04:00
Evgeny Poberezkin
d3dd6e5d1c fix android terminal scroll (#786) 2022-07-06 17:20:33 +01:00
Evgeny Poberezkin
51ea478acb ios: update version v3.0 (56) 2022-07-06 16:53:08 +01:00
Evgeny Poberezkin
8966a0e22f ios: NSE waits up to 10 sec until app is suspended (#785)
* ios: NSE waits up to 10 sec until app is suspended

* show default notification if the app is no longer suspending/suspended
2022-07-06 16:06:35 +01:00
Evgeny Poberezkin
6eb76315eb ios: NSE debug logging 2022-07-06 15:22:01 +01:00
Evgeny Poberezkin
eb35d81aba ios: make qr code pages scrollable (for small screens) (#784) 2022-07-06 15:01:41 +01:00
Evgeny Poberezkin
fb54841d76 update simplexmq (suspendAgent without delay) 2022-07-06 14:17:50 +01:00
Evgeny Poberezkin
95f518a582 ios: stopped state for DB management, suspend quicker/instantly on app termination (#783)
* ios: stopped state for DB management, suspend quicker/instantly on app termination

* update terminateChat
2022-07-06 14:07:27 +01:00
Evgeny Poberezkin
6b89eb872b mobile: update webrtc ICE servers (#782)
* mobile: update webrtc ICE servers

* update webrtc package version
2022-07-06 11:52:25 +01:00
Evgeny Poberezkin
ab6301c3e9 ios: notification preview mode, show connection entity notification (#781)
* ios: notification preview mode, show connection entity notification

* prepare connection entity notification as best attempt
2022-07-06 11:52:10 +01:00
JRoberts
36dc66d5d5 core: use NTF scheme for notification server address (Terminal.hs) 2022-07-06 11:48:38 +04:00
Evgeny Poberezkin
111acb0813 core: use NTF scheme for notification server address (#774)
* core: use NTF scheme for notification server address

* simplexmq
2022-07-06 08:46:04 +01:00
JRoberts
65fae747c3 android: make calls non expirmental feature, hide experimental features (#779) 2022-07-05 15:27:42 +04:00
JRoberts
935d5bfdd6 android: use RcvCallInvitation type in CallInvitation event (#778) 2022-07-05 15:25:29 +04:00
JRoberts
f7a27ff91b ios: make calls non expirmental feature, hide experimental features (#777) 2022-07-05 15:24:51 +04:00
JRoberts
ab848e8c13 ios: refresh call invitations and report call on start and activation; core: restore calls on activation (#776) 2022-07-05 15:15:15 +04:00
JRoberts
8c307c4675 Merge pull request #753 from simplex-chat/ios-notifications
iOS notifications
2022-07-04 16:27:53 +04:00
Evgeny Poberezkin
c323d6c61f ios: update version v3.0 (55) 2022-07-04 11:53:15 +01:00
Evgeny Poberezkin
03ab4612a5 ios: text correction 2022-07-04 11:46:56 +01:00
Evgeny Poberezkin
687e3be9ac iOS: update call invitations when exiting background (#771)
* core: communicate call invitations state between NSE and app via db

* enable tests

* delete calls, encoding

* load calls on start

* remove line

* remove table alias

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-07-04 14:15:25 +04:00
Evgeny Poberezkin
2c121b5731 ios: choose notifications mode during onboarding and after DB migration (#773) 2022-07-03 19:53:07 +01:00
Evgeny Poberezkin
c619092464 update simplexmq 2022-07-03 13:07:42 +01:00
Evgeny Poberezkin
e4c6d210c6 ios: fix updating chats when exiting background (#772) 2022-07-02 17:18:45 +01:00
Evgeny Poberezkin
60642317d0 ios: update version 3.0 (54) 2022-07-02 15:35:51 +01:00
JRoberts
01b9c16f1a update simplexmq (notify on errors) 2022-07-02 13:35:57 +04:00
Evgeny Poberezkin
15a17f3c13 core: subscribe to all connections concurrently (#770) 2022-07-02 10:13:06 +01:00
JRoberts
3450420b80 core: pass ERR responses to view (#768) 2022-07-02 12:35:04 +04:00
Evgeny Poberezkin
29c6d51e6a ios: fix background refresh (#769)
* ios: fix background refresh

* change app inactive check
2022-07-02 08:50:25 +01:00
Evgeny Poberezkin
a8ba4ede82 update simplexmq 2022-07-01 23:12:45 +01:00
Evgeny Poberezkin
f7f3f82090 ios: fix migration, refreshing chat list; disable periodic notifications (#767)
* ios: fix migration, disable refreshing chat list and periodic notifications

* fix refreshing chats when exiting background

* remove unused model property
2022-07-01 22:45:58 +01:00
Evgeny Poberezkin
a8a0f2db03 ios: disable notifications if not migrated (#766)
* ios: disable notifications if not migrated

* refactor, update text
2022-07-01 20:33:20 +01:00
JRoberts
e68cc23828 update simplexmq (v3.0.0-beta.2) (#765) 2022-07-01 16:12:29 +04:00
Evgeny Poberezkin
1e63eb3752 ios: update the list of chats when exiting background (#764) 2022-07-01 10:38:12 +01:00
Evgeny Poberezkin
96866c7a5d core: handle all DB errors (#763) 2022-07-01 10:37:26 +01:00
Evgeny Poberezkin
815981487b ios: register notification token/mode on app start (#761)
* ios: register notification token/mode on app start

* refactor

* register token on start

* update model on main thread
2022-07-01 09:49:30 +01:00
JRoberts
b2c455c301 update simplexmq (recover) (#762) 2022-06-30 20:35:28 +04:00
Evgeny Poberezkin
ca366d0b47 core: fix APINtfGetToken parsing (#760) 2022-06-30 08:16:22 +01:00
JRoberts
904945a67d fix simplexmq range 2022-06-29 17:35:59 +04:00
JRoberts
c8a85f651d update simplexmq (v3.0.0-beta.0) 2022-06-29 17:23:42 +04:00
Evgeny Poberezkin
13603f009b update simplexmq (fixes v2 connecting to v1 contact link) 2022-06-29 09:04:53 +01:00
Evgeny Poberezkin
043005d186 update simplexmq 2022-06-28 20:14:39 +01:00
Evgeny Poberezkin
785fab1653 ios: remove interval notifications method (#759) 2022-06-28 19:39:00 +01:00
Evgeny Poberezkin
7226e5d37a ios: notifications UI (#758)
* ios: notifications UI

* Apply suggestions from code review

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-06-28 19:03:39 +01:00
JRoberts
e8c9f6d5ab core: use domain name in ntf server address (#757) 2022-06-28 16:50:40 +04:00
Evgeny Poberezkin
54126eba6b APNS push environments (#756) 2022-06-27 23:03:27 +01:00
Evgeny Poberezkin
41c9742b0d core: auto-reply message for user contact addresses (#755)
* core: auto-reply message for user contact addresses

* terminal: show auto accept status and message

* test
2022-06-27 19:41:25 +01:00
Evgeny Poberezkin
6d25991417 ios: process notifications, suspend app, notifications settings UI (#754) 2022-06-27 10:28:30 +01:00
Evgeny Poberezkin
463f644bce core: change API to suspend agent (#752) 2022-06-26 15:04:44 +01:00
Evgeny Poberezkin
5367ffe418 core: update api to get/register tokens (#751) 2022-06-25 17:02:16 +01:00
Evgeny Poberezkin
313bc65457 core: start NSE without subscriptions, update simplexmq (fix agent phase) (#750) 2022-06-25 11:49:46 +01:00
Evgeny Poberezkin
4e979aee7e core: update simplexmq 2022-06-24 15:28:38 +01:00
Evgeny Poberezkin
6a2f2a512f ios: UI to export/import/delete chat database (#743)
* ios: UI to export/import/delete chat database

* move files

* ui for database migration

* migration screen layout

* ios: export archive and delete chat database

* import archive

* refactor, update texts

* database migration (almost works)

* fix missing import

* delete legacy database

* update migration errors
2022-06-24 13:52:20 +01:00
Evgeny Poberezkin
4d9e446489 core: set files folder without user (to allow archive import) (#748) 2022-06-23 21:20:56 +01:00
Evgeny Poberezkin
8d93f228b3 update simplexmq 2022-06-21 19:31:20 +01:00
Evgeny Poberezkin
af7d7e8303 Merge branch 'master' into ios-notifications 2022-06-21 19:30:11 +01:00
Evgeny Poberezkin
976b1c919f revert tls to 1.5.7 and nix config changes (#746)
* Revert "nix: update nix for new tls version (#744)"

This reverts commit 7df8c23c81.

* Revert "update flake.nix"

This reverts commit 4b263510ee.

* Revert "update tls"

This reverts commit 2e34ae3b1c.

* update simplexmq
2022-06-21 19:27:30 +01:00
Evgeny Poberezkin
29eafa9a74 update ntf server 2022-06-21 11:38:36 +01:00
Evgeny Poberezkin
7723e4ca7a core: allow starting chat without making SMP subscriptions (to use GET in NSE) (#745) 2022-06-21 11:25:12 +01:00
Evgeny Poberezkin
051726702b Merge branch 'master' into ios-notifications 2022-06-20 12:35:37 +01:00
Evgeny Poberezkin
7df8c23c81 nix: update nix for new tls version (#744) 2022-06-20 12:34:41 +01:00
Evgeny Poberezkin
a362fc734e Merge branch 'master' into ios-notifications 2022-06-19 21:49:08 +01:00
Evgeny Poberezkin
4b263510ee update flake.nix 2022-06-19 21:48:33 +01:00
Evgeny Poberezkin
291096d87f ios: receive message in NSE (#742) 2022-06-19 19:49:39 +01:00
Evgeny Poberezkin
c5c65f813b Merge branch 'master' into ios-notifications 2022-06-19 19:45:13 +01:00
Evgeny Poberezkin
2e34ae3b1c update tls 2022-06-19 19:44:57 +01:00
Evgeny Poberezkin
8432399458 update simplexmq 2022-06-19 19:26:47 +01:00
Evgeny Poberezkin
59ad220d93 Merge branch 'master' into ios-notifications 2022-06-19 19:24:48 +01:00
Evgeny Poberezkin
65369aa47b update simplexmq 2022-06-19 19:22:54 +01:00
Evgeny Poberezkin
60e9ed9476 core: api to get notification messages and set app phase (#741)
* core: api to get notification messages and set app phase

* update simplexmq

* update simplexmq
2022-06-19 14:44:13 +01:00
Evgeny Poberezkin
4bf5125c51 core: support combining store functions in one transaction (#740)
* refactor store functions (WIP - does not compile yet)

* update chat

* update simplexmq
2022-06-18 20:06:13 +01:00
Evgeny Poberezkin
b2a523c3fe core: fix dependencies, update nix (#739)
* core: fix dependencies, update nix

* update
2022-06-16 20:55:45 +01:00
Evgeny Poberezkin
2ae621792e update simplexmq 2022-06-16 20:04:21 +01:00
Evgeny Poberezkin
c62d99ab97 core: remove connection pool (#738)
* core: remove connection pool

* remove local ref from cabal.project

* update simplexmq

* log test

* fix test
2022-06-16 20:00:51 +01:00
Evgeny Poberezkin
1f1ed3f3dd core: remove dependency on zip algorithms bzip2/zstd (#737)
* core: remove dependency on zip algorithms bzip2/zstd

* enable test log

* disable test log
2022-06-12 11:28:30 +01:00
Evgeny Poberezkin
6f195c4167 core add chat_recv_msg_wait and chat_parse_markdown to published API (#736) 2022-06-11 11:52:55 +01:00
Evgeny Poberezkin
235fb8dc0c remove missing libs 2022-06-09 15:18:12 +01:00
Evgeny Poberezkin
44a9ea2102 Merge branch 'master' into ios-notifications 2022-06-09 15:16:21 +01:00
Evgeny Poberezkin
121541759b ios: update library 2022-06-09 14:52:55 +01:00
Evgeny Poberezkin
716a941dc6 core: use duplex handshake (agent v2) (#735)
* core: use duplex handshake (agent v2)

* version test matrix

* update simplexmq
2022-06-09 14:52:12 +01:00
Evgeny Poberezkin
16bd9ccc4f core: send SMP notification msg flag based on chat message (#733)
* core: send SMP notification msg flag based on chat message

* update simplexmq

* remove unnecessary condition

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-06-07 14:14:54 +01:00
Evgeny Poberezkin
33e702d453 Merge branch 'master' into ios-notifications 2022-06-06 22:04:05 +01:00
Milton
883bf768af fix typo (#732)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-06-06 16:24:33 +01:00
Evgeny Poberezkin
f341e54128 Export & import storage archive (#726)
* core: import and export of chat archive

* export chat archive

* import archive, support starting chat after it is stopped

* test for maintenance mode

* test/fix archive with files

* prevent starting chat after chat database was deleted or imported

* update simplexmq
2022-06-06 16:23:47 +01:00
Evgeny Poberezkin
b3f4645011 Merge pull request #730 from simplex-chat/master
docs: contents, faq (#729)
2022-06-04 16:35:52 +01:00
Evgeny Poberezkin
99bd3f6133 docs: contents, faq (#729)
* docs: contents, faq

* add app links to the bottom
2022-06-04 16:33:58 +01:00
Evgeny Poberezkin
7590502f29 Merge branch 'master' into ios-notifications 2022-06-04 15:08:31 +01:00
Evgeny Poberezkin
f770a8396e Merge pull request #727 from simplex-chat/master
merge v2.2.1 to stable
2022-06-04 15:06:04 +01:00
Evgeny Poberezkin
b0f3d59cb0 blog: v2.2 release (#728)
* blog: v2.2 release

* update readme

* update roadmap
2022-06-04 14:37:41 +01:00
Evgeny Poberezkin
cab5bc2daf Merge branch 'master' into ios-notifications 2022-06-03 13:40:03 +01:00
JRoberts
935c3987b3 update version v2.2.1 (53) 2022-06-03 16:18:34 +04:00
Evgeny Poberezkin
084d1d09a5 ios: fix closing chat info (#725) 2022-06-03 16:05:34 +04:00
Evgeny Poberezkin
47ec486201 update version v2.2.1 (52) 2022-06-03 12:46:11 +01:00
Evgeny Poberezkin
72103949a7 ios: fix purple warning on auth failure (#724)
* ios: fix purple warning on auth failure

* avoid showing chats

* avoid flicker

* fix exit

* bg task

* rename function

* remove bg task
2022-06-03 12:24:50 +01:00
JRoberts
3b708105a4 ios: fix modal views not closing (#723) 2022-06-03 13:19:41 +04:00
Evgeny Poberezkin
800efb3a34 ios: fix authentication (#722)
* ios: fix authentication

* Update apps/ios/Shared/ContentView.swift

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* remove doAuthenticate = false

* remove lock button

* moare fixos

* whitespace

* and more

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-06-03 09:16:07 +01:00
Evgeny Poberezkin
949fb17406 ios: mach messages to coordinate database acceess between app & NSE (#717) 2022-06-02 13:16:22 +01:00
Evgeny Poberezkin
b435c0145f Merge branch 'master' into ios-notifications 2022-06-02 12:39:51 +01:00
Evgeny Poberezkin
87c0c9de91 ios: update build to 51 2022-06-02 12:37:27 +01:00
Evgeny Poberezkin
cd1af400bb ios: remove callkit (#720)
* ios: remove callkit

* remove CallKit import
2022-06-02 12:10:41 +01:00
JRoberts
e1e161539d Merge pull request #718 from simplex-chat/master (version 2.2.0) 2022-06-01 19:11:01 +04:00
JRoberts
4db7e88ed8 terminal: version 2.2.0 2022-06-01 18:58:06 +04:00
JRoberts
82a4a8c6f8 mobile: update version 2.2 (ios - 50, android - 36) 2022-05-31 21:30:20 +04:00
JRoberts
15ddefe86b mobile: close modal views (#715) 2022-05-31 20:55:19 +04:00
Evgeny Poberezkin
fa844c48e9 ios: SimpleXChat framework to be shared by app/NSE (#714)
* ios: SimpleXChat framework to be shared by app/NSE

* remove bridging headers from pp/NSE

* embed & sign
2022-05-31 07:55:13 +01:00
JRoberts
7e96da95f9 ios: enable notifications (#713) 2022-05-30 16:15:17 +04:00
Evgeny Poberezkin
0bb5774ff1 mobile: update version 2.2 (ios - 49, android - 35) 2022-05-30 12:58:09 +01:00
Evgeny Poberezkin
866d84e7ac mobile: move calls to experimental features, refactor (#712) 2022-05-30 12:32:11 +01:00
Evgeny Poberezkin
d6262bc2a4 ios: move files 2022-05-30 09:12:57 +01:00
Evgeny Poberezkin
e5909d4e12 ios: SMP servers settings page layout (#711) 2022-05-30 09:05:02 +01:00
Evgeny Poberezkin
23b75f11fe ios: paste image (#710) 2022-05-30 09:03:56 +01:00
Evgeny Poberezkin
71fa2bfec0 android: settings sections (#709) 2022-05-30 09:03:41 +01:00
Evgeny Poberezkin
29e2c00811 mobile: settings for auto-accepting images, link previews, spinner for link previews; privacy settings (#708)
* ios: settings for auto-accepting images, link previews, spinner for link previews

* android: settings for auto-accepting images, link previews, spinner for link previews, privacy settings

* update translation

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* translation

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* translation

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-05-30 08:59:04 +01:00
Evgeny Poberezkin
7c1d573a17 mobile: show skipped messages in the UI (#707)
* mobile: show skipped messages in the UI

* ios: skipped messages alert and translations

* android: skipped messages alert

* android translation keys

* more keys

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-05-29 08:06:56 +01:00
Evgeny Poberezkin
89908ef5dc core: chat item on skipped messages (#705)
* core: chat item integrity

* create chat item on skipped messages (but only on content items)

* report skipped messages on all messages, not only content messages

* remove type signature

* remove migration

* update rfc
2022-05-28 19:13:07 +01:00
JRoberts
c3c712aa02 ios: show local authentication notice; ios & android: retry authentication button (#706)
* advertisement

* refactor

* advertisement state machine

* simplify

* ios: retry

* remove log

* android: retry

* Update apps/ios/Shared/ContentView.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/SettingsView.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-28 22:09:46 +04:00
Evgeny Poberezkin
b56ad77502 core: mark accepted and rejected call items read (#704) 2022-05-28 12:34:40 +01:00
JRoberts
5e476516cb ios: lock toggle; android: fix lock timer (#702) 2022-05-28 14:58:52 +04:00
Evgeny Poberezkin
ce2f3c0371 mobile: timeout call invitations, more android options (#703)
* mobile: timeout call invitations, more android options

* close overlays when call is accepted via notification

* show incoming call above modals, dismiss modals when call is accepted

* fix clickable area of create profile button

* fix pending intent for rullscreen notification, update settings
2022-05-28 09:06:38 +01:00
Evgeny Poberezkin
da13e6614b mobile: call settings, request camera on iOS on call start (#701)
* mobile: call settings, request camera on iOS on call start

* refactor preferences

* fix typo
2022-05-27 16:36:33 +01:00
JRoberts
79d9e90ab7 mobile: local authentication (#696)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-27 18:21:35 +04:00
Evgeny Poberezkin
387aec8593 android: webrtc calls notification and alert (#699)
* android: webrtc calls notification and alert

* add ringtone to incoming call

* incoming call on full screen

* enable notification ringtone

* remove text

* use translated strings in call notification
2022-05-27 08:43:15 +01:00
Evgeny Poberezkin
a403f2051a core: add timestamp to call invitation (#700) 2022-05-27 09:30:01 +04:00
Evgeny Poberezkin
9e83b54b85 android: update colors (#698)
* android: update colors

* update color
2022-05-25 14:28:04 +01:00
Evgeny Poberezkin
2696086faa update webrtc npm package v0.0.5 2022-05-25 09:13:14 +01:00
Evgeny Poberezkin
546ad01fcb ios: integrating webrtc calls with callkit (#686)
* ios: integrating webrtc calls with callkit

* accept call via chat item (e.g. when DND is on, and callkit blocks the call); refactor

* fix remote video, support logging from ios

* use callkit depending on CallController setting

* call sound

* update incoming call view

* fixing audio encryption

* refactor encryption webrtc fix

* log ontrack success/error

* accept / ignore call via notification

* remove unused imports

* remove unused file

* remove comments
2022-05-24 19:34:27 +01:00
JRoberts
247e7f1ea7 Merge pull request #695 from simplex-chat/master (android version 2.1.1) 2022-05-24 09:47:42 +04:00
Evgeny Poberezkin
0290a687af android: version 2.1.1 (34) 2022-05-23 18:59:01 +01:00
Evgeny Poberezkin
97cab8b542 blog: v21 post (#693)
* blog post v2.1

* update images

* add image

* correction

* correction
2022-05-23 18:39:03 +01:00
Evgeny Poberezkin
4a42797d83 android: fix notifications to open correct chat (#692)
* android: fix notifications to open correct chat

* remove optimization
2022-05-23 18:33:28 +01:00
Evgeny Poberezkin
3051732622 android: remove notification when chat marked as read from the context menu (#691)
* android: remove notification when chat marked as read from the context menu

* remove chat notifications when chat is cleared or deleted
2022-05-23 17:49:58 +01:00
JRoberts
3fe005e252 Merge pull request #690 from simplex-chat/master (version 2.1.0, ios 48/android 33) 2022-05-23 15:33:09 +04:00
JRoberts
79dcada757 android: version 2.1.0 (33) 2022-05-23 13:56:08 +04:00
JRoberts
f7eeb4d1e3 android: fix compose view clearing state prematurely (#689) 2022-05-23 13:44:49 +04:00
JRoberts
d572cfbc09 ios: version 2.1.0 (48) 2022-05-23 10:38:37 +04:00
JRoberts
cb95c51fe1 ios: hide CallViewDebug 2022-05-23 10:24:14 +04:00
JRoberts
e057f9e407 Merge pull request #685 from simplex-chat/master (version 2.1.0) 2022-05-21 21:44:33 +04:00
JRoberts
6333a60103 android: version 2.1.0 (32) 2022-05-21 21:16:25 +04:00
JRoberts
3a539aec5b ios: version 2.1.0 (47) 2022-05-21 20:56:01 +04:00
JRoberts
40be12430e terminal: version 2.1.0 (amended) 2022-05-21 20:23:34 +04:00
JRoberts
53d66be910 mobile: disable calls (#683) 2022-05-21 20:14:43 +04:00
JRoberts
d6699ffb03 mobile: translations (#682)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-21 19:43:04 +04:00
JRoberts
6ad2eeec89 terminal: add /image command to send file as image (#681)
* terminal: add /image command sending file as image

* terminal: forward file commands

* terminal: update help

* terminal: update placeholder image

* Update src/Simplex/Chat/Help.hs

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* help

* allow forwarding sent file

* typo

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-21 15:17:15 +01:00
Evgeny Poberezkin
d971e7c31f mobile: call chat items (#680)
* mobile: call chat items

* android: call chat items
2022-05-21 12:13:37 +01:00
JRoberts
d2d8498258 android: show progress indicator on chat preview when connecting to server (#679)
* android: show progress indicator on chat preview when connecting to server

* move to right

* remove unused imports

* remove column
2022-05-21 12:13:04 +01:00
JRoberts
c1f67c08f7 android: close chat link dropdown menu when chat becomes unread and "mark read" menu item becomes available (#678) 2022-05-20 20:40:41 +04:00
JRoberts
70f78e7984 android: hide "mark read" menu item if chat is read (#677) 2022-05-20 20:24:35 +04:00
JRoberts
7f84057b86 android: add "mark read" action to chat link dropdown menu (#675)
* android: add "mark read" action to chat link dropdown menu

* Update apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-20 18:32:21 +04:00
JRoberts
718c8f826a android: fix connection statuses being reset when app comes back from background (closes #644) (#673) 2022-05-20 16:17:29 +04:00
Evgeny Poberezkin
e13cb6e2fd android: update gradle plugin to 7.2.0 (#674) 2022-05-20 12:56:27 +01:00
Evgeny Poberezkin
cb529e3202 Merge branch 'delete-profiles' into stable 2022-05-20 12:03:27 +01:00
Evgeny Poberezkin
b3e67efba0 mobile: fix statusbar in ios webrtc call, move PIP to top 2022-05-20 11:12:04 +01:00
JRoberts
3f2ca8f902 mobile: use updated_at in chat previews (#671)
* mobile: use updated_at in chat previews

* use updated_at returned from chat response

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-20 12:00:58 +04:00
Evgeny Poberezkin
9c9f6d8443 webrtc: show connection information, mark call as missed if it ends while pending (#672) 2022-05-20 07:43:44 +01:00
JRoberts
d50ebbd061 core: mark cleared chat as updated to keep it in the same place on restart (#670) 2022-05-19 21:57:31 +04:00
Evgeny Poberezkin
6cc4323571 webrtc: call overlays for ios/android, support for flipping camera (#669) 2022-05-19 14:33:02 +01:00
JRoberts
5cddf8e2d3 mobile: clear conversation from chat info view (#668) 2022-05-19 16:56:34 +04:00
JRoberts
2c2ab98105 android: more long press interactions on ChatListNavLinkView (#667) 2022-05-19 13:37:12 +04:00
JRoberts
5fd75ee286 terminal: version 2.1.0 2022-05-19 09:21:37 +04:00
Evgeny Poberezkin
0a134e2ded terminal: update chat help (#666) 2022-05-19 09:18:38 +04:00
Evgeny Poberezkin
e4a66c767c core: fix webrtc state machine (#665) 2022-05-18 18:46:45 +01:00
JRoberts
e4b1ff5e0f android: fix race condition on taking photo resulting in null URI; ios: use fullScreenCover for camera (closes #662) (#663) 2022-05-18 21:32:30 +04:00
Evgeny Poberezkin
5f67c450b1 mobile: webrtc calls fix encryption status, translate alerts, report connection stats on connection (#664)
* mobile: webrtc calls fix encryption status, translate alerts, report connection stats on connection

* refactor, remove logger, make property into getter
2022-05-18 17:20:43 +01:00
Evgeny Poberezkin
dc418923ac mobile: webrtc calls (#661)
* mobile: webrtc calls work on Android and iOS separately

* core: only send public key with offer if invitation/current call supports e2ee

* update npm module
2022-05-18 10:01:32 +04:00
JRoberts
106dceabfc mobile: clear chat; allow to delete items deleted by sender (#660)
* ios: clear chat

* android: clear chat

* fix chat stats

* fixes

* check if deleted

* delete from files for groups

* android - fixes

* Update apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-17 22:48:54 +04:00
Evgeny Poberezkin
295cec7c53 terminal: support to connect via webrtc (#659)
* terminal: support to connect via webrtc

* npm package

* update webrtc npm package

* remove console.log

* fix test
2022-05-17 08:37:00 +01:00
JRoberts
84bf815e5c core: fully delete direct chat items; clear chat API (#658) 2022-05-17 11:22:09 +04:00
Evgeny Poberezkin
82445ec8d5 android: refactor webrtc calls, compress webrtc session info, make compatible with Safari (with flag) (#642)
* use simplex.chat relay

* update webrtc settings

* WebRTCView to use command/response types

* compress WebRTC session descriptions, simple web UI for calls

* update webrtc ui

* use webworked in desktop browser

* use RTCRtpScriptTransform in safari

* update android type

* refactor

* add await
2022-05-16 19:27:58 +01:00
Evgeny Poberezkin
36ef6df9fb android: notification service and battery optimization on Android 12 (#656)
* Managing battery optimization on Android 12+ via in-app UI                                                                                                                                              
- in case of battery optimization is enabled a user will be asked to disable it if he wants to have a background service                                                                                
- when the service is enabled but the user don't want to disable the battery optimization, the service will be disabled with an alert for the user

* update service notice conditions

* android: update notification service logic

* update translations

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2022-05-16 07:59:41 +01:00
JRoberts
c000a1b924 docs: fix links in CLI.md (#655) 2022-05-14 21:00:54 +04:00
JRoberts
9bf7821444 core: delete profiles (#654) 2022-05-14 21:00:46 +04:00
JRoberts
03eaa94324 test: delay after mobile tests to fix SQLite IO error on Mac (#653) 2022-05-14 20:10:46 +04:00
Evgeny Poberezkin
7ad7f4f91a mobile: zoomable fullscreen image view (#651)
* ios: zoomable fullscreen image view

* android: zoomable images
2022-05-14 08:07:13 +01:00
JRoberts
8d53c569c7 core: fix createOrUpdateContactRequest logic (#650)
* core: fix createOrUpdateContactRequest logic

* remove do

* fix logic

* rename

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-13 21:57:24 +01:00
Evgeny Poberezkin
b7860ad0e8 simplex-chat server & JavaScript/TypeScript SDK/client (#539)
* simplex-chat server

* typescript types for chat commands and command serialization

* typescript ChatResponse type

* more types

* more types

* websocket chat client

* aligb ts/haskell types

* chat server & TS client via websockets - it works

* TS chat client test

* TS chat client test

* update test

* more api functions

* more api methods, refactor, readme

* squaring chat bot example, fixes

* update readme

* remove console.log

* npm version 0.1.0
2022-05-13 19:44:03 +01:00
JRoberts
9f5ea49676 mobile: show markdown in quotes and context items; ios: fix markdown help (#649) 2022-05-13 12:57:30 +04:00
Evgeny Poberezkin
c1eed47463 core: mark chat as read without specifying the items range, closes #647 (#648)
* core: mark chat as read without specifying the items range, closes #647

* moar tests
2022-05-13 09:38:14 +01:00
JRoberts
91a0283a36 Merge pull request #646 from simplex-chat/master (version 2.0.1) 2022-05-13 10:07:48 +04:00
JRoberts
da793bbf4f android: version 2.0.1 (31) 2022-05-13 09:32:34 +04:00
JRoberts
e958e45652 terminal: version 2.0.1 2022-05-12 19:03:02 +04:00
JRoberts
66284a954b ios: version 2.0.1 (46) 2022-05-12 18:51:05 +04:00
Evgeny Poberezkin
6aebb93f7f mobile: prevent adding spaces to display name (#643)
* mobile: prevent adding spaces to display name

* android: save enabled

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-05-12 18:07:28 +04:00
JRoberts
53e330dac9 core: add missing status transitions for group file transfer; fix group file delivery race condition (#640) 2022-05-12 17:37:09 +04:00
JRoberts
e174c43bec android: fix compose/send message not resetting some state (#639) 2022-05-12 11:08:14 +04:00
Evgeny Poberezkin
18c3f49f96 Merge branch 'master' into stable 2022-05-11 18:36:26 +01:00
Evgeny Poberezkin
aa9c2f3228 fix links 2022-05-11 18:35:55 +01:00
Evgeny Poberezkin
1c5e6f52ec update readme links 2022-05-11 18:34:50 +01:00
JRoberts
1a653649ec Merge pull request #635 from simplex-chat/master (version 2) 2022-05-11 20:56:56 +04:00
Evgeny Poberezkin
50f06a3c55 blog: v2.0 images and files (#632)
* blog: v2.0 images and files

* update post

* update

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-05-11 17:20:22 +01:00
JRoberts
81ebf1b696 terminal: version 2.0.0 (#634)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-11 20:15:09 +04:00
Evgeny Poberezkin
885a4ea972 terminal: change default servers (#633) 2022-05-11 19:52:08 +04:00
JRoberts
0262ab53bf core: improve file cancel (#627)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-11 16:18:28 +04:00
Evgeny Poberezkin
89ea57e4b6 android: fix picking file from gallery (#631) 2022-05-10 16:15:11 +01:00
Evgeny Poberezkin
3d8ccdaa9f android: fix bug in chat list (#630) 2022-05-10 15:00:59 +01:00
Evgeny Poberezkin
058c0f5895 mobile: ios v2.0 (45), android (28) 2022-05-10 11:07:00 +01:00
Evgeny Poberezkin
1027bf923f mobile: update connect action icons 2022-05-10 09:57:37 +01:00
JRoberts
01467769bf mobile: remove icons on received files (#629) 2022-05-10 12:15:46 +04:00
Evgeny Poberezkin
3cd8f8f7dd android: version 2.0 (27) 2022-05-10 08:33:53 +01:00
Evgeny Poberezkin
05283d58b0 ios: version 2.0 (44) 2022-05-10 08:21:09 +01:00
Evgeny Poberezkin
c0528baba7 ios: show spinners when connecting contacts (#628)
* ios: show spinners when connecting contacts

* move status to the corner
2022-05-10 08:04:18 +01:00
Evgeny Poberezkin
412982cc01 android: onboarding (#624)
* android: onboarding views

* create profile

* creating profile works

* make connection view

* onboarding layout

* add translations
2022-05-10 08:03:43 +01:00
Evgeny Poberezkin
69e21781df ios: update libs, fix background refresh crash, remove NSE from app, v2.0 (43) (#626)
* ios: update libs, fix background refresh crash, remove NSE from app, v2.0 (43)

* remove geometryreader

* update translations

* update translations
2022-05-09 17:40:39 +01:00
JRoberts
4dcf1f8d15 test: add missing fields to ServerConfig 2022-05-09 18:53:39 +04:00
JRoberts
ee3d7ae97e core: update simplexmq hash (v2.0.0) (#625) 2022-05-09 16:24:24 +04:00
Evgeny Poberezkin
a3e6582a64 ios: update version 2.0 (42) 2022-05-09 13:17:30 +01:00
Evgeny Poberezkin
3e4826395e ios: fix onboarding info layout for small screen (#623)
* ios: fix onboarding info layout for small screen

* update make connection onboarding view
2022-05-09 13:13:53 +01:00
JRoberts
95c79c1b5c ios: version 2.0 (41) 2022-05-09 14:38:18 +04:00
JRoberts
c61707a358 android: save images to Gallery (#622) 2022-05-09 14:09:10 +04:00
Evgeny Poberezkin
5e9d72d309 ios: translations 2022-05-09 10:28:16 +01:00
Evgeny Poberezkin
dcaefd6566 mobile: onboarding (#618)
* mobile: onboarding

* ios onboarding: create profile and make connection

* how SimpleX works

* connect via link

* remove separate view for connecting via link, fix bugs

* remove unused files

* fix help on small screens, update how it works page

* layout

* add About to settings, tidy up

* rename function

* update layout

* translations

* translation corrections

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* correction

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* corrections

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* fix translations/layout

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-05-09 09:52:09 +01:00
Evgeny Poberezkin
3d2315a117 ios: update icons for edit/quote messages 2022-05-09 09:12:32 +01:00
JRoberts
1dc3190159 remove debug logs on deletion; android: context menu on contact link (#621) 2022-05-09 10:55:56 +04:00
JRoberts
ca4d1910db split delete transaction (#620) 2022-05-08 22:03:45 +04:00
Evgeny Poberezkin
58b37bf114 mobile: add v2 to "choose file" label (#616)
* ios: add v2 to "choose file" label

* android: add v2 to "choose file" label

* fix translations
2022-05-07 21:09:57 +01:00
JRoberts
8773a058bf delete from files; delete from chat_items 2022-05-07 21:25:29 +04:00
Evgeny Poberezkin
1530d1e12b mobile: translations (#615)
* mobile: translations

* change some android translations

* corrections

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* correction

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* correction

* picture

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* import updated

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-05-07 17:29:58 +01:00
JRoberts
2c61358cb9 add logs to deleteContact 2022-05-07 19:33:47 +04:00
Evgeny Poberezkin
b1024be74d mobile: hide notifications and webrtc calls (#612)
* mobile: hide notifications and webrtc calls

* android: remove webrtc link
2022-05-07 16:10:57 +01:00
JRoberts
b3641bdf83 fix deletion of contact with files wip (#614) 2022-05-07 18:24:38 +04:00
Evgeny Poberezkin
c4f4cd85c4 android: fix quotes with images and files (#611) 2022-05-07 14:00:17 +01:00
JRoberts
235bce8e2a android: remove unnecessary READ_EXTERNAL_STORAGE permission request, open image picker in Gallery; IMG timestamp format (#610) 2022-05-07 16:25:04 +04:00
Evgeny Poberezkin
fcb5c69281 android: types and messages for webrtc calls (#609)
* android: webrtc calls

* string localizations, more types
2022-05-07 13:23:20 +01:00
Evgeny Poberezkin
29990765e7 mobile: webrtc calls state machine (#606)
* mobile: webrtc calls state machine

* android: call api types

* android: call api methods

* ios: connect calls via chat UI (WIP)

* ios: webrtc call connects via UI

* core: update call duration/status when x.call.end is received

* improve call UX/UI

* audio calls

* different overlay for audio calls

* toggle video/audio in the call
2022-05-07 09:40:46 +04:00
JRoberts
884231369f mobile: files UI (#597)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-06 21:10:32 +04:00
Evgeny Poberezkin
e575e97019 core: update _send api, remove _send_quote, use new api in tests (#608) 2022-05-06 09:17:49 +01:00
JRoberts
9d47c8a3d4 core: use filename in quote when there is no message text (MCText) (#607)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-06 12:04:53 +04:00
Evgeny Poberezkin
c8aa8db973 mobile: switch to send_v2 in ios, add to android (#605) 2022-05-05 15:44:48 +01:00
Evgeny Poberezkin
e80f617840 core, ios: send_v2 api to send messages as JSON (to support filenames with spaces) (#604) 2022-05-05 17:04:03 +04:00
Evgeny Poberezkin
7928cdbfb8 core: update sent file status for direct chats when sending complete (#603)
* core: update sent file status for direct chats when sending complete

* update snd file status when started and completed
2022-05-05 16:50:19 +04:00
Evgeny Poberezkin
dbf6b1f673 core: use filename in quote when there is no message text (#602) 2022-05-05 14:52:32 +04:00
Evgeny Poberezkin
cf04a9fed3 add AChatItem to snd file events (#601) 2022-05-05 13:37:53 +04:00
Evgeny Poberezkin
76a9b5b8d4 Merge pull request #591 from simplex-chat/webrtc-calls
WebRTC calls
2022-05-05 08:31:33 +01:00
Evgeny Poberezkin
80c5a151a2 core: fix mac tests (#600) 2022-05-05 07:37:33 +01:00
Evgeny Poberezkin
96b514af45 core: update webrtc types (#599) 2022-05-04 23:32:46 +01:00
IanRDavies
f2036236f6 ios: webrtc calls started (#594)
* building towards ios calls

* minor ios changes

* linting

* RPC calls with webview

* tidy up

* more types

* ios: webrtc types, call works

* remove comment

* prettier

* add prettier to build

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-04 23:07:26 +01:00
Evgeny Poberezkin
3e19e495de Merge branch 'master' into webrtc-calls 2022-05-04 13:39:40 +01:00
Evgeny Poberezkin
1ddd17839b core: calls api - support multiple calls, process status updates from webview, refactor, tests (#595)
* core: tests for call api (WIP, test fails)

* fix test

* add APICallStatus, tests

* update call status based on webview events, refactor
2022-05-04 13:31:00 +01:00
JRoberts
70ea803a49 ios: move image utils to FileUtils (#598) 2022-05-04 16:08:40 +04:00
JRoberts
2c1ad9a641 mobile: support receiving files (#584)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-04 09:10:36 +04:00
JRoberts
260a758b82 test: fix race condition caused by small file transfer (sender cancelled) (#596) 2022-05-04 09:09:59 +04:00
Evgeny Poberezkin
8e002eed1c Merge branch 'master' into webrtc-calls 2022-05-03 10:57:00 +01:00
Evgeny Poberezkin
20d253ea35 core: webrtc calls API implementation (#593)
* core: webrtc calls API implementation

* process call messages, send events to the UI
2022-05-03 10:22:35 +01:00
Evgeny Poberezkin
3519032784 ios: notifications service extension - refactor model and API (#578)
* ios: notifications service extension

* create notifications in NSE (WIP)

* refactor notifications to use in NSE

* prepend team ID to shared defaults name to silence the warning

* remove whitespace
2022-05-03 08:20:19 +01:00
Evgeny Poberezkin
cdb919db96 core: webrtc calls api types (#590)
* core: webrtc calls api

* call: call state, chat items, update commands/responses

* update sequence diagram

* remove CRCallInvitationSent, add CISCallError
2022-05-02 17:06:49 +01:00
Evgeny Poberezkin
f78ec3584f webrtc calls in webview to typescript (#592)
* mobile: webrtc calls in webview (typescript WIP)

* typsecript compiles

* fix error messages

* TS works in chrome

* include ICE candidates into offer/answer, report connection state changes to host, end call on disconnection

* refactor, readme for .js file
2022-05-02 15:48:39 +01:00
Evgeny Poberezkin
18d1a0605e Merge branch 'master' into webrtc-calls 2022-05-02 10:57:45 +01:00
Evgeny Poberezkin
63f531259d fix test for /t 2022-05-02 10:57:35 +01:00
IanRDavies
94a4f33a1f id/video calls prototype (#570)
* initial UI framework

* limited javascrtipt interaction

* run some js

* try to resolve permissions issues

* some initial RTC javascript

* approaching a workable js file

* js fixes

* tidy up js

* add some ui to web call

* fixes

* ready to test

* typo

* refactor for readability

* tidy up before adding encryption

* add transform to video streams

* tidy a little, audio encoding works, video fails

* minor changes

* use variables consistently

* e2ee video calls
git push

* include IV in outgoing message, decrypt fails when trying to read back

* add different prefix retention for differing frame types

* e2ee video calls with iv passed in band

* enforce use of VP8 encoding

* allow plaintext chunk only for video frames

* tidy up kotlin. Android <> browser tested

* minor ios changes

* capture js logs in xcode

* typo

* linting

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-01 14:08:20 +01:00
Evgeny Poberezkin
26d3e71c4e fix test for /t 2022-05-01 14:07:37 +01:00
Evgeny Poberezkin
606eefa45d mobile: show contact connection pending status as ellipsis, error as gray circle (no green circles) (#588)
* mobile: show contact connection pending status as ellipsis, error as gray circle (no green circles)

* android: show contact status
2022-05-01 14:05:01 +01:00
IanRDavies
42959cc350 Video calls RFC (#542)
* draft RFC for video calls, own rolled WebRTC approach in comments

* formatting typo

* update

* update rfc (WIP)

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-05-01 09:30:41 +01:00
Evgeny Poberezkin
2b1ab01efe terminal: /tail all messages (for all chats) (#589)
* terminal: /tail all messages (for all chats)

* tests
2022-04-30 21:23:14 +01:00
JRoberts
dd592c7db3 core: discontinue old file protocol (send); use MCFile when sending and receiving files (#587) 2022-04-30 19:18:46 +04:00
Evgeny Poberezkin
305052ecaf core: api to resubscribe connections (#586)
* core: api to resubscribe connections

* update simplexmq
2022-04-30 12:47:50 +01:00
Evgeny Poberezkin
099f25c63f ios: update version 1.7 (40) 2022-04-29 17:03:22 +01:00
JRoberts
6c72db58f5 core: return AChatItem in FileAccepted and FileStart events (#585) 2022-04-29 15:56:56 +04:00
Evgeny Poberezkin
1df9a1ec2d core: refactor terminal commands (#583) 2022-04-28 08:34:21 +01:00
Evgeny Poberezkin
d9572cef86 terminal: command to show last N items in a chat (#582) 2022-04-28 07:26:43 +01:00
JRoberts
c48a516586 core: MCFile (#580) 2022-04-28 09:40:51 +04:00
JRoberts
a7554771a0 android: refactor compose (#579) 2022-04-27 20:54:21 +04:00
JRoberts
645587431d core: fix asynchronous file transfer (#572) 2022-04-26 12:52:41 +04:00
Evgeny Poberezkin
f02dcc851e core: fix mac tests (#576)
* core: fix mac tests

* enable all tests
2022-04-26 09:08:39 +01:00
JRoberts
762024dfd9 test: fix race condition caused by small file transfer (#577) 2022-04-26 11:51:46 +04:00
Evgeny Poberezkin
fc5cdc5eb1 mobile: use batched DOWN/UP events, core: include pending contacts (#573)
* mobile: use batched DOWN/UP events, core: include pending contacts

* query style
2022-04-26 07:51:06 +01:00
Evgeny Poberezkin
44de6297ee android: UI for pending contact connections, ios: translations, show profile picture in contact requests (#571)
* android: UI for pending contact connections, ios: translations, show profile picture in contact requests

* update translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update translation

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-26 07:41:08 +01:00
Evgeny Poberezkin
cd2eb9c88e core: test async handshake (#569)
* core: test async handshake

* Update tests/ChatTests.hs

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-25 19:30:21 +04:00
IanRDavies
5fc1364fd3 refactor to use stringResource in composables (#568)
In order to retain the ability to have Previews in android studio we need to use stringResource over generalGetString in composable items.
2022-04-25 12:37:32 +01:00
IanRDavies
b5022b4d41 android: use opensource boofcv to scan QR codes (#562)
* open source qr code scanner

* refined error localisation - todo moved

* tidy up to make picking back up easier

* better errors

* scan images one at a time and once per second to avoid multiple requests being sent

* more sophisticated avoidance of repeat contact requests

* remove format specifier

* refinements

* update QR scanner to use boofcv

* remove dependence on zxing

* attribution

* Update apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-25 11:05:09 +01:00
Evgeny Poberezkin
89c36d42e2 ios: pending contact connections UI, core: delete connections on the server when deleting in UI/db (#565)
* ios: started pending connections UI

* ios: UI for pending contact connections complete

* this has to be getter, or it would break JSON parsing

* ios: update "initiated" status of connection
2022-04-25 10:39:28 +01:00
JRoberts
db4731f19b mobile: correctly resize images, refine image sending UI (#546) 2022-04-25 12:44:24 +04:00
Evgeny Poberezkin
0470f9cf36 core: batch contact disconnections and re-subscriptions to one event per server (#564)
* core: batch contact disconnections and re-subscriptions to one event per server

* update simplexmq

* update query to use temp table

* remove old code
2022-04-25 09:17:12 +01:00
IanRDavies
e87660974e Paste link to connect (#551)
* initial implementation of textbox

* paste to connect box implemented (and tested) in android

* first pass at pastebox in iOS

* clean up iOS implementation

* put paste link page in for groups in android

* initial inclusion in iOS UI

* refactor naming

* lint kotlin

* fix typo

* ios: update "connect via link" ui, refactor connecting via link to use the one function

* android: update paste link UI

* add russian translations

* update translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update translations

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-25 07:54:07 +01:00
Evgeny Poberezkin
48ba6472b6 core: add updatedAt to UserContactRequest and to PendingContactConnection, use it to sort the list of chats, tests (#563) 2022-04-24 09:05:54 +01:00
Evgeny Poberezkin
9de28c46a0 core: update test (#561) 2022-04-23 18:23:29 +01:00
Evgeny Poberezkin
14514050ae core: api for pending contact connections (#553)
* core: api for pending contact connections

* core: pending contact connection events / api
2022-04-23 17:32:40 +01:00
Evgeny Poberezkin
a525f24969 remove nl translations (will conflict with locale-nl branch) 2022-04-23 13:34:51 +01:00
Evgeny Poberezkin
4a2bfef4b3 ios: refactor views with ViewBuilder (#558) 2022-04-23 12:52:05 +01:00
Evgeny Poberezkin
0c37282cd3 ios: add translation for "Checking new messages..." test notification 2022-04-23 11:34:08 +01:00
Evgeny Poberezkin
e6fdb40c59 ios: fix opening direct chats (#555) 2022-04-23 11:35:56 +04:00
Evgeny Poberezkin
0091e9f162 ios: show notification token status in UI (#552)
* ios: show notification token status in UI

* show notification token status
2022-04-23 09:32:16 +04:00
Evgeny Poberezkin
8257842914 APIRegisterToken returns NtfTknStatus (#550) 2022-04-22 20:32:19 +01:00
Evgeny Poberezkin
dcffdf83b9 ios: show local notification when checking messages having received background notification (#549)
* ios: show local notification when checking messages having received background notification

* Update apps/ios/Shared/Model/NtfManager.swift

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* Update apps/ios/Shared/Model/NtfManager.swift

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-22 17:53:09 +01:00
IanRDavies
1932873776 Show pending contacts (#547)
* capture contact connecting event

* disable navigation to not ready chat

* update "pending contact" alert, Russian translations

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-22 17:26:17 +01:00
Evgeny Poberezkin
7c2edff81f ios: periodic notifications UI (#548)
* ios: periodic notifications UI

* add missing import

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-22 16:46:05 +04:00
Evgeny Poberezkin
f594774579 ios: push notifications (#482)
* ios: get device token for push notifications

* ios: receive messages when background notification is received

* add notifications API, update simplexmq

* chat API to register and verify notification token

* update AppDelegate to recognize different notification types, update simplexmq

* core: api to enable periodic background notifications

* update simplexmq

* chat API to delete device notification token

* use base64url encoding in verification code

* update simplexmq for notifications
2022-04-21 20:04:22 +01:00
IanRDavies
03b8cdea8d android: support app links via simplex.chat (#545)
* update manifest

* autoverify domain

* add path prefix to intent filter

* support contact links

* add android app readme

* only allow a single simplex task at a time to avoid opening simplex 'on top' of app with link
2022-04-21 14:57:36 +01:00
JRoberts
effd37402a SMP AUTH error processing and descriptions (#544)
* SMP AUTH error descriptions

* change text

* android error description

* Update apps/android/app/src/main/res/values/strings.xml

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/android/app/src/main/res/values-ru/strings.xml

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* fix translations

* Apply suggestions from code review

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-21 11:50:24 +04:00
JRoberts
43e560c901 Merge pull request #543 from simplex-chat/master (version 1.6) 2022-04-20 22:17:45 +04:00
JRoberts
ec5aea0773 android: version 1.6 (26) 2022-04-20 12:44:31 +04:00
JRoberts
9a32bb8959 ios: version 1.6 (39) 2022-04-20 12:16:44 +04:00
JRoberts
4cac08cf51 mobile: fix translations, minor view bugs (#541) 2022-04-20 11:53:34 +04:00
JRoberts
3e1fa779b9 android: version 1.6 (25) 2022-04-19 21:44:05 +04:00
Evgeny Poberezkin
5119bb3625 fix: cancelled links previews, context menu width 2022-04-19 18:23:17 +01:00
JRoberts
d61c8a363a android: version 1.6 (24) 2022-04-19 16:57:05 +04:00
Evgeny Poberezkin
059a13576b ios: version 1.6 (38) 2022-04-19 12:52:44 +01:00
JRoberts
2e46092d92 ios: add english localization 2022-04-19 15:47:08 +04:00
Evgeny Poberezkin
44fd13b836 ios: update version 1.6 (37) 2022-04-19 12:22:37 +01:00
JRoberts
015329fcc7 ios 1.6 release 2022-04-19 15:17:47 +04:00
JRoberts
6835b6c1dd mobile: enable/disable features for release (#540) 2022-04-19 13:24:26 +04:00
JRoberts
1152b5d737 mobile: support images (#536)
* ios api

* ios wip

* android wip

* ios files folder

* ios get address on start

* android app files folder

* ios more backend

* android more backend

* translation

* ios image without text, remove preview

* android image without text, remove preview

* fix translation

* file name in previews and w/t text

* Revert "file name in previews and w/t text"

This reverts commit 0110570e55.

* ios filename in preview

* android filename in preview

* android wider images

* ios determine width on image for correct quote width

* ios images in previews wip

* ios square image in quote

* ios: update image layout

* android images in quotes

* android remove redundant modifier

* android clip to bounds

* android - image in right side of quote

* android refactor image view

* android - refactor, align quote text top

* android fix emoji view

* fix image layout

* full screen image view, fix quote layout

* android various size

* android fixed image width

* android meta on image

* ios: add drag gesture to hide full-screen image

* android: make image-only meta white

* refactor file.stored

* android: meta icon color

* android: open chat scrolled to last unread item

* copy/share image messages

* android: full screen image

* check file is loaded

* terminal: refactor view for messages with files

* android: change to onClick, only show stored file

* android: remove close sheet bar

* android: close image view on click

* translation

* android: pass showMenu to CIImageView to show menu on long click

* increase DropDown width

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-19 12:29:03 +04:00
Evgeny Poberezkin
de81afc727 ios: update haskell libs 2022-04-18 11:53:52 +01:00
Evgeny Poberezkin
ffb941ac4d update build badge to point to stable branch 2022-04-16 12:21:37 +01:00
Evgeny Poberezkin
4b39de6c4f Merge pull request #535 from simplex-chat/master
merge master to stable
2022-04-16 12:16:14 +01:00
Evgeny Poberezkin
bc9a8bc32c ios: remove "Tests MacOS" target 2022-04-16 12:09:10 +01:00
JRoberts
757ca74482 terminal: version 1.6.0 (#534) 2022-04-16 13:01:07 +04:00
Evgeny Poberezkin
87c688a739 ios: i18n (#533)
* ios: prepare for i18n

* commit localizations

* update Russian translations

* fix notifications and layouts after localizations

* localization docs

* update translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* fix typo

* update translations

* fix translations for different link types

* update translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update translation

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update translations

* update translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-16 09:37:01 +01:00
IanRDavies
d201c9528a android: i18n (#529)
* internationalization framework

* rearrange strings

* typo

* minor id & xliff changes

* response to comments

* colour comments and verb suffixes

* add russian language file

* fix interpolation error

* final strings

* russian translations

* update Russian translations, refactor strings to full sentences, add prefixes to content description names

* fix layouts, improve font spacing

* split sentence about User address, font line height

* typo

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update Russian translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* remove an

* update Russian translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* commas

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-16 09:29:29 +01:00
Evgeny Poberezkin
2058e904e6 core: refactor files folder support (#532)
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-15 16:16:34 +04:00
JRoberts
e560ed8327 core: support files folder for mobile, delete files, chat item in CRRcvFileComplete (#530) 2022-04-15 09:36:38 +04:00
Evgeny Poberezkin
5281871aa6 typo 2022-04-13 11:49:09 +01:00
JRoberts
f83704c964 fix typos in readme (#528) 2022-04-13 08:37:13 +01:00
IanRDavies
1431002829 add status icons to messages (#524)
* add status icons to messages

* prettier spacing

* tighten status icons

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-12 20:38:38 +01:00
IanRDavies
f1356ca642 readme changes (#527)
* readme changes

* more changes

* response to comments
2022-04-12 19:57:36 +01:00
Evgeny Poberezkin
07c7799523 reduce text in readme (#525)
* reduce text in readme

* update "why"

* typo

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* dot

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-12 19:30:17 +01:00
Evgeny Poberezkin
7ab76528a0 Merge branch 'stable' 2022-04-12 19:24:44 +01:00
Evgeny Poberezkin
a0a14889b1 android: update version 1.5.1 (23) 2022-04-12 19:11:44 +01:00
Evgeny Poberezkin
78133ff4d2 Merge branch 'stable' 2022-04-12 14:00:42 +01:00
Evgeny Poberezkin
34c513adeb core: update simplexmq (fixes SMP END from disconnected clients removing active subscriptions) (#523) 2022-04-12 12:24:34 +01:00
IanRDavies
31eabf07e4 android: notifications improvements (to stable) (#522)
* add intent to grouped notifications

* clear overlays on open from ntf

* cancel notifications alongside unmarked markers

* tidy up
2022-04-12 11:55:18 +01:00
Evgeny Poberezkin
af471d0077 update github content (#519)
* update github content

* update comparison

* update link

* move message_views.sql to scripts

* move section

* move news section

* typos

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update readme

* update readme

* update readme

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-11 22:29:08 +01:00
Evgeny Poberezkin
0a17f5c491 ios: update package name in prepare script, update libs (#509)
* ios: update package name in prepare script, update libs (not working yet)

* ios: update/move prepare scripts
2022-04-11 18:43:09 +01:00
Evgeny Poberezkin
7f8afb0c12 move nix files to folder (#520)
* move nix files to folder

* move nix to scripts
2022-04-11 15:53:44 +01:00
IanRDavies
1b930e717a android: link previews (#510)
* wire up api for link metadata parsing

* add getLinkPreview (synchonous for now)

* api wiring fix

* get network requests off main thread

* copy over state machine logic from iOS

* filter api parsing calls from logs

* refactor of image processing

* remove image deepcopy

* minor change to log filtering

* mobile: link previews

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-11 09:39:04 +01:00
Evgeny Poberezkin
02d21145b2 core: replace quoted content with MCText if the message itself is not MCText (#517)
* core: replace quoted content with MCText if the message itself is not MCText

* core: quoteData in ChatMonad (#518)

* core: quoteData in ChatMonad

* use throwChatError

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-04-11 09:34:59 +01:00
Evgeny Poberezkin
fa313caa82 terminal: refactor chat core used in terminal app and in bot examples (#516)
* terminal: refactor chat core used in terminal app and in bot examples

* fix tests

* refactor
2022-04-10 17:13:06 +01:00
Evgeny Poberezkin
0ac9785e4b terminal: option to execute a single chat command via command line (#515) 2022-04-10 16:30:54 +01:00
Evgeny Poberezkin
fd69b673d8 terminal: use up arrow to edit the last message (#514)
* terminal: use up error to edit the last message

* update help
2022-04-10 12:18:53 +01:00
Evgeny Poberezkin
6c2fb822d7 nix: add the second x86 ios sim build with swift JSON 2022-04-10 10:52:36 +01:00
JRoberts
13f84f2a96 core: sending messages with files (#507)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-10 13:30:58 +04:00
Evgeny Poberezkin
150b4196ea ios: fix scrolling with link previews, fix large terminal item detail view (#512) 2022-04-08 19:58:02 +01:00
Evgeny Poberezkin
84a77de53c remove apiParseMarkdown commands from console (#511) 2022-04-08 18:58:09 +01:00
IanRDavies
d90c4261b8 ios: link previews (#503)
* refactor image utils and initial link metadata tools

* remove LPMetadata conversion as we will build our own view to avoid network calls

* initial very basic preview outline, remove icon loading

* connect preview view to compose view

* v0.1 barely working

* minor refactor

* refactor

* collect images effectively

* link up to api for send/receive

* rework async get metadata logic

* show previews in chat

* refactor resizing logic

* checkpoint before view editing

* ui changes

* housekeeping

* ui tweaks

* typo

* improve link preview design/logic

* resize image to target data size

* fix link preview state machine

* tidy up

* fix typo

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-08 18:17:10 +01:00
Evgeny Poberezkin
9fda89d0db update simplexmq (with swift flag) 2022-04-08 15:44:42 +01:00
Evgeny Poberezkin
8ef27de503 update simplexmq, cabal flag, fix tests 2022-04-08 09:21:56 +01:00
Evgeny Poberezkin
238cc8b90b ios: update libs 2022-04-07 16:11:18 +01:00
Evgeny Poberezkin
f12b5524fd fix flake.nix 2 2022-04-07 10:43:13 +01:00
Evgeny Poberezkin
3f86737d3f fix flake.nix 2022-04-07 10:33:32 +01:00
Moritz Angermann
082e62c56b Update flake.nix (#508)
* Update flake.nix

* update nix file, simplexmq sha

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-07 09:12:54 +01:00
JRoberts
8dd324b9b3 core: images api (#506)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-06 13:21:06 +04:00
JRoberts
de64f3a1a0 tests: maintain schema dump (#505) 2022-04-05 12:44:22 +04:00
JRoberts
a5ca2c2163 core: new files protocol (#492) 2022-04-05 10:01:08 +04:00
Evgeny Poberezkin
a17ddede53 ios: update binaries 2022-04-04 20:44:38 +01:00
Evgeny Poberezkin
7012005feb core: MsgContent for link previews, API to parse markdown (#504) 2022-04-04 19:51:49 +01:00
IanRDavies
0ecaa59df6 ios: update image picker (#495)
* switch to PHPicker for photos. TODO add back camera functionality. [rough]

* add back camera selection option

* remove force unwrap of optional

* response to comments

* rerun tests

* refactor naming
2022-04-04 19:19:54 +01:00
Evgeny Poberezkin
309fdf422f ios: scripts (#501) 2022-04-04 12:33:28 +01:00
Evgeny Poberezkin
9e88e4b940 blog: instant notifications design for Android and iOS (#463)
* blog: instant notifications design for Android and iOS

* fix blog diagram

* update blog post

* typo
2022-04-04 10:30:18 +01:00
Evgeny Poberezkin
7519884a5e Merge pull request #500 from simplex-chat/master
Merge to stable
2022-04-04 10:27:35 +01:00
Evgeny Poberezkin
852421315b SimpleX Chat bot example (#499)
* SimpleX Chat bot example

* extract repl bot

* update .cabal
2022-04-04 08:14:42 +01:00
JRoberts
33857d9aa7 Merge pull request #487 from simplex-chat/master (v1.5.0) 2022-04-03 14:10:02 +04:00
JRoberts
ef41034e17 Merge branch 'stable' 2022-04-03 14:05:57 +04:00
JRoberts
331269a186 terminal: version 1.5.0 (#498) 2022-04-03 13:58:38 +04:00
Evgeny Poberezkin
c14f692b68 terminal: edit and delete messages for everyone on the chat (#497) 2022-04-03 09:44:23 +01:00
Evgeny Poberezkin
4247dc4271 ios update build num 35 2022-04-02 16:09:49 +01:00
Evgeny Poberezkin
7f945d2530 ios: improve connection error alerts 2022-04-02 14:35:35 +01:00
Evgeny Poberezkin
3dc9eded54 ios: fix alert on contact deletion from chat info (#496) 2022-04-02 12:23:05 +01:00
Evgeny Poberezkin
5c13267b47 mobile: build nums ios 34, android 22 2022-04-02 10:41:26 +01:00
IanRDavies
e10c8c7234 android: minor text changes (#491)
* minor changes for readability

* update notifications popup wording

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-02 10:15:44 +01:00
IanRDavies
052963f19e ios: tidy up tmp images (#494)
* catch image URL and tidy up after the fact

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-01 17:13:54 +01:00
Evgeny Poberezkin
c7d7c6c608 mobile: build number ios 33, android 21 2022-04-01 16:54:55 +01:00
JRoberts
6c4c097150 core: update simplexmq hash (remove manual vacuum) (#493) 2022-04-01 17:23:12 +04:00
JRoberts
3eb4d5efdd core: update simplexmq hash (catch db error) (#490) 2022-04-01 13:00:35 +04:00
Evgeny Poberezkin
ea95912bd5 mobile: update lib and versions 1.5 (ios - 32, android - 20) 2022-04-01 09:58:00 +01:00
JRoberts
d080a3a87b mobile: hide broadcast delete button (#488) 2022-03-31 21:38:53 +04:00
JRoberts
54b501913c fix readme link (#485) 2022-03-31 16:41:08 +04:00
JRoberts
7f7abe1c62 terminal: version 1.4.1 (#486) 2022-03-31 16:08:07 +04:00
JRoberts
f1492f8889 core: update simplexmq hash (pragmas) (#484) 2022-03-31 15:32:42 +04:00
JRoberts
4c6800f1ff android: change font (#426) 2022-03-30 23:35:36 +04:00
Evgeny Poberezkin
b6c578ca77 ios: fix missing profile image on the first received item in the group (#483) 2022-03-30 20:04:25 +01:00
JRoberts
f388512592 mobile: message delete (#480)
* mobile: message delete

* ios

* android api

* meta

* android

* new ios libs

* bug fixes

* adjust alert

* fix deleted item upsert

* change border color for ios

* format

* android - red button

* ios: deleted item design

* android: deleted item design

* android alert msg

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-03-30 20:37:47 +04:00
Evgeny Poberezkin
8574674c2d android: notice about background service (#481)
* android: notice about background service

* update alert text
2022-03-30 12:33:31 +01:00
Evgeny Poberezkin
1b7cee9fcf ios: update lib and build version 31 / 1.4 2022-03-30 09:02:45 +01:00
Evgeny Poberezkin
12ee82808e ios: update lib and build version 31 / 1.4 2022-03-30 09:02:20 +01:00
Evgeny Poberezkin
5e964cf7e9 mobile: show group member images in the chat (#473)
* mobile: show group member images in the chat

* improve layout for group chat

* android: show member images in group chat

* do not repeat member name in group messages
2022-03-30 08:57:42 +01:00
Evgeny Poberezkin
8768d03e57 Merge branch 'stable' 2022-03-30 08:19:38 +01:00
Evgeny Poberezkin
75dfd725f4 android: build 19 (v1.4) 2022-03-30 08:18:08 +01:00
Evgeny Poberezkin
ea343b634d core: fix multiline mardown (#478)
* core: fix multiline mardown

* add test
2022-03-29 13:18:44 +01:00
Evgeny Poberezkin
41a2e0b1d5 Merge branch 'stable' 2022-03-29 12:54:42 +01:00
Evgeny Poberezkin
e52e516f5c core: update simplexmq (PING failure resets TCP connection, increase timeout to 5 sec) 2022-03-29 12:49:47 +01:00
Evgeny Poberezkin
ea29f93fb6 core: update simplexmq (PING failure resets TCP connection, increase timeout to 5 sec) 2022-03-29 11:36:52 +01:00
Evgeny Poberezkin
eaa2f4cf04 terminal: send broadcast messages (#477) 2022-03-29 08:53:30 +01:00
Evgeny Poberezkin
954f729a30 update simplexmq (parallel resubscriptions) 2022-03-28 22:01:52 +01:00
JRoberts
d35e7da3e4 trigger new CI job 2022-03-28 22:27:05 +04:00
JRoberts
692f37daa2 core: message delete (#470) 2022-03-28 20:35:57 +04:00
Evgeny Poberezkin
e0f4855d0d android: version 1.5 (18) - includes foreground service 2022-03-27 12:16:05 +01:00
Evgeny Poberezkin
a11784c615 android: foreground service to receive messages (#454)
* android: foreground service to receive messages

* android: fix duplicate chat (caused by persistent state of the service)

* option to turn off background service

* fix: foreground service failing to start when the new user is created

* remove unused background manager
2022-03-26 16:49:08 +00:00
JRoberts
922ec2c045 Merge pull request #476 from simplex-chat/master (v1.4.0 terminal app) 2022-03-26 19:25:03 +04:00
JRoberts
262c999e5c terminal: version 1.4.0 2022-03-26 18:22:45 +04:00
Evgeny Poberezkin
14a5b680d7 core: update simplexmq (#475)
* core: update simplexmq

* update sha256map.nix
2022-03-26 13:47:47 +00:00
Evgeny Poberezkin
a316a95754 android: version 1.4 (17) 2022-03-26 13:25:01 +00:00
Evgeny Poberezkin
a81de493fe ios: version 1.4 (30) 2022-03-26 12:23:14 +00:00
JRoberts
bdb3bc0bd7 mobile: hide edit button (#474) 2022-03-26 15:08:42 +04:00
JRoberts
8b2ae2d426 terminal: version 1.3.4 2022-03-26 10:49:36 +04:00
Evgeny Poberezkin
013a7322d2 ios: fix chat scrolling crashing the app (#472) 2022-03-25 20:02:40 +00:00
Evgeny Poberezkin
e92f960a87 clean-up logo (#471) 2022-03-25 19:34:04 +00:00
JRoberts
0b45ddfc79 mobile: message update (restore #460) (#469) 2022-03-25 22:26:05 +04:00
JRoberts
897c64e0ba core: use existential connection request type in file invitations to allow switching groups to "contact" requests (restore #464) (#468) 2022-03-25 22:23:51 +04:00
JRoberts
26558dfaca profile images (restore #423) (#466)
* core: configurable smp servers (#366)

* core: update simplexmq hash

* core: update simplexmq hash (fix SMPServer json encoding)

* core: fix crashing on supplying duplicate SMP servers

* core: update simplexmq hash (remove SMPServer FromJSON)

* core: update simplexmq hash (merged master)

* core: profile images (#384)

* adding initial RFC

* adding migration SQL

* update RFC

* linting

* Apply suggestions from code review

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* refine RFC

* add avatars db migration to Store.hs

* initial chages to have images in users/groups

* fix protocol tests

* update SQL & MobileTests

* minor bug fixes

* add missing comma

* fix query error

* refactor and update  functions

* bug fixes + testing

* update to parse base64 web format images

* fix parsing and use valid padded base64 encoded image

* fix typos

* respose to and suggestions from review

* fix: typo

* refactor: avatars -> profile_images

* fix: typo

* swap updateProfile parameters

* remove TODO

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* initial changes to show profile images

* simple set up complete

* add initial shape of image getting (needs work)

* redesign

* ios, android: configurable smp servers (only model and api for android) (#392)

* example image picker placed in edit profile screen

* tidy up and allow encoding

* more tidying

* update bottom modal bar

* v0.1 UI for upload ready

* add api calls

* refactor edit profile screen

* complete the refactor with connection back to api

* linting

* update encoding for hs compat

* no line wrapping and resize image

* refactor and tidy up for cleanest compatability with haskell

* ios: UI for editing images

* crop image to square

* update profile edit layout

* fixing image preview orientation etc

* allow expandable image in profile view

* handle case where user exits camera rather than take image

* housekeeping on when to call apiUpdateProfileImage

* improve scaling of large image

* linting

* spacing

* fix padding

* revert whitespace change

* tidy up, one remaining issue

* refactor to get parsing working

* add missed change

* use custom modal in user profile

* fix image size after scaling

* scale image iteratively

* add filter

* update profile editing view

* ios: edit profile image (TODO aspect ratio)

* ios: UI to manage profile images

* ios: use new profile api

* android: use new api to update profile

* android: scroll profile view up when editing

* revert change

* reduce profile image resolution to 104px to fit in 12.5kb

Co-authored-by: IanRDavies <ian_davies_@hotmail.co.uk>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-03-25 22:13:01 +04:00
Evgeny Poberezkin
ff32a44345 trigger new CI job 2022-03-24 11:01:22 +00:00
Evgeny Poberezkin
d4925b7cdd core: api to update user profile in one request (#461) 2022-03-23 20:52:00 +00:00
Evgeny Poberezkin
3c81a44273 message update and delete (#451)
* core: message update and delete, protocol and command syntax

* edit logic wip

* message updates

* revert project.pbxproj

* corrections, dependency, editable

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-03-23 15:37:51 +04:00
Evgeny Poberezkin
319b4dc841 bump haskell.nix (#459)
Co-authored-by: Moritz Angermann <moritz.angermann@gmail.com>
2022-03-23 08:47:36 +00:00
Evgeny Poberezkin
71483b0fc4 update simplexmq 2022-03-22 08:07:52 +00:00
Evgeny Poberezkin
366b84d3fa use simplexmq with TCP keep-alive instead of SMP PINGs (#457)
* use simplexmq with TCP keep-alive instead of SMP PINGs

* update simplexmq

* sha256nix
2022-03-21 17:15:25 +00:00
Evgeny Poberezkin
22dc68ff4e ios: update dummy.m to work with x86 sim, upgrade libraries (#458)
* ios: update dummy.m to work with x86 sim

* add condition for CPU arch to dummy.m
2022-03-21 08:43:34 +00:00
Evgeny Poberezkin
4903966bea update nix dependencies config 2022-03-20 16:41:04 +00:00
Evgeny Poberezkin
f43c462907 ios: load chat from db synchronously to avoid occasional empty chats (#453) 2022-03-19 17:20:27 +00:00
Evgeny Poberezkin
490dc17571 Merge PR #450 - v1.3 release
merge v1.3 to stable
2022-03-19 09:17:35 +00:00
Evgeny Poberezkin
b57a77c8f0 Merge branch 'stable' 2022-03-19 09:05:30 +00:00
Evgeny Poberezkin
fe0e5e8b89 terminal: version 1.3.3 (#447)
* terminal: show version from .cabal file

* update welcome message

* terminal: helo on message quotes

* terminal: allow replies in groups without specifying a member

* core: update version to 1.3.3
2022-03-19 09:04:53 +00:00
Evgeny Poberezkin
3340bea150 core: api to remove profile image (#448) 2022-03-19 07:42:54 +00:00
Evgeny Poberezkin
0e73697ea4 mobile: show app version/build, update settings, update build number (16: android, 28: ios) (#445) 2022-03-18 09:23:01 +00:00
sh
4fcbec49c9 readme: add fdroid badge (#446) 2022-03-18 08:21:36 +00:00
Evgeny Poberezkin
01994d8c6a android: fix message entry size after sending emoji, build 15 2022-03-17 18:01:47 +00:00
Evgeny Poberezkin
31de7fd0ee mobile: update version/build 1.3 (ios: 27, android 14) 2022-03-17 10:34:31 +00:00
Evgeny Poberezkin
744c451927 mobile: message actions (reply, share, copy) (#431)
* ios: add context menu to messages

* ios: UI for replies with quotes

* fix: scrolling crashing in chat

* ios: UI for message replies with quotes

* android: UI for message replies

* android: messages with quotes

* android: update imports

* android: refactor ChatItemView

* remove comments
2022-03-17 09:42:59 +00:00
Evgeny Poberezkin
148474e1ba core: change quoted messages types/protocol (#443)
* core: change quoted messages types/protocol

* remove comments and unused field

* rename CIQuote type

* change type for quote direction to allow unknown group member, use QuotedMsg to save received chat item

* change queries of quoted items when the sending group member is known

* refactor

* fix: make ciQuote polymorphic
2022-03-16 13:20:47 +00:00
Evgeny Poberezkin
d4765bcfec Merge branch 'stable' 2022-03-14 21:04:05 +00:00
Evgeny Poberezkin
e4ea2035ff android: fix app crashing on opening chats, build 12 (#439) 2022-03-14 21:03:36 +00:00
Evgeny Poberezkin
3a28bacf14 Merge branch 'stable' 2022-03-14 21:01:54 +00:00
Evgeny Poberezkin
6ba7d208c8 terminal: version 1.3.2 (#442) 2022-03-14 20:58:53 +00:00
Mark Aleksander Hil
102fdf3b18 mobile: update copy, fix typo (#440)
* Updated copy and fixed typo

* Updated copy and fixed typo
2022-03-14 20:58:19 +00:00
Evgeny Poberezkin
1f539fc8be hide secrets in notifications, closes #416 (#424)
* terminal: hide secrets in notifications #416

* ios: hide secrets in notifications

* android: hide secrets in notifications
2022-03-13 20:13:47 +00:00
Evgeny Poberezkin
806f417e99 message replies and chat item references (#394)
* rfc for message replies and chat item references

* update replies rfc

* save received/sent shared message ids, migration and types for replies

* include reply/forward into MsgContent type

* add sharedMsgId to CIMeta

* save/get shared_msg_id to/from chat items table

* parameterize CIRef by chat type

* add CIRef to ChatItem when it is read from the db

* terminal command to send message replies

* include quoted content into chat items

* quoted message direction in direct chats (TODO test)

* test for replies with quotes to group messages - own and others

* split MsgContainer from MsgContent

* make quoting usable in the terminal

* add formattedText to quotes

* rename migration

* update JSON encoding for MsgContainer

* allow quoted replies to messages from clients not supporting it/not sending msg IDs

* update rfc

* fix group replies

* add APISendMessageQuote and use it for terminal commands

* change how quoted messages are shown in groups
2022-03-13 19:34:03 +00:00
IanRDavies
6c04184a9c core: filter contacts on connection status before broadcasting profile updates (#430)
* filter contacts on connection status before broadcasting profile updates

* catch and report errors when notifying contacts about profile updates

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-03-12 20:57:11 +00:00
Evgeny Poberezkin
22ff17aec9 Merge PR #434 - v1.2 release 2022-03-12 20:28:43 +00:00
Evgeny Poberezkin
b2650947a9 android: update build (11) 2022-03-12 17:24:29 +00:00
Evgeny Poberezkin
604bf0c485 android: smaller fonts, bigger line height (#433) 2022-03-12 16:57:30 +00:00
Evgeny Poberezkin
b7bf3678e5 fix: markdown and links interaction/copy in messages (#432) 2022-03-12 16:52:04 +00:00
Evgeny Poberezkin
b0430f7eee android: update version 10 (1.2) 2022-03-11 19:11:52 +00:00
Evgeny Poberezkin
7d3e440a47 ios: update build (26) 2022-03-11 18:24:38 +00:00
Evgeny Poberezkin
6877261b9c ios: fit smaller screens (#429)
* ios: fit smaller screens

* s/or/and/
2022-03-11 17:52:11 +00:00
Evgeny Poberezkin
eef45a6015 ios: update haskell lib, version 1.2 (25) 2022-03-11 11:32:57 +00:00
Evgeny Poberezkin
0aee431527 update readme 2022-03-11 07:37:13 +00:00
John Roberts
90a18186d9 configurable smp servers (#366, #411); core: profile images (#384)
* core: configurable smp servers (#366)

* core: update simplexmq hash

* core: update simplexmq hash (fix SMPServer json encoding)

* core: fix crashing on supplying duplicate SMP servers

* core: update simplexmq hash (remove SMPServer FromJSON)

* core: update simplexmq hash (merged master)

* core: profile images (#384)

* adding initial RFC

* adding migration SQL

* update RFC

* linting

* Apply suggestions from code review

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* refine RFC

* add avatars db migration to Store.hs

* initial chages to have images in users/groups

* fix protocol tests

* update SQL & MobileTests

* minor bug fixes

* add missing comma

* fix query error

* refactor and update  functions

* bug fixes + testing

* update to parse base64 web format images

* fix parsing and use valid padded base64 encoded image

* fix typos

* respose to and suggestions from review

* fix: typo

* refactor: avatars -> profile_images

* fix: typo

* swap updateProfile parameters

* remove TODO

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* ios, android: configurable smp servers (only model and api for android) (#392)

* android: configurable smp servers (ui)

* fix thumb color, fix text field color in dark mode

* update simplexmq hash (configurable servers in master)

Co-authored-by: IanRDavies <ian_davies_@hotmail.co.uk>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-03-10 15:45:40 +04:00
IanRDavies
38aea7c455 use relative sizing when scaling the QR code (#417)
* use relative sizing when scaling the QR code

* linting

* properly implement image scaling

* remove extra horizontal padding

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-03-10 10:36:21 +00:00
Evgeny Poberezkin
e272048f24 ios: date/time formatting now respects locale settings (#420) 2022-03-09 22:35:33 +00:00
Evgeny Poberezkin
6aa9f208ee Merge pull request #418 from simplex-chat/id/android/fix-chat-scrolling
id/android/fix chat scrolling
2022-03-09 20:06:01 +00:00
IanRDavies
b749bf7b08 fix scrolling with keyboard 2022-03-09 18:54:19 +00:00
IanRDavies
ff3daed4c6 fix scrolling issue using save/load state 2022-03-09 16:30:47 +00:00
IanRDavies
e90e10bd26 add variable to monitor scrolling as scroll fix 2022-03-09 15:56:08 +00:00
Evgeny Poberezkin
c6a49b048f Merge pull request #410 from simplex-chat/master
AppStore 1.1 release (build 24)
2022-03-08 15:57:56 +00:00
Evgeny Poberezkin
29af079a8f ios: update build number (24), app store 1.1 submission - fixing iPhone 7 etc. 2022-03-08 15:19:14 +00:00
Evgeny Poberezkin
9bb6be8e60 update readme (#409) 2022-03-08 13:16:15 +00:00
Evgeny Poberezkin
226daa990f blog: apps announcement draft (#405)
* blog: apps announcement draft

* update mobile apps post

* update blog post

* add "what is simplex"
2022-03-08 12:23:08 +00:00
Evgeny Poberezkin
b8e3809452 Merge pull request #408 from simplex-chat/stable
merge stable back to master
2022-03-08 11:58:29 +00:00
Evgeny Poberezkin
47881f77d9 Merge pull request #407 from simplex-chat/angerman/bump-haskell-nix
bump haskell.nix to support iPhone 7
2022-03-08 10:32:55 +00:00
Moritz Angermann
69d0a5286e bump haskell.nix 2022-03-08 10:03:12 +00:00
Evgeny Poberezkin
ebdd78edea remove iPad support, update build # (23) 2022-03-08 08:46:48 +00:00
Evgeny Poberezkin
eff7c363d4 Merge pull request #403 from simplex-chat/master
app release
2022-03-07 16:07:16 +00:00
Evgeny Poberezkin
44cd482695 android: update version/build 0.4.2 (9) 2022-03-06 08:59:43 +00:00
Evgeny Poberezkin
a801e0c5e9 ios: build 22, add iPad support 2022-03-05 22:33:44 +00:00
Efim Poberezkin
722f836714 core: sort group messages by timestamp (#400) 2022-03-05 20:32:29 +04:00
Efim Poberezkin
1dd62be4ef Merge pull request #387 from simplex-chat/master (v1.3.1 terminal app) 2022-03-05 14:01:39 +04:00
Efim Poberezkin
33f731e247 1.3.1 2022-03-05 13:01:16 +04:00
Efim Poberezkin
7cf139f856 prepare v1.3.1 (#398) 2022-03-05 12:34:48 +04:00
IanRDavies
fd28c939f5 android: disable create button when display name is not valid (#396) 2022-03-04 14:51:25 +00:00
Evgeny Poberezkin
1ab68172cb ios: update version 1.1 (build 21) 2022-03-04 10:34:39 +00:00
Evgeny Poberezkin
87e9ae5a3e mobile: update verion/build: ios 1.0/21, android 0.4.1/8 2022-03-04 10:30:47 +00:00
Evgeny Poberezkin
c47a7d78fe support for unknown message content types (#395)
* android: parse/serialize unknown chat items

* ios: more resilient decoding of MsgContent

* core: preserve JSON of unknown message content type in MCUknown, so it can be parsed once it is supported by the client
2022-03-03 08:32:25 +00:00
Evgeny Poberezkin
b10b3a3434 ios: update libs 2022-03-02 20:15:22 +00:00
Efim Poberezkin
9d4de4b295 core: correctly set "yes to migrations" in agent config (#393) 2022-03-02 22:18:14 +04:00
Evgeny Poberezkin
2e5b123749 update simplexmq 2022-03-02 16:04:06 +00:00
Evgeny Poberezkin
3a6eaa3ddd android: update build number (7) 2022-03-02 15:57:21 +00:00
Evgeny Poberezkin
eb42d739cb android: update version/build 0.4 (6) 2022-03-02 07:11:20 +00:00
Evgeny Poberezkin
e5d5bd5ec8 android: disable background loading 2022-03-01 21:39:37 +00:00
Evgeny Poberezkin
24166a4271 android: background loading 2022-03-01 21:26:47 +00:00
Evgeny Poberezkin
232149817e ios: fix alerts, build 20 (#390) 2022-03-01 18:05:05 +00:00
Moritz Angermann
a286834eb5 bump nixpkgs 2022-03-01 16:04:54 +00:00
Evgeny Poberezkin
c500616bb4 update privacy policy, build number (#389) 2022-03-01 08:45:54 +00:00
Evgeny Poberezkin
42faa2e75b patch getentropy (#388)
* bump nixpkgs

* patch entropy

* bump haskell.nix

* remove file

Co-authored-by: Moritz Angermann <moritz.angermann@gmail.com>
2022-03-01 07:01:52 +00:00
Evgeny Poberezkin
b19cf35d28 iOS: v1 AppStore release (#386)
* fix: disable create when display name is empty

* update version/build 1.0 (17)

* update build number (18)

* terms and privacy policy
2022-02-28 20:45:31 +00:00
Evgeny Poberezkin
4585c7b649 ios: update build version (16) 2022-02-28 14:31:55 +00:00
Evgeny Poberezkin
326a2a1877 ci: add x86 packages (#383) 2022-02-28 13:52:20 +00:00
Efim Poberezkin
8d057613f5 core: update default servers (#385) 2022-02-28 16:27:55 +04:00
Evgeny Poberezkin
0b00c2ad76 android: receiving messages in background; ios: background task completion (#382)
* android: receiving messages in background; ios: background task completion

* complete receiving and sending messages in background
2022-02-28 10:44:48 +00:00
Evgeny Poberezkin
310f56a9b3 update version/build: ios 0.4 (15), android 0.3 (5) 2022-02-27 19:25:40 +00:00
Evgeny Poberezkin
0a94e740d2 android: refactor modal views without navigation controller (#381)
* android: refactor modal views without navigation controller

* refactor navigation

* make alert manager global

* disable CRPendingSubSummary in terminal, hamburger menu instead of gear
2022-02-27 18:16:38 +00:00
IanRDavies
3f3a503def android: notifications (#369)
* minimal implementation of notifications and broken framework for background check for messages

* linting and need different id to have multiple messages

* working notification on new messages

* add autocancel to notifications

* add rudimentary linking to chat from notification

* group notifications from the same chat

* clarify comment

* revert to working version

* refactor

* minors

* two channels, silent and shouty

* rudimentary state control for notifications

* check if running in foreground

* more elegant solution to don't notify if in chat

* tidy up DisposableEffect use

* change message notification priority to high

* nuke opt-ins

* navigation via notification occasionally works with race condition (WIP)

* notification navigation is working; remove chat list/view from navigation; refactor ChatListNavLinkView

* group all simplex notifications, only show the latest message per chat, notification icons

* increase time to 30 sec

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-27 12:14:26 +00:00
Evgeny Poberezkin
0413865a3b ios, core: fix add contact screen, add logging, additional chat events (#380)
* ios, core: fix add contact screen, add logging, additional chat events

* fix alert dialogues

* fix precedence parsing error

* update alert messages
2022-02-26 20:21:32 +00:00
Efim Poberezkin
98268a95c2 Merge pull request #379 from simplex-chat/master (v1.3.0 terminal app) 2022-02-26 17:25:05 +04:00
Evgeny Poberezkin
1110a78e06 update versions/build #s: ios 0.4 (14), android 0.3 (4) 2022-02-26 13:10:47 +00:00
Efim Poberezkin
6086f76d95 1.3.0 2022-02-26 16:41:11 +04:00
Efim Poberezkin
268eaaa9ca prepare v1.3.0 (#378) 2022-02-26 16:24:56 +04:00
Evgeny Poberezkin
ad1612d84a android: markdown help (#376) 2022-02-26 15:35:51 +04:00
Evgeny Poberezkin
0389a58f64 core: fix failing subscriptions when user address is missing (#377)
* core: fix failing subscriptions when user address is missing

* set concurrency limit on subscriptions
2022-02-26 10:04:25 +00:00
Evgeny Poberezkin
6ee2f334f6 update build number (12) 2022-02-26 08:24:58 +00:00
Evgeny Poberezkin
a5afdf4e91 ios: update version/build 0.4 (11) 2022-02-25 21:57:05 +00:00
Evgeny Poberezkin
ecaa570ff3 free C strings (#375) 2022-02-25 21:07:36 +00:00
Evgeny Poberezkin
1d2d1e6df7 process subscription summaries in ios/android (#374) 2022-02-25 20:26:56 +00:00
Efim Poberezkin
c242f0079c core: add fks to messages (#368) 2022-02-25 21:59:35 +04:00
Evgeny Poberezkin
727c533f93 update build number to 0.2 (3) 2022-02-25 15:29:52 +00:00
Efim Poberezkin
5961b7d951 asynchronously subscribe to user connections (#310)
* asynchronously subscribe to user connections

* send subscription status summaries to view/api

* refactor

* add help messages in summaries

* update simplexmq

* rename config field

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-25 12:29:36 +00:00
Evgeny Poberezkin
bbab069bcd android: replace while true loop with async recursion (#371) 2022-02-25 11:37:47 +00:00
Evgeny Poberezkin
1cf3b776d7 ios: use core markdown parser, also make messages in android selectable (#372)
* ios: use core markdown parser, also make messages in android selectable

* remove bold font from members in previews

* markdown help

* text selection
2022-02-25 07:16:19 +00:00
Evgeny Poberezkin
1aa2643c18 android: show member names in group messages (#370)
* android: show member names in group messages

* refactor
2022-02-24 18:02:59 +00:00
Evgeny Poberezkin
1150c04298 ios: process commands and messages asynchronously, on the background thread (#367)
* ios: process commands and messages asynchronously, on the background thread

* move model updates to main thread
2022-02-24 17:16:41 +00:00
Efim Poberezkin
9c57ab5221 android: user address; fix add and connect contact views dark mode; chat list view styling (#359) 2022-02-24 12:58:59 +04:00
IanRDavies
3e61b8c21a ios: display name validation (#364)
* try to add warning text if display name has whitespace

* simplify

* layout/error icon

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-24 08:45:19 +00:00
IanRDavies
99bed645f3 android: more welcome styling (#363)
* spacing and size updates to welcome page

* spacing and allow space for keyboard
2022-02-24 08:01:41 +00:00
Evgeny Poberezkin
51f5982205 markdown: parse emails and phone numbers (#365)
* markdown: parse emails and phone numbers

* phone parsing

* refactor
2022-02-24 07:55:18 +00:00
Evgeny Poberezkin
b7a06dd0cf show date on the same line as the message if space allows (#362) 2022-02-23 21:48:48 +04:00
IanRDavies
e071e4cdbf add check for whitespace in display name (#360) 2022-02-23 12:40:50 +00:00
Evgeny Poberezkin
470b18786e android: show markdown in messages (#361)
* android: show markdown in messages

* empty line
2022-02-23 12:30:48 +00:00
Evgeny Poberezkin
8f21453e82 fix markdown type for Colored, add types/parsing for formatted text to iOS/android (#358) 2022-02-23 08:45:49 +00:00
Evgeny Poberezkin
fb76917ec3 android: update version/build 0.2 (2) 2022-02-23 07:35:22 +00:00
Evgeny Poberezkin
c53500812c android: fix bottom sheet delay and graying out the rest of the screen (#356) 2022-02-22 20:52:02 +00:00
Evgeny Poberezkin
5e6b9e578b smaller size for unread count, show 1000s as Ks (#355) 2022-02-22 19:43:29 +00:00
Efim Poberezkin
f9c495a596 android: help view (#351)
* android: help view

* chats loaded

* remove comment
2022-02-22 19:38:47 +00:00
IanRDavies
e4ec8cccfd android: theme welcome view page (#354)
* initial theming changes

* styling work round 1
2022-02-22 19:32:43 +00:00
IanRDavies
3be350786d android: update logo (#350)
* add updated icon assets

* pure white splash
2022-02-22 19:31:38 +00:00
Evgeny Poberezkin
7cd43de5d5 Merge pull request #353 from simplex-chat/master
v1.2.1 terminal app
2022-02-22 19:28:17 +00:00
Efim Poberezkin
f698a05d53 1.2.1 2022-02-22 22:21:12 +04:00
Efim Poberezkin
518a15934f prepare v1.2.1 2022-02-22 22:20:32 +04:00
Evgeny Poberezkin
48dbd079cf core: improve markdown parsing and recognise URIs (#352) 2022-02-22 22:18:35 +04:00
IanRDavies
efa22715d5 android: unread message counter (#348)
* add unread counter to chats

* run unread clear on message view for more than a second

* track minUnreadItemId
2022-02-22 15:07:55 +00:00
Evgeny Poberezkin
0d88fcc758 core: send parsed markdown via API (#349) 2022-02-22 14:05:45 +00:00
Efim Poberezkin
353e04bddd android: settings drawer, dark mode user profile view, dark mode previews (#347) 2022-02-22 17:08:42 +04:00
Evgeny Poberezkin
0a6c03079c android: use IconButton (#346) 2022-02-22 08:07:27 +00:00
Evgeny Poberezkin
a0a4549045 android: improve chat, chat info, console (#344)
* bigger fonts, text entry layout

* resize scroll area when keyboard appears; automatically scroll on new messages

* fix message entry in dark mode

* imporove console layout

* fix chat info with dark mode

* fix typo

* clean up

* remove unused time formatter
2022-02-22 07:46:42 +00:00
IanRDavies
69c79c5e0a android: splash screen (to avoid showing welcome screen before the user is loaded) (#345)
* initial attempt -- not recomposing

* change to mutable state, still not working

* two state works, why not three?

* fix so we actually change state

* remove unnecessary brackets

* refactor

* using Boolean? for userCreated

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-22 07:29:41 +00:00
Efim Poberezkin
1edf60362e android: UserProfileView (#341)
* android: update user profile view logic

* indentation

* format

* UserProfileView

* remove prints

* empty line

* undo format

* change by value

* separate layout

* layout

* unconditionally editProfile = false

* add header and close button to profile page, add links to "settings"

* use generic navigate in settings, remove terminal button from the list of chats

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-21 20:09:51 +00:00
Evgeny Poberezkin
739990c732 terminal: make input responsible for echo to keep commands synchronous (as in mobile) and avoid echo delays (#343)
* terminal: make input responsible for echo to keep commands synchronous (as in mobile) and avoid echo delays

* use echo

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-02-21 12:05:00 +00:00
Evgeny Poberezkin
c9cfead9bc android: refactor sum types (#342) 2022-02-21 09:10:51 +00:00
Evgeny Poberezkin
d37f493c6a android: add chat info page, delete contacts, show network connection status for contacts, improve error handling 2022-02-20 21:17:24 +00:00
Evgeny Poberezkin
b3153ae0fd align time format with iOS app, use kotlix-datetime only (#340) 2022-02-20 16:33:02 +00:00
IanRDavies
7fc5b833aa android: use deep links to connect (#339)
* simple case

* version almost working with true links

* show alerts in imperative way, like they were meant to

* connecting via links works

* add error handling to connections

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-20 15:47:24 +00:00
Evgeny Poberezkin
d48d4ed8f9 android app: connect via QR code (#338)
* connecting via QR code works

* add contact/scan qr code pages

* new chat sheet layout

* remove unused imports and some warnings
2022-02-19 22:22:07 +00:00
Efim Poberezkin
f57a7009a3 chat view layout (#335)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-19 17:22:52 +04:00
Evgeny Poberezkin
6c4888d275 android app: API, add chat sheet and view with QR code (#336)
* add contact (WIP)

* basic UI to create new chat, finalize API classes and functions (TODO: process chatRecvMsg messages)

* add contact layout with QR code

* refactor NewChatSheet to split layout, refactor withApi

* add newlines

* Update apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt

Co-authored-by: IanRDavies <ian_davies_@hotmail.co.uk>

Co-authored-by: IanRDavies <ian_davies_@hotmail.co.uk>
2022-02-19 10:15:18 +00:00
IanRDavies
3820d08af8 chat list styling round 2 (#334)
* initial restyling:

* polish styling a little

* lint

* more linting

* add dependency

* add time to messages when they exist

* if no chat items show time from time chat created

* playing with colours

* rename shared colour

* flip title text colour in dark mode
2022-02-18 16:55:50 +00:00
Evgeny Poberezkin
bba2783aa4 update model when messages arrive (#333)
* update model when messages arrive

* update chat in the list when message is added

* copy methods with optional parameters

* use data classes to have pre-defined copy methods
2022-02-18 14:33:55 +00:00
IanRDavies
f650308986 initial chat list styling (#332) 2022-02-18 13:10:24 +00:00
Efim Poberezkin
bd13181042 platform independent json encoding for db (#330) 2022-02-18 14:05:11 +04:00
Evgeny Poberezkin
6daad10210 make condition depend on host os (#329) 2022-02-18 09:00:21 +00:00
Evgeny Poberezkin
52f758c6e1 make chat model not nullable (#328)
* make chat model not nullable

* parse datetimes

* smart constructors for TerminalItem
2022-02-17 21:52:37 +00:00
Evgeny Poberezkin
290a88fd90 list of chats and chat messages (#327) 2022-02-17 20:30:21 +00:00
Evgeny Poberezkin
423f54e95d chats in android app (#324)
* view placeholders for chats list and chat views

* classes for chats

* set the user to the model

* use Long for IDs

* chats/messages API (not working yet)

* android api works

* line breaks
2022-02-17 17:15:49 +00:00
IanRDavies
9e46b5117d Id/conditional nav on launch (#326)
* add initial conditional routing -- create user not working

* only one nav controller

* user check on launch works (kind of)

* Apply suggestions from code review

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-17 17:07:58 +00:00
IanRDavies
e8ff6f509b Id/android navigation edits (#325)
* add ids to terminalitems and work with these

* remove unnecessary logging
2022-02-17 10:52:56 +00:00
Evgeny Poberezkin
e7e777ec7b 2 spaces holy war (#323) 2022-02-17 09:15:54 +00:00
Evgeny Poberezkin
f74f932dcd pass IOS devine via GHC options in flake.nix (#322) 2022-02-17 08:40:08 +00:00
Evgeny Poberezkin
7fafb25821 rename file in android app 2022-02-17 08:22:16 +00:00
Evgeny Poberezkin
dd256be4ec use tagged JSON on android, update tests (#321) 2022-02-16 23:24:48 +00:00
Evgeny Poberezkin
d743804b1d update android api to call haskell off main thread (#320) 2022-02-16 21:31:22 +00:00
Evgeny Poberezkin
f8951b44fc use sync commands (#319) 2022-02-16 20:31:26 +00:00
Evgeny Poberezkin
ec70670630 update condition in cabal file 2022-02-16 20:11:29 +00:00
Evgeny Poberezkin
ee07921d42 update cabal file - GHC option for android 2022-02-16 18:49:48 +00:00
Efim Poberezkin
5548494a44 update simplexmq sha (#318) 2022-02-16 22:18:27 +04:00
IanRDavies
7c8ad4aee4 Android compose navigation (#316)
* initial rough ideas

* refactor and put in high level navigation

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-16 18:00:59 +00:00
Evgeny Poberezkin
12b4325435 switch to the new API (does not work) (#317)
* switch to the new API (does not work)

* kind of works without parsing JSON
2022-02-16 17:36:49 +00:00
Evgeny Poberezkin
241d02584a use different names for different build bundles (#315) 2022-02-16 13:22:36 +00:00
Evgeny Poberezkin
0f450fd9bf update readme (#314)
* update readme

* update README.md

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-02-16 13:00:27 +00:00
Evgeny Poberezkin
ce02c514cf started android / compose app (#301)
* new compose project

* classes for chat command and response

* use val with get() for commands and responses

* chat model

* initial jetpack compose set up

* wire it up with chat

* first ability to send and receive messages

* refactor model/controller interface

* JSON samples

* terminal view with items

* playing around with json

* JSON serialization works

* parsing API responses in the terminal

* add subclass for contactSubscribed reponse

* remove android-poc

* remove JSON example

Co-authored-by: IanRDavies <ian_davies_@hotmail.co.uk>
2022-02-16 12:49:47 +00:00
Efim Poberezkin
322ab9d854 use async commands (#313)
* switch to async

* make tests pass
2022-02-16 12:48:28 +00:00
Efim Poberezkin
d40ee71a2c update simplexmq sha (#312)
* update simplexmq sha

* package build for iOS/Intel simulator

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-16 09:38:49 +00:00
Evgeny Poberezkin
c81bb0f15d iOS: show dates in older messages 2022-02-15 08:14:50 +00:00
Evgeny Poberezkin
b7fda194c8 update binaries in iOS app and build number (10) 2022-02-14 21:38:12 +00:00
Evgeny Poberezkin
c37f41c171 use sync commands (#306) 2022-02-14 19:36:15 +00:00
Efim Poberezkin
ced8d2a45f Merge pull request #305 from simplex-chat/master (v1.2.0 terminal app) 2022-02-14 22:41:33 +04:00
Efim Poberezkin
c580c34a35 1.2.0 2022-02-14 21:55:39 +04:00
Efim Poberezkin
fdf312d9e1 ios: add contactNotReady error type (#304) 2022-02-14 21:52:01 +04:00
Evgeny Poberezkin
44d8b549c4 return version number to mobile (#303) 2022-02-14 21:51:50 +04:00
Efim Poberezkin
928dd27043 prepare v1.2.0 (#302) 2022-02-14 21:21:16 +04:00
Efim Poberezkin
4419051347 connection precedence logic in getContact_ (fixes asynchronous establishment of connection) (#300) 2022-02-14 18:49:42 +04:00
Evgeny Poberezkin
8cf88019e5 ios public beta announcement (#298)
* ios public beta announcement

* update post

* corrections

* corrections

* update blog links

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-02-14 13:48:21 +00:00
Evgeny Poberezkin
710971a0cd show confirmation alert after the connection (#299)
* show confirmation alert after the connection

* update build number
2022-02-14 11:53:44 +00:00
Efim Poberezkin
dc306dfcd0 option to auto-accept contact requests (#296) 2022-02-14 14:59:11 +04:00
Mark Aleksander Hil
e90520a5ec update banner (#297) 2022-02-14 10:29:16 +00:00
Evgeny Poberezkin
7805bd1e45 show large unread numbers 2022-02-13 10:09:09 +00:00
Efim Poberezkin
c1c55ca700 deduplicate contact requests (#287)
* deprecate XContact

* XInfoId

* xInfoId tests

* merging

* saving on connection

* connectByAddress

* remove old connect

* deduplicate contact requests

* check on contact acceptance

* test

* rename response

* reuse CRContactRequestAlreadyAccepted

* Update src/Simplex/Chat.hs

* createConnReqConnection

* simplify controller logic

* store methods + profile change

* index

* more indices

* unXInfoId

* simplify

* XInfo with ID -> XContact

* sync reply to Connect when contact already exists

* update view for sync CRContactAlreadyExists command response

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-13 09:19:24 +00:00
Evgeny Poberezkin
8e34d2fbbc fix swift 2022-02-13 09:13:06 +00:00
Evgeny Poberezkin
61afb64dd7 search chats, longer emojis (#295)
* search chats, longer emojis

* simplify
2022-02-13 08:45:08 +00:00
Evgeny Poberezkin
aa2bc545db update build number (8) 2022-02-12 18:02:52 +00:00
Evgeny Poberezkin
067f122b05 iOS app version 0.3.1 2022-02-12 17:28:37 +00:00
Evgeny Poberezkin
9d9bb68d50 iOS: show message sent/unread status (#293)
* light github image for dark mode

* show message received status, remove chevrons in chat list

* show unread message status

* add message send error mark

* refactor alerts to use AlertManager

* show alert message on tapping undelivered message, simplify text-only alerts
2022-02-12 15:59:43 +00:00
Efim Poberezkin
af5abae558 fix group leave (#294)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-12 13:17:11 +04:00
Efim Poberezkin
c59caa5d7f Merge pull request #292 from simplex-chat/master
v1.1.1 terminal app, v0.3 iOS app
2022-02-11 13:06:40 +04:00
Efim Poberezkin
0ea8705014 1.1.1 2022-02-11 12:05:22 +04:00
Efim Poberezkin
92409820fb enable async commands (#290)
* enable async

* fix async command error response

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-11 12:03:34 +04:00
Evgeny Poberezkin
98fc6c6adf chat usage help and minor UI fixes (#291)
* chat usage help and minor UI fixes

* update version, build and binary
2022-02-11 07:42:00 +00:00
Efim Poberezkin
771bc6a14d prepare v1.1.1 (#289) 2022-02-10 20:08:29 +04:00
Evgeny Poberezkin
86c36f53e4 simplify and fix background loading (#288)
* simplify and fix background loading

* start receive loop in the main chat
2022-02-10 15:52:11 +00:00
Efim Poberezkin
5c24089f9f check group member connection status before delivery; best effort delivery per group member (#286) 2022-02-10 17:03:36 +04:00
Evgeny Poberezkin
516c8d79ad receiving messages in the background and sending local notifications (#284)
* receiving messages in the background and sending local notifications

* show notifications in foreground and background

* presentation logic for notification options when app is in the foreground

* background refresh works

* remove async dispatch
2022-02-09 22:53:06 +00:00
Efim Poberezkin
ff7a8cade1 test chat items (#285) 2022-02-09 20:58:02 +04:00
Efim Poberezkin
7af4cdffee add unreadCount and minUnreadItemId stats to Chat type (#283) 2022-02-08 20:38:57 +04:00
Efim Poberezkin
b06838b651 add APIChatRead chat command (#282) 2022-02-08 17:27:43 +04:00
Evgeny Poberezkin
b3a4c21c4b updated text items (#278)
* updated text items

* update version

* fix JSON parsing in CIDirection, refactor data samples

* show group member in received messages and chat preview

* use profile displayName instead of localDisplayName, do not show fullName if it is the same as displayName
2022-02-08 09:19:25 +00:00
Efim Poberezkin
855881094b add CRContactConnecting api response (#281) 2022-02-08 13:04:17 +04:00
Efim Poberezkin
82d02e923a ios: add CIStatus type (#280) 2022-02-08 11:20:41 +04:00
Efim Poberezkin
d11d66fa90 connection precedence logic in getDirectChatPreviews_; update item status in object (#279) 2022-02-07 18:34:54 +04:00
Efim Poberezkin
f5507436f3 chat item status, CRChatItemUpdated api response (#269) 2022-02-07 15:19:34 +04:00
Evgeny Poberezkin
eeea33c7cb fix loading chat, contact connection status info (#277) 2022-02-07 10:36:11 +00:00
Evgeny Poberezkin
7883ca7657 improve text message view (#276)
* show text and time on the same line

* convert emails and phones to links
2022-02-06 21:06:02 +00:00
Evgeny Poberezkin
8efb8b2f86 use simplified chat controller, fix keyboard removing on tap (#275) 2022-02-06 18:26:22 +00:00
Evgeny Poberezkin
408a30c25b simplify mobile API to have single controller (#274)
* simplify mobile API to have single controller

* update chat response in swift

* add async to stack
2022-02-06 16:18:01 +00:00
Evgeny Poberezkin
9b67aa537a each command takes lock if it needs it (#273) 2022-02-06 08:21:40 +00:00
Evgeny Poberezkin
5aabf87898 ios: highlight URLs in texts (#272)
* ios: highlight URLs in texts

* Apply suggestions from code review
2022-02-06 07:44:41 +00:00
Evgeny Poberezkin
67dbdcd257 contact and server connection info (#271) 2022-02-05 20:10:47 +00:00
Evgeny Poberezkin
3d137995d8 multiline message entry field (#270) 2022-02-05 14:24:23 +00:00
Evgeny Poberezkin
e424e9328b large emojis, full contact names, contact createdAt, process profile updates, etc. (#268) 2022-02-04 22:13:52 +00:00
Evgeny Poberezkin
214ecf605b minor UI improvements (#267) 2022-02-04 16:31:08 +00:00
Evgeny Poberezkin
7d06d0660d Merge pull request #266 from simplex-chat/ep/fix-utf8-api
fix utf8 encoding for C API requests
2022-02-04 12:46:45 +00:00
Evgeny Poberezkin
c34eddb82a fix utf8 encoding for C API requests 2022-02-04 12:41:43 +00:00
Efim Poberezkin
9969606432 fix utf8 encoding when writing to database 2022-02-04 14:30:00 +04:00
Evgeny Poberezkin
d8abdb7927 Merge pull request #265 from simplex-chat/ep/sync-cmd
fix C string UTF8 encoding, revert to sync commands
2022-02-04 08:50:52 +00:00
Evgeny Poberezkin
71a60795cf Merge pull request #263 from simplex-chat/ep/ios-fixes
configure build for device/simulator
2022-02-04 08:17:18 +00:00
Evgeny Poberezkin
d07ce0b8f4 use 8 byte characters, as encoding is handled elsewhere 2022-02-04 08:15:25 +00:00
Evgeny Poberezkin
565bc70843 sync commands 2022-02-04 08:02:48 +00:00
Efim Poberezkin
7924861810 sort chat items by id (#264) 2022-02-04 11:12:12 +04:00
Evgeny Poberezkin
08dd92b726 configure build for device/simulator 2022-02-03 18:22:05 +00:00
Evgeny Poberezkin
dca5dc4fce iOS version 1.0.1 2022-02-03 07:18:17 +00:00
Evgeny Poberezkin
24f3637199 add animations (#260)
* add animations

* improve settings screen

* app icons
2022-02-03 07:16:29 +00:00
Efim Poberezkin
4dd95c1639 create release as prerelease; fix windows build (#261) 2022-02-03 10:15:38 +04:00
Efim Poberezkin
4724669bce prepare v1.1.0 (#259) 2022-02-02 23:50:43 +04:00
Evgeny Poberezkin
292c334460 make slow commands asynchronous (#258) 2022-02-02 21:47:27 +04:00
Evgeny Poberezkin
dafdf66ada update entity connection status to report it correctly (#257) 2022-02-02 17:01:12 +00:00
Evgeny Poberezkin
38424af48e refactor files, auto-scrollback for messages (#256) 2022-02-02 16:46:05 +00:00
Efim Poberezkin
88a33990b7 sort chats w/t items by time of creation; created_at & updated_at in all tables; merge v1.1 migrations (#255)
* merge migrations; timestamps

* contact created_at

* group, contact request created_at

* sort

* redundant imports
2022-02-02 16:25:36 +00:00
Evgeny Poberezkin
7ce305e16f ios: fix message view updates (refactor model to make it shallow) (#254) 2022-02-02 12:51:39 +00:00
Evgeny Poberezkin
1d1ba8607e send message integrity errors to view as a separate notification (#253) 2022-02-02 11:43:52 +00:00
Evgeny Poberezkin
9f6385f763 update connection status in entity used in controller notifications (#252)
* update connection status in entity used in controller notifications

* remove unused code
2022-02-02 11:31:01 +00:00
Evgeny Poberezkin
a68b591029 connect via link with simplex: protocol (#251) 2022-02-01 20:30:33 +00:00
Evgeny Poberezkin
711207743b add support for user addresses (#246)
* add support for user addresses

* started processing contact requests

* update command syntax

* fix: make Profile Codable

* accept/reject contact requests

* update API, accept/reject contact requests
2022-02-01 17:34:06 +00:00
Efim Poberezkin
a8a7bb3c99 return accepted contact from APIAcceptContact (#250) 2022-02-01 17:04:44 +04:00
Efim Poberezkin
228c118714 api for chat pagination (#249) 2022-02-01 15:05:27 +04:00
Evgeny Poberezkin
0b86402ce3 fix constructor name for JSON encoding (#248) 2022-02-01 07:16:02 +00:00
Evgeny Poberezkin
2295f7a92b update commands (#247) 2022-02-01 09:31:34 +04:00
Evgeny Poberezkin
8e03eefa9b update API commands syntax 2022-01-31 23:20:52 +00:00
Evgeny Poberezkin
53040dbe1d iOS: chats and messages layout (#241)
* iOS: chats and messages layout

* model update for updated API

* improve chat list view

* chat view layouts

* delete contacts

* larger headers, clean up, move message reception loop to ContentView

* settings: user profile
2022-01-31 21:28:07 +00:00
Efim Poberezkin
6d5b5ab44f getContactRequestChatPreviews_ (#245) 2022-01-31 22:43:39 +04:00
Efim Poberezkin
0a18985e68 contact requests api (#244)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-31 21:53:53 +04:00
Efim Poberezkin
047aa7deef delete contact api (#243)
* delete contact api

* chat command
2022-01-31 11:14:56 +00:00
Efim Poberezkin
945ed3f7cb fix queries returning duplicate contacts (#242) 2022-01-31 13:20:26 +04:00
Evgeny Poberezkin
e29ea99d2c getChats returns [Chat] with 0-1 item instead of [ChatPreview] (#240) 2022-01-30 21:51:23 +00:00
Evgeny Poberezkin
3b19aaf1d1 iOS: send/receive messages in chats, connect via QR code (#238)
* send messages from chats

* update API to use chat IDs

* send messages to groups

* generate invitation QR code

* connect via QR code
2022-01-30 18:27:20 +00:00
Evgeny Poberezkin
15a91278d6 API to send direct and group messages (#239)
* API to send direct and group messages

* update API parsing
2022-01-30 10:49:13 +00:00
Evgeny Poberezkin
cb602dd377 show received messages in chat, send command on Enter, fix Date parsing (#237)
* refactor UI and API, send command on Enter, fix Date parsing

* UI sheets to create connection and groups

* show received messages

* readme
2022-01-29 23:37:02 +00:00
Efim Poberezkin
7e2f365c1c ios: group chat preview (#235) 2022-01-29 20:35:20 +00:00
Evgeny Poberezkin
8425be0612 use aeson fork with nullableToObject option to make JSON compatible with Swift (#236) 2022-01-29 20:21:37 +00:00
Efim Poberezkin
c0199a38fd add readme for ios setup (#234) 2022-01-29 16:53:24 +04:00
Efim Poberezkin
d97a8c1934 getGroupChat, getGroupChatPreviews_ (#233) 2022-01-29 16:06:08 +04:00
Evgeny Poberezkin
7c36ee7955 swift API for chat, started chat UI (#228)
* started swift API for chat

* skeleton UI

* show all chat responses in Terminal view

* show chat list in UI

* refactor swift API
2022-01-29 11:10:04 +00:00
Efim Poberezkin
55dde3531e most recent chat items in getDirectChatPreviews_ (#232) 2022-01-28 19:24:31 +04:00
Evgeny Poberezkin
c3a8ae1eb5 chats API for mobile (#230)
Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-28 14:41:09 +04:00
Efim Poberezkin
edc9560d36 getDirectChat (#227)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-28 11:52:10 +04:00
Evgeny Poberezkin
37cfb93217 switch to JSON single field encodings for sum types to align with Swift enums (#229) 2022-01-27 22:01:15 +00:00
Evgeny Poberezkin
28ee40074a update sha256map.nix 2022-01-26 22:49:44 +00:00
Evgeny Poberezkin
0ba4598ca2 JSON encoding for ChatResponse and all other types used in mobile API (#226)
* JSON encoding for ChatResponse and all other types used in mobile API

* omit null corrId in response, refactor

* more JSON field names
2022-01-26 21:20:08 +00:00
Efim Poberezkin
ecb5b0fdeb add getChatPreviews to Store (#225) 2022-01-26 21:19:46 +04:00
Efim Poberezkin
6cf23f1fd1 chat items (#223)
* add chat items migration

* chat and chat items types

* queries draft

* ChatInfo with optional ChatItem

* schema adjustments

* flat schema and queries

* refactor ChatResponse using ChatItem types

* schema adjustments

* refactor GroupInfo to include GroupMember of the user

* remove Message

* createNewChatItem, sendDirectChatItem

* refactor to use GroupInfo in Chat type and all ChatResponses

* replace ContactName with Contact in some ChatResponse constructors

* remove Group selectors

* minor correction

* refactor

* refactor 2

* nullable created_by_msg_id

* remove normalized schema and queries

* ON DELETE CASCADE / SET NULL

* CIContent to Text

* files chat_item_id

* fix

* apply ciContentToText

* queries folder

* refactor

* moar refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-26 16:18:27 +04:00
Evgeny Poberezkin
b86f034c0b update C api to return JSON messages via chat_recv_msg (#224) 2022-01-24 22:52:55 +00:00
Evgeny Poberezkin
ce3d7f21b0 haskell nix flake CI (#222)
* Adds preliminary flake

This nix flake should be enough to try and build an android library.

* add sha256map

* bump

* bump index-state

Co-authored-by: Moritz Angermann <moritz.angermann@gmail.com>
2022-01-24 19:42:41 +00:00
Evgeny Poberezkin
b38d5f3465 started chat model (#221)
* started chat model

* refactor processing commands and UI events

* message chat event processing

* groups: delayed delivery of messages and introductions to announced members (#217)

* combine migrations, rename fields

* show all view messages vis ChatResponse type

* serialize chat response

* update C api

* remove unused extensions, fix typos

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-24 16:07:17 +00:00
Evgeny Poberezkin
a5ad0b185c use Haskell library (#220) 2022-01-22 17:54:22 +00:00
Evgeny Poberezkin
4f5e135992 test android app (#219) 2022-01-22 17:54:06 +00:00
Evgeny Poberezkin
50d83d2374 prepare v1.0.2 (#218)
* update dependencies

* update version and dependencies

* add tls@1.5.7 to stack.yaml

* update readme

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-21 18:58:43 +00:00
Evgeny Poberezkin
64381be91d export C interface, started mobile app (#210)
* initial mobile app design draft

* add proposals

* xcode project

* refactor function to send to view as parameter

* export C interface

* remove unused files

* run chat from chatInit

* split chatStart to a separate function

* replace file-embed with QQ

* add mobile views

* server using IP address

* pass dbFilePrefix as parameter to chatInit

* comment on enabling logging

* fix mobile db config

* update C API, make user non-optional in ChatController

* restore SMP server addresses

* revert the change in the tests

* flip dependency - now Controller depends on Terminal

* make ChatController independent of terminal package

* fix Main.hs

* add iOS .gitignore

* refactor Simplex.Chat.Terminal

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-21 11:09:33 +00:00
Evgeny Poberezkin
f47494e5c8 add loggin option to test 2022-01-20 20:23:21 +00:00
Efim Poberezkin
32a105bac8 fix Windows CI to fail when commands fail, use fixed terminal package (#214)
* fix windows CI

* use fixed terminal package

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-20 20:19:39 +00:00
Efim Poberezkin
65b17c9d18 add option to enable logging (#216)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-20 12:18:00 +04:00
Evgeny Poberezkin
aef159b097 readme: building from stable branch 2022-01-18 20:39:16 +00:00
Evgeny Poberezkin
d29a6542de 1.0.1 2022-01-18 20:19:05 +00:00
Evgeny Poberezkin
aef697e30a make tests independent of JSON fields order (#212) 2022-01-17 12:24:55 +00:00
Evgeny Poberezkin
fca063e131 Fork/fix terminal libary to work on Apple M1 (#211)
* use forked terminal with CapiFFI (fails to compile)

* update terminal package git tag

* add terminal fork to stack.yaml
2022-01-16 15:22:58 +00:00
Efim Poberezkin
8a859044cb fix explanation of server fingerprint (#207) 2022-01-13 10:23:34 +04:00
Evgeny Poberezkin
895e3878f9 add cabal.project (#205)
* add cabal.project

* update meta-data in package.yaml
2022-01-12 21:18:54 +00:00
Evgeny Poberezkin
b2556e3306 add blog (#187)
* blog: v1 release notes

* Update 20220112-simplex-chat-v1-released.md (#181)

* Update 20220112-simplex-chat-v1-released.md (#183)

updated intro and "journalist" description.

* add blog posts

* make corrections to v1 blog (#188)

* update 20210512-simplex-chat-terminal-ui.md (#192)

* Update 20210512-simplex-chat-terminal-ui.md

* Update 20210914-simplex-chat-v0.4-released.md

* Update 20210914-simplex-chat-v0.4-released.md

* update blog post

* add blog toc and old post

Co-authored-by: Mark Aleksander Hil <32651095+markaleksanderh@users.noreply.github.com>
Co-authored-by: Rob Chandhok <rob@chandhok.net>
Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-12 19:52:40 +00:00
Efim Poberezkin
eebc24086b fix installation script (#204) 2022-01-12 23:16:05 +04:00
Evgeny Poberezkin
94bbc44960 1.0.0 2022-01-12 18:06:30 +00:00
Evgeny Poberezkin
78712541f0 update "incompatible link" message 2022-01-12 18:01:57 +00:00
Evgeny Poberezkin
2b4bdf39fb Merge pull request #196 from simplex-chat/v1
v1
2022-01-12 17:44:05 +00:00
Evgeny Poberezkin
a8faaef54e team user address, remove onboarding 2022-01-12 17:37:46 +00:00
Evgeny Poberezkin
44bad8e093 rename migration file 2022-01-12 16:42:08 +00:00
Efim Poberezkin
a988ab84f9 restore bracket in readme 2022-01-12 20:40:29 +04:00
Evgeny Poberezkin
85e2013639 update simplexmq version 2022-01-12 16:39:08 +00:00
Evgeny Poberezkin
1978801561 add tests for group deletion; remove restrict constraints (#203)
* add tests for group deletion

* update constraints

* move index

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-12 16:32:22 +00:00
Mark Aleksander Hil
95a4da71cb update banner image (#202)
* Add files via upload

* Updated logo

Added line back in
2022-01-12 16:31:05 +00:00
Efim Poberezkin
f13a65ca85 update chat README for v1 (#201)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-12 16:24:41 +04:00
Evgeny Poberezkin
e87be44134 update server addresses 2022-01-12 11:54:40 +00:00
Evgeny Poberezkin
fb8dfa02f2 merge database migration, rename field in group_members: inv_queue_info to sent_inv_queue_info (#200) 2022-01-12 10:35:52 +00:00
Evgeny Poberezkin
67e0ca28a9 additional notifications (#199) 2022-01-12 06:55:04 +00:00
Evgeny Poberezkin
7438db0a7d update file chunk size (#198) 2022-01-12 06:07:49 +00:00
Evgeny Poberezkin
b47f064115 Simplex chat logo (#197)
* Updated SimpleX Chat logo

* Updated logo

Co-authored-by: Mark Aleksander Hil <32651095+markaleksanderh@users.noreply.github.com>
2022-01-11 21:28:15 +00:00
Efim Poberezkin
d9afc47993 update install.sh to check for v0 and ask to continue (#184)
* update install.sh to check for v0 and ask to continue

* pseudo code

* pseudo

* continue

* continue

* continue

* implement logic

* tab

* full path to agent db

* Update install.sh

* Update install.sh

* Update install.sh

* Update install.sh

* Update install.sh

* Update install.sh

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

* Update install.sh

* Update install.sh

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-11 21:26:51 +00:00
Evgeny Poberezkin
fcee108863 Merge branch 'master' into v1 2022-01-11 21:25:18 +00:00
Evgeny Poberezkin
5a74b8066f prepare v1 release (#189)
* update servers

* update version

* update simplexmq version

* update database file names

* update server fingerprints and simlexmq

* update simplexmq commit

* fix port in tests

* update tls fixtures (#193)

* add -v cli option; print update instructions on -v and /v (#194)

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-11 21:23:57 +00:00
Efim Poberezkin
809a87ce61 script to add message views to database (#195) 2022-01-11 23:22:59 +04:00
Evgeny Poberezkin
c2c05816f3 binary encoding for file chunks (#191) 2022-01-11 12:41:38 +00:00
Evgeny Poberezkin
cc4fff0ae5 tests for JSON message encoding/decoding (#190)
* tests for JSON message encoding/decoding

* fix XContact parsing to allow absent field "content"

* update XContact JSON encoding
2022-01-11 12:27:57 +00:00
Evgeny Poberezkin
be537f3a24 update chat protocol to use JSON encoding for chat messages (#182)
* started chat protocol

* text message example

* events json

* same style comments

* jsonc

* num for rendering

* try to fix comment rendering

* revert num

* chat protocol: make msg params closer to types

* AppMessage type

* combine new and old simplexmq dependencies

* json parsers

* version-compatible types for connection requests

* more parsers

* remove import

* decode/encode from/to AppMessage

* make group invitation a property in params

* switch chat to the new agent

* remove "compatibility" attempt

* new JSON encoding for chat messages

* simplexmq from github

* update MsgContent name

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-11 08:50:44 +00:00
Efim Poberezkin
7498cd4432 0.5.5 (#179) 2022-01-07 11:32:06 +04:00
Efim Poberezkin
5e545b639f update simplex-chat.cabal (#178) 2022-01-07 11:28:39 +04:00
Evgeny Poberezkin
1093b01e7e update simplex.md (#133)
* switch to ghc-8.10.7 (lts-18.17 resolver) (#125)

* update simplex.md

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-06 23:11:53 +00:00
Evgeny Poberezkin
44845ad563 refactor closure (#177) 2022-01-06 20:29:57 +00:00
Efim Poberezkin
1bfa7f1104 allow to repeat group invitation using saved queue info; recognize it's the same group at invitee (#176)
* naming; full names on start for groups

* allow to re-add member

* save and reuse connection request

* TODO

* wording

* index

* user id

* revert to listToMaybe . map fromOnly

* add to test

* fix null conversion

* Update src/Simplex/Chat.hs

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update src/Simplex/Chat.hs

* fix

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-06 23:39:58 +04:00
Evgeny Poberezkin
79658b3d8d update simplexmq to 0.5.2, update resolver (#175)
* groups when in status invited - list as invitations on /gs

* don't list on start

* test

* refactor

* getUserGroupDetails

* update simplexmq to 0.5.2, update resolver

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-06 16:03:45 +00:00
Efim Poberezkin
962287c439 unprocessed group invitations - highlight, print on start (#174) 2022-01-06 14:24:33 +04:00
Efim Poberezkin
ea89c9d8c8 groups when in status invited - list as invitations on /gs; do not list on start (#173) 2022-01-06 13:09:03 +04:00
Efim Poberezkin
7c723213c2 allow invitee to delete group when in status invited (#172) 2022-01-05 20:46:35 +04:00
Efim Poberezkin
f29614058a 0.5.4 (#171) 2021-12-30 18:35:39 +04:00
Efim Poberezkin
8033c8648b update README for v0.5.4 (#170) 2021-12-30 18:27:19 +04:00
Efim Poberezkin
3160a9559a don't broadcast x.grp.mem.del when removing group member with status "invited" (#169) 2021-12-30 17:36:24 +04:00
Efim Poberezkin
74cb3a3cc0 fix contact field in all_messages_plain view (#168) 2021-12-30 14:22:13 +04:00
Efim Poberezkin
f2735020e3 improve README instructions on querying messages (#167) 2021-12-30 13:21:34 +04:00
Efim Poberezkin
81f29d679b store messages (#166)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-12-29 23:11:55 +04:00
Efim Poberezkin
a7703209f2 change tests port (fix for port 5000 now in use on macOS) (#165) 2021-12-27 15:15:35 +04:00
Evgeny Poberezkin
6e48fe3f72 0.5.3 2021-12-24 11:36:04 +00:00
Evgeny Poberezkin
29b683329d show "upgrade" message on invalid connection request (#164) 2021-12-24 11:12:08 +00:00
Evgeny Poberezkin
e7f9e5a834 only use notify-send when present (#163) 2021-12-20 12:24:28 +00:00
Evgeny Poberezkin
66ab5bc424 0.5.2 2021-12-19 15:43:39 +00:00
Evgeny Poberezkin
279f8c5453 add CODEOWNERS (#160) 2021-12-19 15:25:19 +00:00
Evgeny Poberezkin
0e91f10851 fix welcome message type (#159) 2021-12-19 15:11:08 +00:00
Evgeny Poberezkin
4856f6e3e4 Update FUNDING.yml 2021-12-18 16:27:27 +00:00
Evgeny Poberezkin
0ccf431002 add simplex-chat.cabal file (#158) 2021-12-18 13:59:06 +00:00
Evgeny Poberezkin
433200bab9 0.5.1 2021-12-18 12:56:34 +00:00
Evgeny Poberezkin
9513a47860 update version to 0.5.1 (#157) 2021-12-18 12:54:38 +00:00
Evgeny Poberezkin
96176936e6 update welcome messages (#156)
* simple welcome message

* show welcome message only once

* show onboarding progress

* admin and groups

* show full group names with /gs command

* Update src/Simplex/Chat/Help.hs

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

* Update src/Simplex/Chat/Help.hs

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2021-12-18 10:23:47 +00:00
Evgeny Poberezkin
20e7feb953 simple welcome message (#152)
* simple welcome message

* show welcome message only once

* show onboarding progress
2021-12-13 12:05:57 +00:00
Evgeny Poberezkin
7fa671f829 show confirmation when invitation accepted or contact request sent (#150)
* show confirmation when invitation accepted or contact request sent

* refactor
2021-12-11 12:57:12 +00:00
Evgeny Poberezkin
1c2e49ae83 trim trailing whitespace, additional commands to list contacts and groups (#149) 2021-12-10 11:45:58 +00:00
Mark Aleksander Hil
2e56b3cb58 Added Reddit badge (#148)
* Added Reddit badge

* Update README.md

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2021-12-09 12:45:42 +00:00
Evgeny Poberezkin
642cec3092 0.5.0 2021-12-08 20:21:27 +00:00
Evgeny Poberezkin
1564424f0d update readme and version in code (#147)
* update readme and version in code

* Update README.md

* Update README.md

Co-authored-by: Patryk Laurent <plaurent@me.com>

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
Co-authored-by: Patryk Laurent <plaurent@me.com>
2021-12-08 20:18:48 +00:00
Evgeny Poberezkin
177c007edc Permanent user addresses (aka contact links) (#139)
* update for ConectionMode parameters

* update with CONF notification and different ConnectionRequest types

* high level flow for contact requests, add x.con to chat protocol

* store functions for user contact links and contact requests

* contact links work

* subscribe to user contact link connection

* subscribe to user contact address: messages

* send rejectContact to the agents when rejected in chat

* user contact link (address) test

* Update src/Simplex/Chat/View.hs

* Update tests/ChatTests.hs

* user address help, fix tests

* delete connection requests when contact link deleted

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2021-12-08 13:09:51 +00:00
Mark Aleksander Hil
d279c144a6 Removed horizontal lines (#144)
* Removed horizontal lines

Removed horizontal lines - page looked a bit cluttered

* remove extra line breaks

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-12-07 21:12:25 +00:00
Efim Poberezkin
ba2378e5d6 add user addresses section to readme (#145) 2021-12-07 23:42:06 +04:00
Mark Aleksander Hil
b7b393b993 Replace logo image (#143) 2021-12-07 08:47:47 +00:00
Mark Aleksander Hil
d5e66e2284 Edited readme and added new logo image (#142)
* Edited readme and added new logo image

* Update README.md

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

* update readme

* update readme

* extra spaces

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-12-07 07:03:54 +00:00
Efim Poberezkin
2ce3cd2fad install script: exit on failure, conditional curl/wget (#140) 2021-12-05 20:01:34 +04:00
Evgeny Poberezkin
e4328cb98d use ConnectionRequest syntax instead of "queue information" (#137) 2021-12-02 11:17:09 +00:00
Efim Poberezkin
498181b2e9 add quick installation & welcome sections to readme (#135) 2021-12-01 02:59:32 +10:00
Eliaz Bobadilla
6c8fb9e6d0 📝 Update readme install instructions (#134)
* 📝 Update readme install instructions

* Update README.md

* Update README.md

* Update README.md

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-11-29 22:33:22 +00:00
Efim Poberezkin
e5f13adc2a fix cla workflow trigger (#132) 2021-11-28 11:35:04 +00:00
Evgeny Poberezkin
d9b3742f62 add install.sh script (#129)
*  add script installer

* Update install.sh

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

* ♻️ Update

* ✏️ Typos

* ♻️ Move export to .profile

* ♻️ Update

* ♻️ Update

* Update install.sh

* Update install.sh

* Update install.sh

* Update install.sh

* install.sh: add simplex-chat folder to path conditionally

Co-authored-by: Eliaz Bobadilla <eliaz.bobadilladev@gmail.com>
Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2021-11-28 10:08:26 +00:00
Evgeny Poberezkin
800a4f90bf add version command (#123)
* Add /version and /v flags

* Move version info from C Chat.Help to Chat.View

* Move version string, use it in Main startup

* use "SimpleX Chat" consistently

Co-authored-by: TheTaoOfSu <TheTaoOfSu@protonmail.com>
2021-11-07 21:57:05 +00:00
Evgeny Poberezkin
deaea44024 CLA workflow (#122)
* CLA: doc and workflow

* update CLA version and signatures path

* update workflow target

* update cla signatures and doc location to simplex-chat/cla repo

* remove CLA.md
2021-11-07 19:40:52 +00:00
Efim Poberezkin
23468f0afd add section Troubleshooting on Unix to README (#113) 2021-10-05 01:44:36 +10:00
Evgeny Poberezkin
8b7d6e5f19 0.4.2 2021-09-26 16:57:52 +01:00
Evgeny Poberezkin
eb1ab8f561 fix sending notification containing apostrophe on mac, wrap notification in exception handler (#107) 2021-09-26 16:36:05 +01:00
Vsevolod Mineev
883887c569 update readme (#106)
Added clarification that you are able to create multiple invitations by entering /connect multiple times without invalidating the previously created invitations.
2021-09-26 15:48:52 +01:00
Evgeny Poberezkin
62a8ac4b21 0.4.1 2021-09-25 10:12:22 +01:00
Evgeny Poberezkin
e9180ed0dc fix small file transfer, closes #104 (#105) 2021-09-25 10:09:49 +01:00
Efim Poberezkin
2bf6d08a16 update gifs for larger font; readme fixes (#103)
* update gifs for larger font; readme fixes

* return articles

* return articles
2021-09-12 14:20:13 +01:00
Evgeny Poberezkin
43fc819f77 0.4.0 2021-09-11 20:44:01 +01:00
Evgeny Poberezkin
471652a042 Merge pull request #65 from simplex-chat/v4
v0.4.0
2021-09-11 20:18:53 +01:00
Evgeny Poberezkin
3a2c7927e1 update simplexmq version (#102)
* update simplexmq version

* update simplexmq version 0.4.1
2021-09-11 19:50:00 +01:00
Efim Poberezkin
4360d34847 update readme (#99)
* update readme

* case

* fixes, roadmap

* wording

* features

* update gifs and images

* block warning

* add link from announcement to groups

* update default servers

* update readme

* update roadmap, image, etc.

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-09-11 17:00:59 +01:00
Efim Poberezkin
46cf314403 update default smp servers (#101)
* update default smp servers

* add smp3 server
2021-09-11 13:45:22 +01:00
Efim Poberezkin
fe5769156c correctly print both db files (#100) 2021-09-07 01:08:29 +10:00
Evgeny Poberezkin
28103825fa send files to groups (#97)
* add sender/recipient info to file types

* send file to group (WIP)

* send file to group, test

* show file status when sending file to group

* notification when cancelled sending to group, remove chunks when file complete or canceleld
2021-09-05 14:08:29 +01:00
Efim Poberezkin
4bbdcc1d06 update help - file transfer and groups (#98)
* update help - file transfer and groups

* update help

* update help

* update help

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-09-04 20:38:11 +01:00
Evgeny Poberezkin
c51493e016 send files to contacts (#94)
* schema for sending files

* send file "invitation"

* receive file "invitation"

* send/receive file flow (with stubs)

* update simplexmq

* send and receive the file (WIP - only the first chunk)

* sending and receiving file works (but it is slow)

* use correct terminal output for file sending/receiving

* improve file transfer, support cancellation

* command to show file transfer status and progress

* file transfer tests

* resume file transfer on restart (WIP)

* stabilize test of recipient cancelling file transfer

* trying to improve file transfer on restart

* update SMP block size and file chunk size

* acquire agent lock before chat lock to test whether it avoids deadlock

* fix resuming sending file on client restart

* manual message ACK (prevents losing messages between agent and chat client and stabilizes resuming file reception after restart)

* do NOT send file chunk after receiving it before it is appended to the file

* update file chunk size for SMP block size 8192 (set in smpDefaultConfig)

* save received files to ~/Downloads folder by default; create empty file when file is accepted

* keep file handle used to create empty file

* check message integrity

* fix trying to resume sending file when it was not yet accepted

* fix subscribing to pending connections on start

* update simplexmq (fixes smp-server syntax parser)
2021-09-04 07:32:56 +01:00
Efim Poberezkin
97fde7ecd0 subscribe pending connections on chat start (#95) 2021-08-28 20:54:53 +10:00
Evgeny Poberezkin
9cfca4ed35 update user profile (and notify contacts) (#93)
* update user profile (and notify contacts)

* add concurrently to profile update test for better layout
2021-08-22 15:56:36 +01:00
Evgeny Poberezkin
e5b9cdef9d update for asynchronous message delivery (#92) 2021-08-14 21:04:51 +01:00
Evgeny Poberezkin
f3c64f3fc7 Merge branch 'master' into v4 2021-08-06 21:51:21 +01:00
Nikita Poberezkin
2884bf73b7 escape/remove symbols in notifications for all platforms (#88)
* escape backtick, backslash, double quotes wsl/win

* remove quotes, backtick from win notifications to prevent chat from crashing

* put notification title and text in single quotes and escape single quotes inside

* escape notifications for mac (draft)

* add replaceAll func

* remove unused import

* imports

* refactor replaceAll

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-08-06 21:41:35 +01:00
Evgeny Poberezkin
d23417596e TMVar lock to avoid subscriber and client processing in parallel, fix the test (#90)
* TMVar lock to avoid subscriber and client processing in parallel, fix the test

* run SMP server as part of the test

* stabilize tests

* update simplexmq

* test: stabilize getting invitation from terminal

* remove unused import

* simplify test
2021-08-05 20:51:48 +01:00
Evgeny Poberezkin
a9d32db404 update for SMP agent protocol changes (#89) 2021-08-05 08:38:39 +01:00
Evgeny Poberezkin
b798342c61 group commands (remove member, leave group, delete group) (#87)
* remove group member

* leave group, fix remove member, tests for leave group/remove member

* delete group with test

* prevent contact deletion error when it is a group member

* support inviting the group member who left or was removed

* use small retry interval in the tests

* test multiline outputs
2021-08-02 20:10:24 +01:00
Evgeny Poberezkin
b7c4a6e195 Merge branch 'master' into v4 2021-07-27 08:10:20 +01:00
Evgeny Poberezkin
2d1ff5fb4b Merge branch 'master' into v4 2021-07-27 08:09:48 +01:00
Evgeny Poberezkin
b3af93e0ad merge profiles using contact probe (#86)
* chat commands to list members and to quit chat

* merge profiles using probe

* merge contacts connected to the same user based on successful profile probe

* delete display name after merging contacts

* probe: rename "existing" contacts to "matching"
2021-07-27 08:08:05 +01:00
Evgeny Poberezkin
cc4cb78209 subscribe all user contacts and group members (#85) 2021-07-25 20:23:52 +01:00
Evgeny Poberezkin
488df1aa3c refactor groups (#84)
* refactor groups

* disable chat test

* remove comments
2021-07-24 18:11:04 +01:00
Evgeny Poberezkin
189cd7e09d core chat groups protocol for adding members (#78)
* add category and local display name to group members, extend member status

* additional chat commands, serialization

* parse all chat messages

* draft group protocol implementation

* group protocol: connect new member to existing members (TODO fix race condition with contact connection)

* send/receive group messages (race condition still there - the 3rd member cannot send either group or direct messages to the 2nd member - CONN SIMPLEX)

* send x.grp.mem.info and x.ok in SMP confirmation

* fix host user adding new member, update simplexmq to fix sqlite concurrency, remove logs, make # optional in chat commands

* more precise view messages about members joining and connecting

* track connection status; only send messages to active members (TODO change to current members); group name autocomplete after joining the group

* track via which group the contact was added; show only one message when a contact fully connected; group tests

* test sending messages to the new direct contacts created via the group

* update simplexmq to include .cabal file

* remove unused import
2021-07-24 10:26:28 +01:00
Nikita Poberezkin
94f89ed8f7 merge master to v4 (#83)
* update ghc version to 8.10.4 for Docker build (#67)

* make broader check for WSL on notifications (#68)

* update readme: network topology and disclaimer on encryption design (#73)

* update readme with the disclaimer on encryption design and explanation of the network topology

* corrections

* remove old disclaimer

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

* create appDir if absent (#79)

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-07-24 09:57:10 +01:00
Nikita Poberezkin
3c942f6f3e create appDir if absent (#79) 2021-07-23 07:41:10 +01:00
Evgeny Poberezkin
f1a44383fa chat groups: establish connection between host and invitee members (#77)
* create group after invitation

* add group invitation to db, show sent and received group invitations

* test creating group and sending invitation

* establish group connections (WIP)

* connect user to the inviter, notification, member classification
2021-07-16 07:40:55 +01:00
Evgeny Poberezkin
e9d931059b use shared namespace for usernames, contact names and group names (#76)
* test adding same contact, add display_names table and functions

* rename display_name -> full_name

* use shared namespace for usernames, contact names and group names
2021-07-14 20:11:41 +01:00
Evgeny Poberezkin
e99c4bda1e started chat groups protocol (#75)
* create group

* add user as member, store methods to get group and to create group member

* add group member and send member invitation

* fix ci: use simplexmq from github

* chat protocol: create SMP agent connection when inviting member

* update protocol, started group invitation receiving
2021-07-12 19:00:03 +01:00
Evgeny Poberezkin
24c62584fc simplify chat protocol (#74)
* groups protocol and some group commands

* simplify chat message format, refactor types to include parsed message body

* disable chat test
2021-07-11 12:22:22 +01:00
Evgeny Poberezkin
44496bc003 update readme: network topology and disclaimer on encryption design (#73)
* update readme with the disclaimer on encryption design and explanation of the network topology

* corrections

* remove old disclaimer

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2021-07-07 22:58:53 +01:00
Evgeny Poberezkin
d21abbdec1 chat test with VirtualTerminal (#72)
* chat test with VirtualTerminal

* disable chat test

* fix intermittently failing test

* simplify test
2021-07-07 22:46:38 +01:00
Evgeny Poberezkin
25ac250d37 use chat message format to pass profile information, refactor (#71) 2021-07-06 19:07:03 +01:00
Evgeny Poberezkin
85727bfbf1 move files to src folder (to allow testing) (#70) 2021-07-05 20:05:07 +01:00
Evgeny Poberezkin
58889be83d establish connection using user profiles (#69)
* establish connection using user profiles (TODO: delete contact and send message)

* delete contact and send message with the updated schema

* comment

* refactor, remove old code
2021-07-05 19:54:44 +01:00
Evgeny Poberezkin
2f604d91ba use chat protocol and contacts in chat commands/messages (#66)
* chat types, chat protocol syntax idea

* chat message syntax, raw message type

* chat message format and parsing

* raw chat message parsing test

* add message parsing tests

* interpret RawChatMessage

* use chat message format when sending messages

* save contacts and related connections to DB (WIP)

* use contacts in all chat commands (add, connect, send, delete)

* use contacts when receiving messages and notifications

* handle contact not found error

* automatically accept connection when CONF is received from the agent
2021-07-04 18:42:24 +01:00
Efim Poberezkin
c6f1858ca0 make broader check for WSL on notifications (#68) 2021-07-02 00:37:19 +10:00
Efim Poberezkin
321f4bbe9d update ghc version to 8.10.4 for Docker build (#67) 2021-07-01 00:37:47 +10:00
Evgeny Poberezkin
c3d5797a0b Merge branch 'master' into v4 2021-06-26 20:20:33 +01:00
Nikita Poberezkin
32d90580e7 desktop notifications (#64)
* send notifications

* support for linux notifications (draft)

* add support for linux, win (draft) and wsl (draft) notifications

* add support for windows/wsl notifications

* add unix to extra-deps

* add alternative linux notification method

* remove unused cpp conditions

* fix notification commands for win/lin

* remove dbus package and code

* remove fdo-notify from extra-deps

* move script running logic to common method + add lacking quotes

* remove unrelated workspace file

* corrections

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-06-26 19:48:08 +01:00
Evgeny Poberezkin
5a2ded775d rename app folder (#63)
* rename app folder

* clean up package.yaml
2021-06-25 18:34:29 +01:00
Evgeny Poberezkin
eb2404c9ce simplex-chat schema, refactor chat to use SMP agent functions (#62)
* chat messages namespace and types

* initial schema (WIP)

* schema for messages (WIP)

* fix schema, add migrations, remove broadcast

* simplex-chat spike (WIP)

* chat client design

* update chat schema

* more chat schema updates

* simplex-chat app structure

* chat app layout demo

* update schema

* refactor dog-food (WIP)

* refactor / simplify

* refactor output of sent message to avoid separate parsing

* refactor inputSubscriber

* remove unused simplex-chat code

* update simplexmq commit

* update schema

* remove ncurses
2021-06-25 18:18:24 +01:00
Evgeny Poberezkin
4232f73ed2 support ad-hoc groups (broadcasts) (#61)
* support ad-hoc groups (broadcasts)

* fake group chat

* use simplexmq latest
2021-06-10 20:34:52 +01:00
Efim Poberezkin
e4f3414b0b add missing dot (#58) 2021-05-16 18:58:19 +04:00
Evgeny Poberezkin
d4ecd27067 add gif to readme (#59) 2021-05-12 19:33:50 +01:00
Evgeny Poberezkin
723c787edc 0.3.1 (#57) 2021-05-10 19:49:21 +01:00
Evgeny Poberezkin
8f69d176c7 move Markdown from simplexmq (#56)
* move Markdown from simplexmq

* update simplexmq
2021-05-09 10:53:18 +01:00
Evgeny Poberezkin
36a34eed4a update for SMP agent protocol 0.3.1 - SMP servers are in agent config… (#53)
* update for SMP agent protocol 0.3.1 - SMP servers are in agent config, not in commands

* remove explicit server port

* update simplexmq
2021-05-09 07:56:44 +01:00
Efim Poberezkin
7c0cd342cc show message timestamps (#55) 2021-05-08 14:49:17 +04:00
Evgeny Poberezkin
73a3b2f351 add link to motivation (#54)
* motivation

* readme correction

* corrections

* correction

* corrections

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2021-05-07 08:03:47 +01:00
Mark Aleksander Hil
701e120e9a edit readme, add images and table of contents (#52)
* Edited text, added images and table of contents

* readme corrections

* change win command to forward slashes

* readme corrections

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2021-05-06 18:37:47 +01:00
Efim Poberezkin
822c9bbd3a clean up docs (#51)
* clean up docs

* restore simplex.md
2021-05-04 21:36:25 +01:00
Efim Poberezkin
eb44fb24e8 print chat version on start (#50) 2021-05-04 21:20:17 +04:00
Evgeny Poberezkin
bf86904e97 0.3.0 2021-05-04 08:47:02 +01:00
Evgeny Poberezkin
2b4399b57f optionally show message integrity violations (#49)
* optionally show message integrity violations

* remove message integrity option
2021-05-04 06:37:30 +01:00
Evgeny Poberezkin
7ae6b64a99 change contact color (#48) 2021-05-03 21:44:50 +01:00
Evgeny Poberezkin
d9aee80b42 update simplexmq (#47) 2021-05-03 16:13:22 +01:00
Evgeny Poberezkin
103595a8e8 simplex-chat readme (#46)
* simplex-chat readme

* add convenience folders

* readme corrections

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2021-05-03 14:15:12 +01:00
Evgeny Poberezkin
9b3efbabbe Merge branch 'master' of simplexmq (README.md, Dockerfile) 2021-05-03 07:47:43 +01:00
Evgeny Poberezkin
734ca2977a rename readme.md to simplex.md 2021-05-02 22:17:45 +01:00
Evgeny Poberezkin
a1f86bf4a7 Merge branch 'master' (.github folder) of simplexmq 2021-05-02 21:37:18 +01:00
Evgeny Poberezkin
8938a71ac6 move workflows 2021-05-02 21:33:45 +01:00
Evgeny Poberezkin
577d593f67 package.yaml, chat dependencies (#45) 2021-05-02 21:26:25 +01:00
Evgeny Poberezkin
2362fd5d29 Merge branch 'master' of simplexmq 2021-05-02 20:42:28 +01:00
Evgeny Poberezkin
f7d561e9ea move chat files to src 2021-05-02 20:40:13 +01:00
Efim Poberezkin
539e09f8cd docs, smp: align with implementation (#43)
* adjust out-of-band message abnf

* define hostname and encoded using prose-val

* elaborate on base64

* corrections up to SMP procedure

* fix CONN -> NEW

* update SMP protocol to align with the implementation

* remove Possible extensions from TOC

* lists

* corrections

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2021-05-02 19:15:40 +01:00
Evgeny Poberezkin
1c85c4a379 SMP agent protocol commands semantics (#44) 2021-05-02 18:47:16 +01:00
Evgeny Poberezkin
b992b00223 Merge branch 'master' into v2 2021-05-02 11:23:41 +01:00
Efim Poberezkin
6a589688c6 agent: verify msg integrity based on previous msg hash and id (#110)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-05-02 00:38:32 +04:00
Evgeny Poberezkin
28b7d01117 reduce help size (#113) 2021-04-30 09:06:59 +01:00
Mark Aleksander Hil
8aaf0df8e7 Updated ChatHelpInfo (#112) 2021-04-29 10:27:07 +01:00
Evgeny Poberezkin
7b31fafc2d Store log (#108)
* StoreLog (WIP)

* add log records to map

* revert Protocol change

* revert Server change

* fix parseLogRecord

* optionally save/restore queues to/from store log

* refactor

* refactor delQueueAndMsgs

* move store log to /var/opt/simplex

* use ini file
2021-04-26 20:34:28 +01:00
Evgeny Poberezkin
88314ebadb set different default server (#107)
* set different default server

* remove comment
2021-04-26 20:18:20 +01:00
Efim Poberezkin
f061f72021 docs, smp, chore: remove multiline sentences line breaks; uniform lists (#42) 2021-04-19 23:14:40 +04:00
Evgeny Poberezkin
f767d1f8ff chat: add connection errors in chat, fix catch (#103) 2021-04-19 08:40:23 +01:00
Evgeny Poberezkin
bfa90b842f duplex procedure, update diagram (#41)
* duplex procedure, update diagram

* reduce svg whitespace

* update svg
2021-04-18 07:34:47 +01:00
Evgeny Poberezkin
cc9b351c65 SMP agent protocol - duplex messaging (#39)
* duplex messaging commands syntax

* update duplex messaging commands

* update duplex commands/responses

* SMP messages between agents

* error for multiple skipped messages

* more syntax

* more syntax

* add diagram: creating duplex connection

* fix diagram link

* update diagram

* update duplex diagram

* add queue statuses to the diagram

* add "try sending" periods to duplex diagram

* diagram: queue status (receive/send)

* update queue status

* simplify duplex connection to only have two queues

* remove error notification sent to another agent, only notify user

* remove unused commands, add "unsubscribed" notification

* simplified commands and added connection invitation syntax

* update SMP agent protocol

* duplex protocol correction

* corrections (#40)

* SMP agent protocol

* rename duplex-messaging to agent-protocol

* minor fixes

* SMP agent protocol corrections

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2021-04-16 19:56:53 +01:00
Evgeny Poberezkin
4a5b5da3e2 Merge branch 'master' into v2 2021-04-14 21:30:30 +01:00
Efim Poberezkin
7503ee9a3a tests: block on tcp server creation (#99)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-04-14 02:25:57 +04:00
Evgeny Poberezkin
d6cd828257 remove /reset command (#96) 2021-04-12 23:56:17 +01:00
Efim Poberezkin
ef944226b2 automate changelogs (#84) 2021-04-09 18:20:09 +04:00
Evgeny Poberezkin
0ccde5871c transport encryption (#65)
* transport encryption (WIP - using fixed key, parsing/serialization works, SMP tests fail)

* transport encryption

* transport encryption: separate keys to receive and to send, counter-based IVs

* docs: update transport encryption and handshake

* transport encryption handshake (TODO: validate key hash, welcome block, move keys to system environment)

* change KeyHash type to newtype of Digest SHA256

* transport encryption: validate public key hash

* send and receive welcome block with SMP version

* refactor: parsing SMPServer

* remove unused function

* verify that client version is compatible with server version (major version is not smaller)

* update (fix) SMP server tests
2021-04-05 13:10:16 +01:00
Efim Poberezkin
4f20c23201 automate releases (#76) 2021-04-03 23:17:51 +04:00
Efim Poberezkin
31b0cf8a8e agent sqlite: initialize database in home directory by default (#74) 2021-03-29 19:18:54 +04:00
Evgeny Poberezkin
251f453c91 readme: note on docker (#73) 2021-03-09 07:17:11 +00:00
Evgeny Poberezkin
a602587046 simplify installation instruction (#72) 2021-03-09 07:08:36 +00:00
Efim Poberezkin
0bce6e8173 check that sqlite library is compiled with threadsafe code (#63) 2021-03-02 22:30:59 +04:00
Efim Poberezkin
fe8b28a655 add chat history instructions to README (#56) 2021-02-26 18:53:50 +04:00
Efim Poberezkin
b5bfa3ac8c add Dockerfile for building chat executable and instructions on running it (#48)
* [WIP] add instructions how to build project inside docker container

* docker run -> docker create

* add Dockerfile for building chat executable and instructions
2021-02-26 18:17:29 +04:00
Efim Poberezkin
2ad54cf1d3 add instructions on how to run chat client to README (#43)
* add instructions on how to run chat client to README

* wording

* wording

* corrections to the manual

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-02-26 18:17:29 +04:00
Efim Poberezkin
aedba41e16 fix ghc version in build (#36) 2021-02-26 18:14:22 +04:00
Evgeny Poberezkin
5489e92e31 message management (#32)
* message management rfc

* update message management rfc

* message management ideas (WIP)

* message management updated

* messages RFC

* update agent MSG constructor to include recipient/broker/sender message IDs and timestamps

* remove agent command ACK - agent automatically acknowledges server messages

* correct messages doc
2021-02-26 18:13:04 +04:00
Evgeny Poberezkin
54e818bd39 Sending messages end to end (#21)
* duplex connection end-to-end (working, debug logs)

* agent: send, receive, acknowledge messages

* logging proposal

* logging: client/server (dis)connections

* agent scenario testing framework

* add tests, remove logs

* clean up
2021-02-26 18:11:22 +04:00
Efim Poberezkin
8e52d78cf2 ci: cache dependencies (#11) 2021-02-26 18:09:26 +04:00
Evgeny Poberezkin
0720d20218 Merge pull request #1 from simplex-chat/client
SMP agent implementation
2021-01-12 08:45:38 +00:00
Evgeny Poberezkin
062934ec1e readme: link to releases 2021-01-11 19:25:37 +00:00
Evgeny Poberezkin
8be832689a rename workflow, build/version badges 2021-01-11 19:23:36 +00:00
Evgeny Poberezkin
1c2ac43a13 rename workflow, build/version badges 2021-01-11 19:23:36 +00:00
Evgeny Poberezkin
eede6c5da9 readme: released version link 2021-01-11 19:14:54 +00:00
Efim Poberezkin
986e44abbe GitHub workflow for tests (#7)
* ci: add github workflow for tests

* ci: break test

* ci: fix folder name

* ci: fix test

* ci: break test

* fix test

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2021-01-11 22:21:35 +04:00
Evgeny Poberezkin
162af5c60c Merge pull request #2 from simplex-chat/correlation-id
add corellationId to SMP protocol server (WIP)
2020-12-28 16:56:03 +00:00
Evgeny Poberezkin
df181bb0f0 docs: add correlation IDs to examples 2020-12-28 16:28:57 +00:00
Evgeny Poberezkin
b399ee78da update readme 2020-10-22 14:13:06 +01:00
Evgeny Poberezkin
d6d23bcac9 update protocol to use term "queue" to mean "SMP connection", CONN -> NEW 2020-10-22 11:29:48 +01:00
Evgeny Poberezkin
9b3c63deaa readme corrections 2020-10-18 21:31:51 +01:00
Evgeny Poberezkin
11580d9938 docs: readme, system design 2020-10-18 21:28:37 +01:00
Evgeny Poberezkin
d0b959168a LF -> CRLF 2020-10-18 12:50:14 +01:00
Evgeny Poberezkin
6ad32cf7cf corrections 2020-10-17 21:58:03 +01:00
Evgeny Poberezkin
5e19d9a801 add subscription END notification, corrections 2020-10-17 21:09:49 +01:00
Evgeny Poberezkin
f8b9c5937c change command names and errors 2020-10-15 15:47:47 +01:00
Evgeny Poberezkin
662717a25b initial 2020-10-11 11:00:25 +01:00
Evgeny Poberezkin
4d6fce970a update syntax 2020-10-10 22:16:36 +01:00
Evgeny Poberezkin
59b475a5cd unify and simplify SMP (#38)
* unify and simplify SMP

* use cameCase in ABNFs

* update diagrams

* update ABNF RFC

* update protocol syntax

* table of contents
2020-10-10 21:47:17 +01:00
Evgeny Poberezkin
722286e495 Update readme.md 2020-10-08 08:00:44 +01:00
Evgeny Poberezkin
82570826ca Create FUNDING.yml 2020-09-30 20:11:57 +01:00
Evgeny Poberezkin
53d598cdc2 Connection type (#36)
* use protocol package

* Connection, Invitation types

* remove idris code
2020-07-16 19:32:36 +01:00
Evgeny Poberezkin
3d7992835f rename to runProtocol, remove ProtocolCmd constructor export 2020-07-12 19:13:45 +01:00
Evgeny Poberezkin
f97a7885a0 refactor protocol interpreter 2020-07-12 19:02:56 +01:00
Evgeny Poberezkin
9eec22ca43 rename type paarameters 2020-07-12 15:18:32 +01:00
Evgeny Poberezkin
bedcd0fa50 stack error messages 2020-07-12 10:36:00 +01:00
Evgeny Poberezkin
ac79fe45c2 print allow comments 2020-07-12 10:28:13 +01:00
Evgeny Poberezkin
85b10f08ae unused pragma 2020-07-12 09:48:00 +01:00
Evgeny Poberezkin
2b07f80828 all parties have resource state of the same kind 2020-07-12 09:45:55 +01:00
Evgeny Poberezkin
cf3afbac8a style: split lines 2020-07-11 20:40:34 +01:00
Evgeny Poberezkin
616e39eda2 Merge branch 'master' of github.com:simplex-chat/protocol 2020-07-11 20:38:05 +01:00
Evgeny Poberezkin
b5a04ad178 Control.Protocol (#35)
* polysemy effects

* exctract Protocol abstraction

* refactor: use Control.Protocol

* better type errors
2020-07-11 20:27:23 +01:00
Evgeny Poberezkin
36d12a505b polysemy effects 2020-07-11 12:30:01 +01:00
Evgeny Poberezkin
7b7f4b23ff refactor: AllowedStates 2020-07-10 16:01:41 +01:00
Evgeny Poberezkin
283eacd9a5 stricter Enabled 2020-07-10 14:50:52 +01:00
Evgeny Poberezkin
b19b5be50e refactor: group Command parameters, do syntax in scenarios 2020-07-10 12:36:14 +01:00
Evgeny Poberezkin
063b7286e2 refactor: make Protocol a freer parameterized monad 2020-07-10 11:54:09 +01:00
Evgeny Poberezkin
cffb8bd11a refactor: make Protocol closer to parameterized monad 2020-07-10 11:13:01 +01:00
Evgeny Poberezkin
d74c109328 use ExceptT 2020-05-31 22:37:08 +01:00
Evgeny Poberezkin
cc55bf3e6b Different approach to commands (#34)
* different approach to command types (WIP)

* PartyProtocol class and other commands

* pretty-print scenarion

* remove old files

* remove unused prf/predicate templates

* remove NoImplicitePrelude from doctest (although there are no doctests atm)
2020-05-31 21:51:15 +01:00
Evgeny Poberezkin
dc7835992c remove Drained state 2020-05-25 09:20:22 +01:00
Evgeny Poberezkin
bdec751725 Instance template (#33)
* protocol instance template [WIP]

* protocol instances template

* add methods to check correctness of participant types in protocol TH

* PushConfirm and and PushMsg implementation types

* check Command type + doctest
2020-05-14 21:30:37 +01:00
Evgeny Poberezkin
aa2ac80cf9 simplify predicate template 2020-05-13 11:47:24 +01:00
Evgeny Poberezkin
a9565a5754 predicate template to add Auto instances 2020-05-12 19:27:08 +01:00
Evgeny Poberezkin
223931bc93 Subscribe recipient command stub 2020-05-11 20:51:08 +01:00
Evgeny Poberezkin
6eb75a5bdb type classes to ensure consistency of implementation types with command types 2020-05-11 20:45:17 +01:00
Evgeny Poberezkin
f07f99c94f instance PrfCommand for CreateConn command [WIP - not working yet] 2020-05-11 08:27:34 +01:00
Evgeny Poberezkin
f52ce87a89 type classes to ensure consistency of implementation types with the protocol 2020-05-10 20:51:03 +01:00
Evgeny Poberezkin
eb5e99710f change scenario syntax 2020-05-10 14:16:37 +01:00
Evgeny Poberezkin
08274c9b52 track connection message count in type, remove ticks from promoted constructors 2020-05-10 12:13:24 +01:00
Evgeny Poberezkin
fbafaa8ac5 add recipient/broker subscription state to protocol command type 2020-05-10 11:10:34 +01:00
Evgeny Poberezkin
f3f39e760a refine definition of Subscribe to prevent subscription in None and Disabled states 2020-05-10 09:50:09 +01:00
Evgeny Poberezkin
7520c681da establishConnection protocol flow 2020-05-09 23:23:18 +01:00
Evgeny Poberezkin
f9e75aebeb protocol commands 2020-05-09 21:30:39 +01:00
Evgeny Poberezkin
53055dcae6 Show and Eq instances 2020-05-09 14:15:04 +01:00
Evgeny Poberezkin
3923de9b49 change data familiy to type family 2020-05-09 13:40:32 +01:00
Evgeny Poberezkin
7dce45ea2a add comment 2020-05-09 13:28:05 +01:00
Evgeny Poberezkin
a796215de2 move extensions to code 2020-05-09 13:24:08 +01:00
Evgeny Poberezkin
17aabcde04 gitignore 2020-05-09 12:41:04 +01:00
Evgeny Poberezkin
7ee44a6d41 connection states in haskell protocol definition 2020-05-09 12:38:07 +01:00
Evgeny Poberezkin
b16b5c5948 remove participants "list" 2020-05-08 17:40:48 +01:00
Evgeny Poberezkin
bbb763655e participants list (to be removed) 2020-05-08 17:36:04 +01:00
Evgeny Poberezkin
4a3e76cea1 change operator 2020-05-08 13:35:01 +01:00
Evgeny Poberezkin
b64a2e615d add type-verified command actor (from) 2020-05-08 13:33:54 +01:00
Evgeny Poberezkin
222051fc5d make message count second in tuple 2020-05-08 13:20:49 +01:00
Evgeny Poberezkin
23d07cc350 add connection message count to command type 2020-05-08 13:15:41 +01:00
Evgeny Poberezkin
77fb8b9ce0 scenario using deterministic command resulting state 2020-05-08 11:30:06 +01:00
Evgeny Poberezkin
df0552ef6b move to subfolder 2020-05-08 10:17:20 +01:00
Evgeny Poberezkin
5689cd9064 change used connection state proofs 2020-05-08 10:12:51 +01:00
Evgeny Poberezkin
947d7676a2 fix types 2020-05-08 10:03:47 +01:00
Evgeny Poberezkin
bcd58117a2 connection states for all participants stored in one type (r <==> b <==| s) 2020-05-08 09:58:27 +01:00
Evgeny Poberezkin
a6d963035e protocol command type - establishing connection 2020-05-07 21:24:18 +01:00
Evgeny Poberezkin
ece63ea894 protocol command type [WIP] 2020-05-07 18:45:19 +01:00
Evgeny Poberezkin
22e14c821c correction 2020-05-07 18:05:05 +01:00
Evgeny Poberezkin
e7550f026c improve connection data structures 2020-05-07 17:58:42 +01:00
Evgeny Poberezkin
f4c4dde30f unify connection states 2020-05-07 17:19:17 +01:00
Evgeny Poberezkin
de706b9d23 connection states and data (in idris) 2020-05-07 13:43:09 +01:00
Evgeny Poberezkin
5643c6e270 lint 2020-04-10 18:33:50 +01:00
Evgeny Poberezkin
3f7be07d53 classy-prelude 2020-04-10 18:32:36 +01:00
Evgeny Poberezkin
ddfda96523 type names 2020-03-21 19:15:59 +00:00
Evgeny Poberezkin
c34ba79f0b executable name 2020-03-21 18:27:20 +00:00
Evgeny Poberezkin
b8991a4fbf src folder 2020-03-21 18:19:57 +00:00
Evgeny Poberezkin
a6700c1633 definitions 2020-03-21 18:16:25 +00:00
Evgeny Poberezkin
bee8366e51 expose modules 2020-03-21 18:08:24 +00:00
Evgeny Poberezkin
cb39727088 simplex messaging api types and docs generation (#32)
* simplex messaging api types and docs generation

* api endpoints annotations

* endpoint titles

* refactor
2020-03-21 18:00:25 +00:00
Efim Poberezkin
22f2e318af apply minor corrections (#26)
* docs: websocket API, changed failed REST response codes, #23

* apply minor corrections

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2020-02-01 16:21:12 +04:00
Evgeny Poberezkin
2fa21836ba docs: websocket API, changed failed REST response codes, #23 (#25) 2020-02-01 10:23:12 +00:00
Evgeny Poberezkin
3e07161121 Remove connection ID and alternative flow (#22)
* docs: remove connection ID from simplex-messaging.md, #8

* docs: remove alternative flow in simplex-messaging, #20

* docs: update simplex diagrams to remove connection ID

* docs: remove connection ID from graph-chat

* docs: update duplex connection diagram to remove connection ID, closes #8
2020-01-26 21:34:14 +00:00
Evgeny Poberezkin
cc04a5cc6e Simplify API, remove connection ID, add single message API (#19)
* docs: simplify API, remove connection ID, add single message API, closes #12

* dos: correct protocol implementation spec

* fix typo

Co-authored-by: Efim Poberezkin <efim.poberezkin@gmail.com>
2020-01-26 21:30:27 +00:00
Evgeny Poberezkin
5074d5baaa docs: AGPL v3 license, closes #17 (#21) 2020-01-26 10:37:10 +00:00
Evgeny Poberezkin
9b5ac493d5 docs: comparison with P2P, closes #14 (#18) 2020-01-25 12:54:13 +00:00
Efim Poberezkin
a78dd33848 Rename edge-messaging to simplex messaging (#15)
* rename edge-messaging protocol related files

* rename edge-messaging protocol to simplex messaging protocol

* adjust wordings for simplex connections
2020-01-25 13:19:34 +04:00
Evgeny Poberezkin
983f9af714 Connection URIs (#11)
* simplex connection: why RU and SU should be different, closes #5

* remove api allowing connection URIs change
2020-01-22 19:09:07 +04:00
Gajus Kuizinas
51198cca17 docs: minor spelling errors (#13) 2020-01-22 14:03:40 +00:00
Evgeny Poberezkin
bc460f0e31 Split to edge-messaging and graph-chat (#1)
* docs: graph-messaging protocol [WIP]

* docs: creating and using graph-messaging connection

* docs: subtitle

* docs: [WIP] graph-chat protocol

* docs: graph-chat establishing duplex connection

* apply minor typo fixes and wording adjustments to protocols docs

* rename file graph-messaging -> edge-messaging

* update test graph-messaging -> edge-messaging

* correction re CID

* duplex connection correction

* rename folder graph-messaging -> edge-messaging

* add duplex connection

* update edge-messaging to match graph-chat

* update symbols in graph-chat

* sequence diagram: creating duplex connection

* fix indentation

* edge-messaging: REST API, crypto, IDs, URIs

* REST API endpoints summary

* Rest -> REST

* REST API additional requirements

* adjust wordings and fix typos (#2)

* update readme (#4)

* update readme

* send message story and diagram

* edge-messaging: alternative flow of creating connection

* remove old diagrams

* apply minor fixes

* correct readme

Co-authored-by: Efim Poberezkin <efim.poberezkin@gmail.com>

* graph-chat: added duplex connection types

* graph-chat: comment on user profile visibility

* clarify duplex diagram

Co-authored-by: Efim Poberezkin <efim.poberezkin@gmail.com>
2020-01-21 21:01:48 +00:00
Evgeny Poberezkin
8f01a0d841 docs: readme 2020-01-06 06:46:49 +00:00
Evgeny Poberezkin
bda1b72a4e docs: other 2019-12-24 20:03:11 +00:00
Evgeny Poberezkin
05df86bd92 docs: updated 2019-12-23 23:34:51 +00:00
Evgeny Poberezkin
078bc91930 docs: simplex connection 2019-12-23 23:18:17 +00:00
Evgeny Poberezkin
54b3d15dc9 docs: updated readme 2019-12-22 10:27:57 +00:00
Evgeny Poberezkin
4da5ae01b5 docs: update message diagram to account for user-server auth and encryption 2019-12-21 22:34:58 +00:00
Evgeny Poberezkin
902c2007dc docs: update connection diagram to include user-server authentication and encryption 2019-12-21 22:20:56 +00:00
Evgeny Poberezkin
85998d9e8e docs: update connection diagram 2019-12-21 15:13:03 +00:00
Evgeny Poberezkin
fea92fa2fe docs: adding connection diagram 2019-12-21 15:04:51 +00:00
Evgeny Poberezkin
ac58b56ac6 docs: update sending message diagram 2019-12-21 14:31:42 +00:00
Evgeny Poberezkin
08a933e22c docs: update sending message diagram 2019-12-21 14:21:01 +00:00
Evgeny Poberezkin
c7a95710eb docs: update sending message diagram 2019-12-21 14:11:51 +00:00
Evgeny Poberezkin
d24053aba3 docs: sending message diagram 2019-12-21 14:08:57 +00:00
Evgeny Poberezkin
daab409837 docs: readme 2019-12-21 13:06:18 +00:00
Evgeny Poberezkin
04fa1708b1 Initial commit 2019-12-21 09:50:10 +00:00
695 changed files with 90096 additions and 811 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @epoberezkin @jr-simplex

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
github: simplex-chat
open_collective: simplex-chat

4
.github/changelog_conf.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"template": "Commits:\n${{UNCATEGORIZED}}",
"pr_template": "- ${{TITLE}}"
}

152
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,152 @@
name: build
on:
push:
branches:
- master
- stable
- sqlcipher
tags:
- "v*"
pull_request:
jobs:
prepare-release:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Clone project
uses: actions/checkout@v2
- name: Build changelog
id: build_changelog
uses: mikepenz/release-changelog-builder-action@v1
with:
configuration: .github/changelog_conf.json
failOnError: true
ignorePreReleases: true
commitMode: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
uses: softprops/action-gh-release@v1
with:
body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: true
files: |
LICENSE
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build:
name: build-${{ matrix.os }}
if: always()
needs: prepare-release
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-20.04
cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-20_04-x86-64
- os: ubuntu-18.04
cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-18_04-x86-64
- os: macos-latest
cache_path: ~/.cabal/store
asset_name: simplex-chat-macos-x86-64
# - os: windows-latest
# cache_path: C:/sr
# asset_name: simplex-chat-windows-x86-64
steps:
- name: Clone project
uses: actions/checkout@v2
- name: Setup Haskell
uses: haskell/actions/setup@v1
with:
ghc-version: "8.10.7"
cabal-version: "latest"
- name: Cache dependencies
uses: actions/cache@v2
with:
path: |
${{ matrix.cache_path }}
dist-newstyle
key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
# / Unix
- name: Unix prepare cabal.project.local for Mac
if: matrix.os == 'macos-latest'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Unix prepare cabal.project.local for Ubuntu
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-18.04'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Unix build
id: unix_build
if: matrix.os != 'windows-latest'
shell: bash
run: |
cabal build --enable-tests
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
- name: Unix test
if: matrix.os != 'windows-latest'
shell: bash
run: cabal test --test-show-details=direct
- name: Unix upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.unix_build.outputs.bin_path }}
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 /

36
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request'
# Beta Release
uses: cla-assistant/github-action@v2.1.3-beta
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
path-to-signatures: 'signatures/v1.1/cla.json'
path-to-document: 'https://github.com/simplex-chat/cla/blob/master/CLA.md'
# branch should not be protected
remote-organization-name: simplex-chat
remote-repository-name: cla
branch: 'master'
# allowlist: user1,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA

38
.github/workflows/web.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Build Eleventy
on:
push:
branches:
- master
- stable
paths:
- website/**
- images/**
- blog/**
- .github/workflows/web.yml
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies & build
run: |
./website/web.sh
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
publish_dir: ./website/_site
github_token: ${{ secrets.GITHUB_TOKEN }}

74
.gitignore vendored Normal file
View File

@@ -0,0 +1,74 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Dependency directories (remove the comment below to include it)
# vendor/
.DS_Store
# Haskell
dist
dist-*
cabal-dev
*.o
*.hi
*.hie
*.chi
*.chs.h
*.dyn_o
*.dyn_hi
.hpc
.hsenv
.cabal-sandbox/
cabal.sandbox.config
*.prof
*.aux
*.hp
*.eventlog
.stack-work/
cabal.project.local
cabal.project.local~
.HTF/
.ghc.environment.*
stack.yaml.lock
# Chat database
*.db
*.db.bak
# Temporary test files
tests/tmp
logs/
# for website
website/node_modules/
website/src/blog/
website/src/img/images/
website/src/images/
# Generated files
website/package/generated*
# Ignore build tool output, e.g. code coverage
website/.nyc_output/
website/coverage/
# Ignore API documentation
website/api-docs/
# Ignore folders from source code editors
website/.vscode
website/.idea
# Ignore eleventy output when doing manual tests
website/_site/
website/package-lock.json
# Ignore test files
website/.cache
website/test/stubs-layout-cache/_includes/*.js

View File

@@ -1,66 +0,0 @@
{-# LANGUAGE LambdaCase #-}
module ChatOptions (getChatOpts, ChatOpts (..)) where
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Options.Applicative
import Simplex.Messaging.Agent.Transmission (SMPServer (..), smpServerP)
import System.FilePath (combine)
import Types
data ChatOpts = ChatOpts
{ dbFileName :: String,
smpServer :: SMPServer,
termMode :: TermMode
}
chatOpts :: FilePath -> Parser ChatOpts
chatOpts appDir =
ChatOpts
<$> strOption
( long "database"
<> short 'd'
<> metavar "DB_FILE"
<> help ("sqlite database file path (" <> defaultDbFilePath <> ")")
<> value defaultDbFilePath
)
<*> option
parseSMPServer
( long "server"
<> short 's'
<> metavar "SERVER"
<> help "SMP server to use (smp.simplex.im:5223)"
<> value (SMPServer "smp.simplex.im" (Just "5223") Nothing)
)
<*> option
parseTermMode
( long "term"
<> short 't'
<> metavar "TERM"
<> help ("terminal mode: editor or basic (" <> termModeName TermModeEditor <> ")")
<> value TermModeEditor
)
where
defaultDbFilePath = combine appDir "smp-chat.db"
parseSMPServer :: ReadM SMPServer
parseSMPServer = eitherReader $ A.parseOnly (smpServerP <* A.endOfInput) . B.pack
parseTermMode :: ReadM TermMode
parseTermMode = maybeReader $ \case
"basic" -> Just TermModeBasic
"editor" -> Just TermModeEditor
_ -> Nothing
getChatOpts :: FilePath -> IO ChatOpts
getChatOpts appDir = execParser opts
where
opts =
info
(chatOpts appDir <**> helper)
( fullDesc
<> header "Chat prototype using Simplex Messaging Protocol (SMP)"
<> progDesc "Start chat with DB_FILE file and use SERVER as SMP server"
)

View File

@@ -1,103 +0,0 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module ChatTerminal
( ChatTerminal (..),
newChatTerminal,
chatTerminal,
ttyContact,
ttyFromContact,
)
where
import ChatTerminal.Basic
import ChatTerminal.Core
import ChatTerminal.Editor
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (race_)
import Control.Monad
import Numeric.Natural
import Styled
import System.Terminal
import Types
import UnliftIO.STM
newChatTerminal :: Natural -> TermMode -> IO ChatTerminal
newChatTerminal qSize termMode = do
inputQ <- newTBQueueIO qSize
outputQ <- newTBQueueIO qSize
activeContact <- newTVarIO Nothing
termSize <- withTerminal . runTerminalT $ getWindowSize
let lastRow = height termSize - 1
termState <- newTVarIO newTermState
termLock <- newTMVarIO ()
nextMessageRow <- newTVarIO lastRow
threadDelay 500000 -- this delay is the same as timeout in getTerminalSize
return ChatTerminal {inputQ, outputQ, activeContact, termMode, termState, termSize, nextMessageRow, termLock}
newTermState :: TerminalState
newTermState =
TerminalState
{ inputString = "",
inputPosition = 0,
inputPrompt = "> ",
previousInput = ""
}
chatTerminal :: ChatTerminal -> IO ()
chatTerminal ct
| termSize ct == Size 0 0 || termMode ct == TermModeBasic =
run basicReceiveFromTTY basicSendToTTY
| otherwise = do
withTerminal . runTerminalT $ updateInput ct
run receiveFromTTY sendToTTY
where
run receive send = race_ (receive ct) (send ct)
basicReceiveFromTTY :: ChatTerminal -> IO ()
basicReceiveFromTTY ct =
forever $ getLn >>= atomically . writeTBQueue (inputQ ct)
basicSendToTTY :: ChatTerminal -> IO ()
basicSendToTTY ct = forever $ readOutputQ ct >>= mapM_ putStyledLn
withTermLock :: MonadTerminal m => ChatTerminal -> m () -> m ()
withTermLock ChatTerminal {termLock} action = do
_ <- atomically $ takeTMVar termLock
action
atomically $ putTMVar termLock ()
receiveFromTTY :: ChatTerminal -> IO ()
receiveFromTTY ct@ChatTerminal {inputQ, activeContact, termSize, termState} =
withTerminal . runTerminalT . forever $
getKey >>= processKey >> withTermLock ct (updateInput ct)
where
processKey :: MonadTerminal m => (Key, Modifiers) -> m ()
processKey = \case
(EnterKey, _) -> submitInput
key -> atomically $ do
ac <- readTVar activeContact
modifyTVar termState $ updateTermState ac (width termSize) key
submitInput :: MonadTerminal m => m ()
submitInput = do
msg <- atomically $ do
ts <- readTVar termState
let s = inputString ts
writeTVar termState $ ts {inputString = "", inputPosition = 0, previousInput = s}
writeTBQueue inputQ s
return s
withTermLock ct $ printMessage ct [styleMessage msg]
sendToTTY :: ChatTerminal -> IO ()
sendToTTY ct = forever $ do
-- `readOutputQ` should be outside of `withTerminal` (see #94)
msg <- readOutputQ ct
withTerminal . runTerminalT . withTermLock ct $ do
printMessage ct msg
updateInput ct
readOutputQ :: ChatTerminal -> IO [StyledString]
readOutputQ = atomically . readTBQueue . outputQ

View File

@@ -1,89 +0,0 @@
{-# LANGUAGE LambdaCase #-}
module ChatTerminal.Basic where
import Control.Monad.IO.Class (liftIO)
import Styled
import System.Console.ANSI.Types
import System.Exit (exitSuccess)
import System.Terminal as C
getLn :: IO String
getLn = withTerminal $ runTerminalT getTermLine
putStyledLn :: StyledString -> IO ()
putStyledLn s =
withTerminal . runTerminalT $
putStyled s >> C.putLn >> flush
-- Currently it is assumed that the message does not have internal line breaks.
-- Previous implementation "kind of" supported them,
-- but it was not determining the number of printed lines correctly
-- because of accounting for control sequences in length
putStyled :: MonadTerminal m => StyledString -> m ()
putStyled (s1 :<>: s2) = putStyled s1 >> putStyled s2
putStyled (Styled [] s) = putString s
putStyled (Styled sgr s) = setSGR sgr >> putString s >> resetAttributes
setSGR :: MonadTerminal m => [SGR] -> m ()
setSGR = mapM_ $ \case
Reset -> resetAttributes
SetConsoleIntensity BoldIntensity -> setAttribute bold
SetConsoleIntensity _ -> resetAttribute bold
SetItalicized True -> setAttribute italic
SetItalicized _ -> resetAttribute italic
SetUnderlining NoUnderline -> resetAttribute underlined
SetUnderlining _ -> setAttribute underlined
SetSwapForegroundBackground True -> setAttribute inverted
SetSwapForegroundBackground _ -> resetAttribute inverted
SetColor l i c -> setAttribute . layer l . intensity i $ color c
SetBlinkSpeed _ -> pure ()
SetVisible _ -> pure ()
SetRGBColor _ _ -> pure ()
SetPaletteColor _ _ -> pure ()
SetDefaultColor _ -> pure ()
where
layer = \case
Foreground -> foreground
Background -> background
intensity = \case
Dull -> id
Vivid -> bright
color = \case
Black -> black
Red -> red
Green -> green
Yellow -> yellow
Blue -> blue
Magenta -> magenta
Cyan -> cyan
White -> white
getKey :: MonadTerminal m => m (Key, Modifiers)
getKey =
flush >> awaitEvent >>= \case
Left Interrupt -> liftIO exitSuccess
Right (KeyEvent key ms) -> pure (key, ms)
_ -> getKey
getTermLine :: MonadTerminal m => m String
getTermLine = getChars ""
where
getChars s =
getKey >>= \(key, ms) -> case key of
CharKey c
| ms == mempty || ms == shiftKey -> do
C.putChar c
flush
getChars (c : s)
| otherwise -> getChars s
EnterKey -> do
C.putLn
flush
pure $ reverse s
BackspaceKey -> do
moveCursorBackward 1
eraseChars 1
flush
getChars $ if null s then s else tail s
_ -> getChars s

View File

@@ -1,139 +0,0 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module ChatTerminal.Core where
import Control.Concurrent.STM
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.List (dropWhileEnd)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding
import Styled
import System.Console.ANSI.Types
import System.Terminal hiding (insertChars)
import Types
data ChatTerminal = ChatTerminal
{ inputQ :: TBQueue String,
outputQ :: TBQueue [StyledString],
activeContact :: TVar (Maybe Contact),
termMode :: TermMode,
termState :: TVar TerminalState,
termSize :: Size,
nextMessageRow :: TVar Int,
termLock :: TMVar ()
}
data TerminalState = TerminalState
{ inputPrompt :: String,
inputString :: String,
inputPosition :: Int,
previousInput :: String
}
inputHeight :: TerminalState -> ChatTerminal -> Int
inputHeight ts ct = length (inputPrompt ts <> inputString ts) `div` width (termSize ct) + 1
positionRowColumn :: Int -> Int -> Position
positionRowColumn wid pos =
let row = pos `div` wid
col = pos - row * wid
in Position {row, col}
updateTermState :: Maybe Contact -> Int -> (Key, Modifiers) -> TerminalState -> TerminalState
updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p} = case key of
CharKey c
| ms == mempty || ms == shiftKey -> insertCharsWithContact [c]
| ms == altKey && c == 'b' -> setPosition prevWordPos
| ms == altKey && c == 'f' -> setPosition nextWordPos
| otherwise -> ts
TabKey -> insertCharsWithContact " "
BackspaceKey -> backDeleteChar
DeleteKey -> deleteChar
HomeKey -> setPosition 0
EndKey -> setPosition $ length s
ArrowKey d -> case d of
Leftwards -> setPosition leftPos
Rightwards -> setPosition rightPos
Upwards
| ms == mempty && null s -> let s' = previousInput ts in ts' (s', length s')
| ms == mempty -> let p' = p - tw in if p' > 0 then setPosition p' else ts
| otherwise -> ts
Downwards
| ms == mempty -> let p' = p + tw in if p' <= length s then setPosition p' else ts
| otherwise -> ts
_ -> ts
where
insertCharsWithContact cs
| null s && cs /= "@" && cs /= "/" =
insertChars $ contactPrefix <> cs
| otherwise = insertChars cs
insertChars = ts' . if p >= length s then append else insert
append cs = let s' = s <> cs in (s', length s')
insert cs = let (b, a) = splitAt p s in (b <> cs <> a, p + length cs)
contactPrefix = case ac of
Just (Contact c) -> "@" <> B.unpack c <> " "
Nothing -> ""
backDeleteChar
| p == 0 || null s = ts
| p >= length s = ts' (init s, length s - 1)
| otherwise = let (b, a) = splitAt p s in ts' (init b <> a, p - 1)
deleteChar
| p >= length s || null s = ts
| p == 0 = ts' (tail s, 0)
| otherwise = let (b, a) = splitAt p s in ts' (b <> tail a, p)
leftPos
| ms == mempty = max 0 (p - 1)
| ms == shiftKey = 0
| ms == ctrlKey = prevWordPos
| ms == altKey = prevWordPos
| otherwise = p
rightPos
| ms == mempty = min (length s) (p + 1)
| ms == shiftKey = length s
| ms == ctrlKey = nextWordPos
| ms == altKey = nextWordPos
| otherwise = p
setPosition p' = ts' (s, p')
prevWordPos
| p == 0 || null s = p
| otherwise =
let before = take p s
beforeWord = dropWhileEnd (/= ' ') $ dropWhileEnd (== ' ') before
in max 0 $ p - length before + length beforeWord
nextWordPos
| p >= length s || null s = p
| otherwise =
let after = drop p s
afterWord = dropWhile (/= ' ') $ dropWhile (== ' ') after
in min (length s) $ p + length after - length afterWord
ts' (s', p') = ts {inputString = s', inputPosition = p'}
styleMessage :: String -> StyledString
styleMessage = \case
"" -> ""
s@('@' : _) -> let (c, rest) = span (/= ' ') s in Styled selfSGR c <> markdown rest
s -> markdown s
where
markdown :: String -> StyledString
markdown = styleMarkdownText . T.pack
safeDecodeUtf8 :: ByteString -> Text
safeDecodeUtf8 = decodeUtf8With onError
where
onError _ _ = Just '?'
ttyContact :: Contact -> StyledString
ttyContact (Contact a) = Styled contactSGR $ B.unpack a
ttyFromContact :: Contact -> StyledString
ttyFromContact (Contact a) = Styled contactSGR $ B.unpack a <> "> "
contactSGR :: [SGR]
contactSGR = [SetColor Foreground Vivid Yellow]
selfSGR :: [SGR]
selfSGR = [SetColor Foreground Vivid Cyan]

View File

@@ -1,61 +0,0 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-}
module ChatTerminal.Editor where
import ChatTerminal.Basic
import ChatTerminal.Core
import Styled
import System.Terminal
import UnliftIO.STM
-- debug :: MonadTerminal m => String -> m ()
-- debug s = do
-- saveCursor
-- setCursorPosition $ Position 0 0
-- putString s
-- restoreCursor
updateInput :: forall m. MonadTerminal m => ChatTerminal -> m ()
updateInput ct@ChatTerminal {termSize = Size {height, width}, termState, nextMessageRow} = do
hideCursor
ts <- readTVarIO termState
nmr <- readTVarIO nextMessageRow
let ih = inputHeight ts ct
iStart = height - ih
prompt = inputPrompt ts
Position {row, col} = positionRowColumn width $ length prompt + inputPosition ts
if nmr >= iStart
then atomically $ writeTVar nextMessageRow iStart
else clearLines nmr iStart
setCursorPosition $ Position {row = max nmr iStart, col = 0}
putString $ prompt <> inputString ts <> " "
eraseInLine EraseForward
setCursorPosition $ Position {row = iStart + row, col}
showCursor
flush
where
clearLines :: Int -> Int -> m ()
clearLines from till
| from >= till = return ()
| otherwise = do
setCursorPosition $ Position {row = from, col = 0}
eraseInLine EraseForward
clearLines (from + 1) till
printMessage :: forall m. MonadTerminal m => ChatTerminal -> [StyledString] -> m ()
printMessage ChatTerminal {termSize = Size {height, width}, nextMessageRow} msg = do
nmr <- readTVarIO nextMessageRow
setCursorPosition $ Position {row = nmr, col = 0}
mapM_ printStyled msg
flush
let lc = sum $ map lineCount msg
atomically . writeTVar nextMessageRow $ min (height - 1) (nmr + lc)
where
lineCount :: StyledString -> Int
lineCount s = sLength s `div` width + 1
printStyled :: StyledString -> m ()
printStyled s = do
putStyled s
eraseInLine EraseForward
putLn

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM haskell:8.10.4 AS build-stage
# if you encounter "version `GLIBC_2.28' not found" error when running
# chat client executable, build with the following base image instead:
# FROM haskell:8.10.4-stretch AS build-stage
COPY . /project
WORKDIR /project
RUN stack install
FROM scratch AS export-stage
COPY --from=build-stage /root/.local/bin/simplex-chat /

661
LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

279
Main.hs
View File

@@ -1,279 +0,0 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Main where
import ChatOptions
import ChatTerminal
import ChatTerminal.Core
import Control.Applicative ((<|>))
import Control.Concurrent.STM
import Control.Logger.Simple
import Control.Monad.Reader
import Data.Attoparsec.ByteString.Char8 (Parser)
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Functor (($>))
import Data.List (intersperse)
import qualified Data.Text as T
import Data.Text.Encoding
import Numeric.Natural
import Simplex.Markdown
import Simplex.Messaging.Agent (getSMPAgentClient, runSMPAgentClient)
import Simplex.Messaging.Agent.Client (AgentClient (..))
import Simplex.Messaging.Agent.Env.SQLite
import Simplex.Messaging.Agent.Transmission
import Simplex.Messaging.Client (smpDefaultConfig)
import Simplex.Messaging.Util (raceAny_)
import Styled
import System.Console.ANSI.Types
import System.Directory (getAppUserDataDirectory)
import Types
cfg :: AgentConfig
cfg =
AgentConfig
{ tcpPort = undefined, -- TODO maybe take it out of config
rsaKeySize = 2048 `div` 8,
connIdBytes = 12,
tbqSize = 16,
dbFile = "smp-chat.db",
smpCfg = smpDefaultConfig
}
logCfg :: LogConfig
logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
data ChatClient = ChatClient
{ inQ :: TBQueue ChatCommand,
outQ :: TBQueue ChatResponse,
smpServer :: SMPServer
}
-- | GroupMessage ChatGroup ByteString
-- | AddToGroup Contact
data ChatCommand
= ChatHelp
| MarkdownHelp
| AddConnection Contact
| Connect Contact SMPQueueInfo
| DeleteConnection Contact
| ResetChat
| SendMessage Contact ByteString
chatCommandP :: Parser ChatCommand
chatCommandP =
("/help" <|> "/h") $> ChatHelp
<|> ("/markdown" <|> "/m") $> MarkdownHelp
<|> ("/add " <|> "/a ") *> (AddConnection <$> contact)
<|> ("/connect " <> "/c ") *> connect
<|> ("/delete " <> "/d ") *> (DeleteConnection <$> contact)
<|> ("/reset" <> "/r") $> ResetChat
<|> "@" *> sendMessage
where
connect = Connect <$> contact <* A.space <*> smpQueueInfoP
sendMessage = SendMessage <$> contact <* A.space <*> A.takeByteString
contact = Contact <$> A.takeTill (== ' ')
data ChatResponse
= ChatHelpInfo
| MarkdownInfo
| Invitation SMPQueueInfo
| Connected Contact
| Confirmation Contact
| ReceivedMessage Contact ByteString
| Disconnected Contact
| YesYes
| ErrorInput ByteString
| ChatError AgentErrorType
| NoChatResponse
serializeChatResponse :: ChatResponse -> [StyledString]
serializeChatResponse = \case
ChatHelpInfo -> chatHelpInfo
MarkdownInfo -> markdownInfo
Invitation qInfo ->
[ "pass this invitation to your contact (via any channel): ",
"",
(bPlain . serializeSmpQueueInfo) qInfo,
"",
"and ask them to connect: /c <name_for_you> <invitation_above>"
]
Connected c -> [ttyContact c <> " connected"]
Confirmation c -> [ttyContact c <> " ok"]
ReceivedMessage c t -> prependFirst (ttyFromContact c) $ msgPlain t
Disconnected c -> ["disconnected from " <> ttyContact c <> " - try \"/chat " <> bPlain (toBs c) <> "\""]
YesYes -> ["you got it!"]
ErrorInput t -> ["invalid input: " <> bPlain t]
ChatError e -> ["chat error: " <> plain (show e)]
NoChatResponse -> [""]
where
prependFirst :: StyledString -> [StyledString] -> [StyledString]
prependFirst s [] = [s]
prependFirst s (s' : ss) = (s <> s') : ss
msgPlain :: ByteString -> [StyledString]
msgPlain = map styleMarkdownText . T.lines . safeDecodeUtf8
chatHelpInfo :: [StyledString]
chatHelpInfo =
map
styleMarkdown
[ "Using chat:",
highlight "/add <name>" <> " - create invitation to send out-of-band to your contact <name>",
" (<name> is the alias you choose to message your contact)",
highlight "/connect <name> <invitation>" <> " - connect using <invitation>",
" (a string returned by /add that starts from \"smp::\")",
" if /connect is used by your contact,",
" <name> is the alias your contact chooses to message you",
highlight "@<name> <message>" <> " - send <message> (any string) to contact <name>",
" @<name> will be auto-typed to send to the previous contact -",
" just start typing the message!",
highlight "/delete" <> " - delete contact and all messages you had with them",
highlight "/reset" <> " - reset chat and all connections",
highlight "/markdown" <> " - markdown cheat-sheet",
"",
"Commands can be abbreviated to 1 letter: ",
listCommands ["/h", "/a", "/c", "/d", "/r", "/m"]
]
where
listCommands = mconcat . intersperse ", " . map highlight
highlight = Markdown (Colored Cyan)
markdownInfo :: [StyledString]
markdownInfo =
map
styleMarkdown
[ "Markdown:",
" *bold* - " <> Markdown Bold "bold text",
" _italic_ - " <> Markdown Italic "italic text" <> " (shown as underlined)",
" +underlined+ - " <> Markdown Underline "underlined text",
" ~strikethrough~ - " <> Markdown StrikeThrough "strikethrough text" <> " (shown as inverse)",
" `code snippet` - " <> Markdown Snippet "a + b // no *markdown* here",
" !r text! - " <> red "red text" <> " (red, green, blue, yellow, cyan, magenta)",
" !1 text! - " <> red "also red text" <> " (1-6)",
" #secret# - " <> Markdown Secret "secret text" <> " (can be copy-pasted)"
]
where
red = Markdown (Colored Red)
main :: IO ()
main = do
ChatOpts {dbFileName, smpServer, termMode} <- welcomeGetOpts
t <- getChatClient smpServer
ct <- newChatTerminal (tbqSize cfg) termMode
-- setLogLevel LogInfo -- LogError
-- withGlobalLogging logCfg $
env <- newSMPAgentEnv cfg {dbFile = dbFileName}
dogFoodChat t ct env
welcomeGetOpts :: IO ChatOpts
welcomeGetOpts = do
appDir <- getAppUserDataDirectory "simplex"
opts@ChatOpts {dbFileName} <- getChatOpts appDir
putStrLn "SimpleX chat prototype"
putStrLn $ "db: " <> dbFileName
putStrLn "type \"/help\" or \"/h\" for usage info"
pure opts
dogFoodChat :: ChatClient -> ChatTerminal -> Env -> IO ()
dogFoodChat t ct env = do
c <- runReaderT getSMPAgentClient env
raceAny_
[ runReaderT (runSMPAgentClient c) env,
sendToAgent t ct c,
sendToChatTerm t ct,
receiveFromAgent t ct c,
receiveFromChatTerm t ct,
chatTerminal ct
]
getChatClient :: SMPServer -> IO ChatClient
getChatClient srv = atomically $ newChatClient (tbqSize cfg) srv
newChatClient :: Natural -> SMPServer -> STM ChatClient
newChatClient qSize smpServer = do
inQ <- newTBQueue qSize
outQ <- newTBQueue qSize
return ChatClient {inQ, outQ, smpServer}
receiveFromChatTerm :: ChatClient -> ChatTerminal -> IO ()
receiveFromChatTerm t ct = forever $ do
atomically (readTBQueue $ inputQ ct)
>>= processOrError . A.parseOnly (chatCommandP <* A.endOfInput) . encodeUtf8 . T.pack
where
processOrError = \case
Left err -> writeOutQ . ErrorInput $ B.pack err
Right ChatHelp -> writeOutQ ChatHelpInfo
Right MarkdownHelp -> writeOutQ MarkdownInfo
Right cmd -> atomically $ writeTBQueue (inQ t) cmd
writeOutQ = atomically . writeTBQueue (outQ t)
sendToChatTerm :: ChatClient -> ChatTerminal -> IO ()
sendToChatTerm ChatClient {outQ} ChatTerminal {outputQ} = forever $ do
atomically (readTBQueue outQ) >>= \case
NoChatResponse -> return ()
resp -> atomically . writeTBQueue outputQ $ serializeChatResponse resp
sendToAgent :: ChatClient -> ChatTerminal -> AgentClient -> IO ()
sendToAgent ChatClient {inQ, smpServer} ct AgentClient {rcvQ} = do
atomically $ writeTBQueue rcvQ ("1", "", SUBALL) -- hack for subscribing to all
forever . atomically $ do
cmd <- readTBQueue inQ
writeTBQueue rcvQ `mapM_` agentTransmission cmd
setActiveContact cmd
where
setActiveContact :: ChatCommand -> STM ()
setActiveContact = \case
SendMessage a _ -> setActive ct a
DeleteConnection a -> unsetActive ct a
_ -> pure ()
agentTransmission :: ChatCommand -> Maybe (ATransmission 'Client)
agentTransmission = \case
AddConnection a -> transmission a $ NEW smpServer
Connect a qInfo -> transmission a $ JOIN qInfo $ ReplyVia smpServer
DeleteConnection a -> transmission a DEL
ResetChat -> transmission (Contact "") SUBALL
SendMessage a msg -> transmission a $ SEND msg
ChatHelp -> Nothing
MarkdownHelp -> Nothing
transmission :: Contact -> ACommand 'Client -> Maybe (ATransmission 'Client)
transmission (Contact a) cmd = Just ("1", a, cmd)
receiveFromAgent :: ChatClient -> ChatTerminal -> AgentClient -> IO ()
receiveFromAgent t ct c = forever . atomically $ do
resp <- chatResponse <$> readTBQueue (sndQ c)
writeTBQueue (outQ t) resp
setActiveContact resp
where
chatResponse :: ATransmission 'Agent -> ChatResponse
chatResponse (_, a, resp) = case resp of
INV qInfo -> Invitation qInfo
CON -> Connected contact
END -> Disconnected contact
MSG {m_body} -> ReceivedMessage contact m_body
SENT _ -> NoChatResponse
OK -> Confirmation contact
ERR e -> ChatError e
where
contact = Contact a
setActiveContact :: ChatResponse -> STM ()
setActiveContact = \case
Connected a -> setActive ct a
ReceivedMessage a _ -> setActive ct a
Disconnected a -> unsetActive ct a
_ -> pure ()
setActive :: ChatTerminal -> Contact -> STM ()
setActive ct = writeTVar (activeContact ct) . Just
unsetActive :: ChatTerminal -> Contact -> STM ()
unsetActive ct a = modifyTVar (activeContact ct) unset
where
unset a' = if Just a == a' then Nothing else a'

90
PRIVACY.md Normal file
View 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

240
README.md Normal file
View File

@@ -0,0 +1,240 @@
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
[![build](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml/badge.svg?branch=stable)](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
[![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases)
[![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases)
[![Follow on Twitter](https://img.shields.io/twitter/follow/SimpleXChat?style=social)](https://twitter.com/SimpleXChat)
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat)
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
&nbsp;
[![Android app](https://github.com/simplex-chat/.github/blob/master/profile/images/google_play.svg)](https://play.google.com/store/apps/details?id=chat.simplex.app)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
&nbsp;
[<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)
&nbsp;
[<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 end-to-end encryption, with additional encryption layer.
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
## Contents
- [Why privacy matters](#why-privacy-matters)
- [SimpleX approach to privacy and security](#simplex-approach-to-privacy-and-security)
- [Complete privacy](#complete-privacy-of-your-identity-profile-contacts-and-metadata)
- [Protection against spam and abuse](#the-best-protection-against-spam-and-abuse)
- [Ownership and security of your data](#complete-ownership-control-and-security-of-your-data)
- [Users own SimpleX network](#users-own-simplex-network)
- [Frequently asked questions](#frequently-asked-questions)
- [News and updates](#news-and-updates)
- [Make a private connection](#make-a-private-connection)
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
- [SimpleX Platform design](#simplex-platform-design)
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
- [For developers](#for-developers)
- [Roadmap](#roadmap)
- [Help us pay for 3rd party security audit](#help-us-pay-for-3rd-party-security-audit)
- [Disclaimer, License](#disclaimer)
## Why privacy matters
Everyone should care about privacy and security of their communications - innocuous conversations can put you in danger even if there is nothing to hide.
One of the most shocking stories is the experience of [Mohamedou Ould Salahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi) that he wrote about in his memoir and that is shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the 10 years prior to the attacks.
It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks - who we are connected with.
## SimpleX approach to privacy and security
### Complete privacy of your identity, profile, contacts and metadata
**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - not even random numbers. This protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. [Read more](./docs/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata).
### The best protection against spam and abuse
As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. [Read more](./docs/SIMPLEX.md#the-best-protection-against-spam-and-abuse).
### Complete ownership, control and security of your data
SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received. [Read more](./docs/SIMPLEX.md#complete-ownership-control-and-security-of-your-data).
### Users own SimpleX network
You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. [Read more](./docs/SIMPLEX.md#users-own-simplex-network).
## Frequently asked questions
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release annoucement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
2. _Why should I not just use Signal?_ Signal is a centralised platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identites?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
## News and updates
Recent updates:
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
[All updates](./blog)
## Make a private connection
You need to share a link or scan a QR code (in person or during a video call) to make a connection and start messaging.
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/conversation.png" alt="Make a private connection" width="594" height="360">
## :zap: Quick installation of a terminal app
```sh
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash
```
Once the chat client is installed, simply run `simplex-chat` from your terminal.
![simplex-chat](./images/connection.gif)
Read more about [installing and using the terminal app](./docs/CLI.md).
## SimpleX Platform design
SimpleX is a client-server network with a unique network topology that uses redundant, disposable message relay nodes to asynchronously pass messages via unidirectional (simplex) message queues, providing recipient and sender anonymity.
Unlike P2P networks, all messages are passed through one or several server nodes, that do not even need to have persistence. In fact, the current [SMP server implementation](https://github.com/simplex-chat/simplexmq#smp-server) uses in-memory message storage, persisting only the queue records. SimpleX provides better metadata protection than P2P designs, as no global participant identifiers are used to deliver messages, and avoids [the problems of P2P networks](./docs/SIMPLEX.md#comparison-with-p2p-messaging-protocols).
Unlike federated networks, the server nodes **do not have records of the users**, **do not communicate with each other** and **do not store messages** after they are delivered to the recipients. There is no way to discover the full list of servers participating in SimpleX network. This design avoids the problem of metadata visibility that all federated networks have and better protects from the network-wide attacks.
Only the client devices have information about users, their contacts and groups.
See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md) for more information on platform objectives and technical design.
See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of messages sent between chat clients over [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md).
## Privacy: technical details and limitations
SimpleX Chat is a work in progress we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
What is already implemented:
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notificaitons on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
2. End-to-end encryption in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with forward secrecy (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
5. Several levels of content padding to frustrate message size attacks.
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
We plan to add soon:
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
2. Local database encryption. Currently the local chat database stored on your device is not encrypted.
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
4. Independent implementation audit.
## For developers
You can:
- use SimpleX Chat library to integrate chat functionality into your mobile apps.
- create chat bots and services in Haskell - see [simple](./apps/simplex-bot/) and more [advanced chat bot example](./apps/simplex-bot-advanced/).
- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScipt chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
If you are considering developing with SimpleX platform please get in touch for any advice and support.
## Roadmap
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
- ✅ Terminal (console) client with groups and files support.
- ✅ One-click SimpleX server deployment on Linode.
- ✅ End-to-end encryption using double-ratchet protocol with additional encryption layer.
- ✅ Mobile apps v1 for Android and iOS.
- ✅ Private instant notifications for Android using background service.
- ✅ Haskell chat bot templates.
- ✅ v2.0 - supporting images and files in mobile apps.
- ✅ Manual chat history deletion.
- ✅ End-to-end encrypted WebRTC audio and video calls via the mobile apps.
- ✅ Privacy preserving instant notifications for iOS using Apple Push Notification service.
- ✅ Chat database export and import
- ✅ Chat groups in mobile apps.
- ✅ Connecting to messaging servers via Tor.
- ✅ Dual server addresses to access messaging servers as v3 hidden services.
- ✅ Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (ready for announcement).
- ✅ Incognito mode to share a new random name with each contact.
- 🏗 Chat database encryption.
- 🏗 Links to join groups and improve groups stability.
- Disappearing messages, with mutual agreement.
- Voice messages
- Video messages
- Web widgets for custom interactivity in the chats.
- SMP protocol improvements:
- SMP queue redundancy and rotation.
- Message delivery confirmation.
- Supporting the same profile on multiple devices.
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
- keep all your contacts and groups even if you lose the domain.
- the server doesn't have information about your contacts and groups.
- Channels server for large groups and broadcast channels.
- Media server to optimize sending large files to groups.
- Desktop client.
- Using the same profile on multiple devices.
## Help us pay for 3rd party security audit
I will get straight to the point: I ask you to support SimpleX Chat with donations.
We are prioritizing users privacy and security - it would be impossible without your support we were lucky to have so far.
We are planning a 3rd party security audit for the app, and it would hugely help us if some part of this $20,000+ expense could be covered with donations.
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
If you are already using SimpleX Chat, or plan to use it in the future when it has more features, please consider making a donation - it will help us to raise more funds. Donating any amount, even the price of the cup of coffee, would make a huge difference for us.
It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
Thank you,
Evgeny
SimpleX Chat founder
## Disclaimer
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed and had many improvements in v1.0.0; we are currently arranging for the independent implementation audit.
You are likely to discover some bugs - we would really appreciate if you use it and let us know anything that needs to be fixed or improved.
## License
[AGPL v3](./LICENSE)
[<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)
&nbsp;
[![Android app](https://github.com/simplex-chat/.github/blob/master/profile/images/google_play.svg)](https://play.google.com/store/apps/details?id=chat.simplex.app)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
&nbsp;
[<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)
&nbsp;
[<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)

View File

@@ -1,60 +0,0 @@
module Styled
( StyledString (..),
bPlain,
plain,
styleMarkdown,
styleMarkdownText,
sLength,
)
where
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.String
import Data.Text (Text)
import qualified Data.Text as T
import Simplex.Markdown
import System.Console.ANSI.Types
data StyledString = Styled [SGR] String | StyledString :<>: StyledString
instance Semigroup StyledString where (<>) = (:<>:)
instance Monoid StyledString where mempty = plain ""
instance IsString StyledString where fromString = plain
plain :: String -> StyledString
plain = Styled []
bPlain :: ByteString -> StyledString
bPlain = Styled [] . B.unpack
styleMarkdownText :: Text -> StyledString
styleMarkdownText = styleMarkdown . parseMarkdown
styleMarkdown :: Markdown -> StyledString
styleMarkdown (s1 :|: s2) = styleMarkdown s1 <> styleMarkdown s2
styleMarkdown (Markdown Snippet s) = '`' `wrap` styled Snippet s
styleMarkdown (Markdown Secret s) = '#' `wrap` styled Secret s
styleMarkdown (Markdown f s) = styled f s
wrap :: Char -> StyledString -> StyledString
wrap c s = plain [c] <> s <> plain [c]
styled :: Format -> Text -> StyledString
styled f = Styled sgr . T.unpack
where
sgr = case f of
Bold -> [SetConsoleIntensity BoldIntensity]
Italic -> [SetUnderlining SingleUnderline, SetItalicized True]
Underline -> [SetUnderlining SingleUnderline]
StrikeThrough -> [SetSwapForegroundBackground True]
Colored c -> [SetColor Foreground Vivid c]
Secret -> [SetColor Foreground Dull Black, SetColor Background Dull Black]
Snippet -> []
NoFormat -> []
sLength :: StyledString -> Int
sLength (Styled _ s) = length s
sLength (s1 :<>: s2) = sLength s1 + sLength s2

View File

@@ -1,14 +0,0 @@
{-# LANGUAGE LambdaCase #-}
module Types where
import Data.ByteString.Char8 (ByteString)
newtype Contact = Contact {toBs :: ByteString} deriving (Eq)
data TermMode = TermModeBasic | TermModeEditor deriving (Eq)
termModeName :: TermMode -> String
termModeName = \case
TermModeBasic -> "basic"
TermModeEditor -> "editor"

19
apps/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/deploymentTargetDropDown.xml
/.idea/misc.xml
/.idea/uiDesigner.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
app/src/main/cpp/libs/

1
apps/android/.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
SimpleX

149
apps/android/.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,149 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<ComposeCustomCodeStyleSettings>
<option name="USE_CUSTOM_FORMATTING_FOR_MODIFIERS" value="false" />
</ComposeCustomCodeStyleSettings>
<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_FIRST_COLUMN_COMMENT" value="false" />
<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="ALIGN_MULTILINE_PARAMETERS" value="false" />
<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>

View 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
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>

19
apps/android/.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View 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
View 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
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,198 @@
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 54
versionName "4.0-beta.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters 'arm64-v8a'
}
vectorDrawables {
useSupportLibrary true
}
externalNativeBuild {
cmake {
cppFlags ''
}
}
manifestPlaceholders.app_name = "@string/app_name"
manifestPlaceholders.provider_authorities = "chat.simplex.app.provider"
manifestPlaceholders.extract_native_libs = compression_level != "0"
}
buildTypes {
debug {
applicationIdSuffix "$application_id_suffix"
debuggable new Boolean("$enable_debuggable")
manifestPlaceholders.app_name = "$app_name"
// Provider can't be the same for different apps on the same device
manifestPlaceholders.provider_authorities = "chat.simplex.app${application_id_suffix}.provider"
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi"
freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi"
freeCompilerArgs += "-opt-in=androidx.compose.material.ExperimentalMaterialApi"
freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets"
freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi"
freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi"
freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
}
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
}
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
jniLibs.useLegacyPackaging = compression_level != "0"
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
implementation 'androidx.activity:activity-compose:1.4.0'
implementation 'androidx.fragment:fragment:1.4.1'
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.compose.ui:ui-util:$compose_version"
implementation "androidx.navigation:navigation-compose:2.4.1"
implementation "com.google.accompanist:accompanist-insets:0.23.0"
implementation 'androidx.webkit:webkit:1.4.0'
implementation "com.godaddy.android.colorpicker:compose-color-picker:0.4.2"
def work_version = "2.7.1"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.work:work-multiprocess:$work_version"
def camerax_version = "1.1.0-beta01"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
//Barcode
implementation 'org.boofcv:boofcv-android:0.40.1'
implementation 'org.boofcv:boofcv-core:0.40.1'
//Camera Permission
implementation "com.google.accompanist:accompanist-permissions:0.23.0"
// Link Previews
implementation 'org.jsoup:jsoup:1.13.1'
// Biometric authentication
implementation 'androidx.biometric:biometric:1.2.0-alpha04'
// GIFs support
implementation "io.coil-kt:coil-compose:2.1.0"
implementation "io.coil-kt:coil-gif:2.1.0"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
}
def buildType = "unknown"
// Don't do anything if no compression is needed
if (compression_level != "0") {
tasks.whenTaskAdded { task ->
if (task.name == 'packageDebug') {
task.doLast {
buildType = "debug"
}
task.finalizedBy compressApk
} else if (task.name == 'packageRelease') {
task.doLast {
buildType = "release"
}
task.finalizedBy compressApk
}
}
}
tasks.register("compressApk") {
doLast {
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
def sdkDir = android.getSdkDirectory().getAbsolutePath()
def keyAlias = ""
def keyPassword = ""
def storeFile = ""
def storePassword = ""
if (project.properties['android.injected.signing.key.alias'] != null) {
keyAlias = project.properties['android.injected.signing.key.alias']
keyPassword = project.properties['android.injected.signing.key.password']
storeFile = project.properties['android.injected.signing.store.file']
storePassword = project.properties['android.injected.signing.store.password']
} else if (android.signingConfigs.hasProperty(buildType)) {
def gradleConfig = android.signingConfigs[buildType]
keyAlias = gradleConfig.keyAlias
keyPassword = gradleConfig.keyPassword
storeFile = gradleConfig.storeFile
storePassword = gradleConfig.storePassword
} else {
// There is no signing config for current build type, can't sign the apk
println("No signing configs for this build type: $buildType")
return
}
def outputDir = tasks["package${buildType.capitalize()}"].outputs.files.last()
exec {
workingDir '../../../scripts/android'
setEnvironment(['JAVA_HOME': "$javaHome"])
commandLine './compress-and-sign-apk.sh', \
"$compression_level", \
"$outputDir", \
"$sdkDir", \
"$storeFile", \
"$storePassword", \
"$keyAlias", \
"$keyPassword"
}
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
}
// View all gradle properties set
// project.properties.each { k, v -> println "$k -> $v" }
}
}

21
apps/android/app/proguard-rules.pro vendored Normal file
View 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

View File

@@ -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)
}
}

View File

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="chat.simplex.app">
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIDEO_CAPTURE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<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" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<application
android:name="SimplexApp"
android:allowBackup="true"
android:icon="@mipmap/icon"
android:label="${app_name}"
android:extractNativeLibs="${extract_native_libs}"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<!-- Main activity -->
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:exported="true"
android:label="${app_name}"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.SimpleX">
<intent-filter>
<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>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="simplex.chat" />
<data android:pathPrefix="/invitation" />
<data android:pathPrefix="/contact" />
</intent-filter>
</activity>
<activity-alias
android:name=".MainActivity_default"
android:exported="true"
android:icon="@mipmap/icon"
android:enabled="true"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivity_dark_blue"
android:exported="true"
android:icon="@mipmap/icon_dark_blue"
android:enabled="false"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true"/>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${provider_authorities}"
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>

View File

@@ -0,0 +1,3 @@
# WebView for WebRTC calls in SimpleX Chat
Do NOT edit call.js here, it is compiled abd copied here from call.ts in packages/simplex-chat-webrtc

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link href="./style.css" rel="stylesheet" />
<script src="./lz-string.min.js"></script>
</head>
<body>
<video
id="remote-video-stream"
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
></video>
<video
id="local-video-stream"
muted
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
></video>
</body>
<footer>
<script src="./call.js"></script>
</footer>
</html>

View File

@@ -0,0 +1,650 @@
"use strict";
// Inspired by
// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption
var CallMediaType;
(function (CallMediaType) {
CallMediaType["Audio"] = "audio";
CallMediaType["Video"] = "video";
})(CallMediaType || (CallMediaType = {}));
var VideoCamera;
(function (VideoCamera) {
VideoCamera["User"] = "user";
VideoCamera["Environment"] = "environment";
})(VideoCamera || (VideoCamera = {}));
// for debugging
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
// Global object with cryptrographic/encoding functions
const callCrypto = callCryptoFunction();
var TransformOperation;
(function (TransformOperation) {
TransformOperation["Encrypt"] = "encrypt";
TransformOperation["Decrypt"] = "decrypt";
})(TransformOperation || (TransformOperation = {}));
let activeCall;
const processCommand = (function () {
const defaultIceServers = [
{ urls: ["stun:stun.simplex.im:5349"] },
{ urls: ["turn:turn.simplex.im:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
];
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
return {
peerConnectionConfig: {
iceServers: iceServers !== null && iceServers !== void 0 ? iceServers : defaultIceServers,
iceCandidatePoolSize: 10,
encodedInsertableStreams,
iceTransportPolicy: relay ? "relay" : "all",
},
iceCandidates: {
delay: 3000,
extrasInterval: 2000,
extrasTimeout: 8000,
},
};
}
function getIceCandidates(conn, config) {
return new Promise((resolve, _) => {
let candidates = [];
let resolved = false;
let extrasInterval;
let extrasTimeout;
const delay = setTimeout(() => {
if (!resolved) {
resolveIceCandidates();
extrasInterval = setInterval(() => {
sendIceCandidates();
}, config.iceCandidates.extrasInterval);
extrasTimeout = setTimeout(() => {
clearInterval(extrasInterval);
sendIceCandidates();
}, config.iceCandidates.extrasTimeout);
}
}, config.iceCandidates.delay);
conn.onicecandidate = ({ candidate: c }) => c && candidates.push(c);
conn.onicegatheringstatechange = () => {
if (conn.iceGatheringState == "complete") {
if (resolved) {
if (extrasInterval)
clearInterval(extrasInterval);
if (extrasTimeout)
clearTimeout(extrasTimeout);
sendIceCandidates();
}
else {
resolveIceCandidates();
}
}
};
function resolveIceCandidates() {
if (delay)
clearTimeout(delay);
resolved = true;
const iceCandidates = serialize(candidates);
candidates = [];
resolve(iceCandidates);
}
function sendIceCandidates() {
if (candidates.length === 0)
return;
const iceCandidates = serialize(candidates);
candidates = [];
sendMessageToNative({ resp: { type: "ice", iceCandidates } });
}
});
}
async function initializeCall(config, mediaType, aesKey, useWorker) {
const pc = new RTCPeerConnection(config.peerConnectionConfig);
const remoteStream = new MediaStream();
const localCamera = VideoCamera.User;
const localStream = await getLocalMediaStream(mediaType, localCamera);
const iceCandidates = getIceCandidates(pc, config);
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker };
await setupMediaStreams(call);
pc.addEventListener("connectionstatechange", connectionStateChange);
return call;
async function connectionStateChange() {
sendMessageToNative({
resp: {
type: "connection",
state: {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
iceGatheringState: pc.iceGatheringState,
signalingState: pc.signalingState,
},
},
});
if (pc.connectionState == "disconnected" || pc.connectionState == "failed") {
pc.removeEventListener("connectionstatechange", connectionStateChange);
if (activeCall) {
setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
}
endCall();
}
else if (pc.connectionState == "connected") {
const stats = (await pc.getStats());
for (const stat of stats.values()) {
const { type, state } = stat;
if (type === "candidate-pair" && state === "succeeded") {
const iceCandidatePair = stat;
const resp = {
type: "connected",
connectionInfo: {
iceCandidatePair,
localCandidate: stats.get(iceCandidatePair.localCandidateId),
remoteCandidate: stats.get(iceCandidatePair.remoteCandidateId),
},
};
setTimeout(() => sendMessageToNative({ resp }), 500);
break;
}
}
}
}
}
function serialize(x) {
return LZString.compressToBase64(JSON.stringify(x));
}
function parse(s) {
return JSON.parse(LZString.decompressFromBase64(s));
}
async function processCommand(body) {
const { corrId, command } = body;
const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection;
let resp;
try {
switch (command.type) {
case "capabilities":
console.log("starting outgoing call - capabilities");
if (activeCall)
endCall();
// This request for local media stream is made to prompt for camera/mic permissions on call start
if (command.media)
await getLocalMediaStream(command.media, VideoCamera.User);
const encryption = supportsInsertableStreams(command.useWorker);
resp = { type: "capabilities", capabilities: { encryption } };
break;
case "start": {
console.log("starting incoming call - create webrtc session");
if (activeCall)
endCall();
const { media, useWorker, iceServers, relay } = command;
const encryption = supportsInsertableStreams(useWorker);
const aesKey = encryption ? command.aesKey : undefined;
activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker);
const pc = activeCall.connection;
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// for debugging, returning the command for callee to use
// resp = {
// type: "offer",
// offer: serialize(offer),
// iceCandidates: await activeCall.iceCandidates,
// capabilities: {encryption},
// media,
// iceServers,
// relay,
// aesKey,
// useWorker,
// }
resp = {
type: "offer",
offer: serialize(offer),
iceCandidates: await activeCall.iceCandidates,
capabilities: { encryption },
};
break;
}
case "offer":
if (activeCall) {
resp = { type: "error", message: "accept: call already started" };
}
else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) {
resp = { type: "error", message: "accept: encryption is not supported" };
}
else {
const offer = parse(command.offer);
const remoteIceCandidates = parse(command.iceCandidates);
const { media, aesKey, useWorker, iceServers, relay } = command;
activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker);
const pc = activeCall.connection;
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
addIceCandidates(pc, remoteIceCandidates);
// same as command for caller to use
resp = {
type: "answer",
answer: serialize(answer),
iceCandidates: await activeCall.iceCandidates,
};
}
break;
case "answer":
if (!pc) {
resp = { type: "error", message: "answer: call not started" };
}
else if (!pc.localDescription) {
resp = { type: "error", message: "answer: local description is not set" };
}
else if (pc.currentRemoteDescription) {
resp = { type: "error", message: "answer: remote description already set" };
}
else {
const answer = parse(command.answer);
const remoteIceCandidates = parse(command.iceCandidates);
await pc.setRemoteDescription(new RTCSessionDescription(answer));
addIceCandidates(pc, remoteIceCandidates);
resp = { type: "ok" };
}
break;
case "ice":
if (pc) {
const remoteIceCandidates = parse(command.iceCandidates);
addIceCandidates(pc, remoteIceCandidates);
resp = { type: "ok" };
}
else {
resp = { type: "error", message: "ice: call not started" };
}
break;
case "media":
if (!activeCall) {
resp = { type: "error", message: "media: call not started" };
}
else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) {
resp = { type: "error", message: "media: no video" };
}
else {
enableMedia(activeCall.localStream, command.media, command.enable);
resp = { type: "ok" };
}
break;
case "camera":
if (!activeCall || !pc) {
resp = { type: "error", message: "camera: call not started" };
}
else {
await replaceMedia(activeCall, command.camera);
resp = { type: "ok" };
}
break;
case "end":
endCall();
resp = { type: "ok" };
break;
default:
resp = { type: "error", message: "unknown command" };
break;
}
}
catch (e) {
resp = { type: "error", message: `${command.type}: ${e.message}` };
}
const apiResp = { corrId, resp, command };
sendMessageToNative(apiResp);
return apiResp;
}
function endCall() {
var _a;
try {
(_a = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection) === null || _a === void 0 ? void 0 : _a.close();
}
catch (e) {
console.log(e);
}
activeCall = undefined;
resetVideoElements();
}
function addIceCandidates(conn, iceCandidates) {
for (const c of iceCandidates) {
conn.addIceCandidate(new RTCIceCandidate(c));
}
}
async function setupMediaStreams(call) {
const videos = getVideoElements();
if (!videos)
throw Error("no video elements");
await setupEncryptionWorker(call);
setupLocalStream(call);
setupRemoteStream(call);
setupCodecPreferences(call);
// setupVideoElement(videos.local)
// setupVideoElement(videos.remote)
videos.local.srcObject = call.localStream;
videos.remote.srcObject = call.remoteStream;
}
async function setupEncryptionWorker(call) {
if (call.aesKey) {
if (!call.key)
call.key = await callCrypto.decodeAesKey(call.aesKey);
if (call.useWorker && !call.worker) {
const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`;
call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" })));
call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message }));
call.worker.onmessage = ({ data }) => console.log(JSON.stringify({ message: data }));
}
}
}
function setupLocalStream(call) {
const videos = getVideoElements();
if (!videos)
throw Error("no video elements");
const pc = call.connection;
let { localStream } = call;
for (const track of localStream.getTracks()) {
pc.addTrack(track, localStream);
}
if (call.aesKey && call.key) {
console.log("set up encryption for sending");
for (const sender of pc.getSenders()) {
setupPeerTransform(TransformOperation.Encrypt, sender, call.worker, call.aesKey, call.key);
}
}
}
function setupRemoteStream(call) {
// Pull tracks from remote stream as they arrive add them to remoteStream video
const pc = call.connection;
pc.ontrack = (event) => {
try {
if (call.aesKey && call.key) {
console.log("set up decryption for receiving");
setupPeerTransform(TransformOperation.Decrypt, event.receiver, call.worker, call.aesKey, call.key);
}
for (const stream of event.streams) {
for (const track of stream.getTracks()) {
call.remoteStream.addTrack(track);
}
}
console.log(`ontrack success`);
}
catch (e) {
console.log(`ontrack error: ${e.message}`);
}
};
}
function setupCodecPreferences(call) {
// We assume VP8 encoding in the decode/encode stages to get the initial
// bytes to pass as plaintext so we enforce that here.
// VP8 is supported by all supports of webrtc.
// Use of VP8 by default may also reduce depacketisation issues.
// We do not encrypt the first couple of bytes of the payload so that the
// video elements can work by determining video keyframes and the opus mode
// being used. This appears to be necessary for any video feed at all.
// For VP8 this is the content described in
// https://tools.ietf.org/html/rfc6386#section-9.1
// which is 10 bytes for key frames and 3 bytes for delta frames.
// For opus (where encodedFrame.type is not set) this is the TOC byte from
// https://tools.ietf.org/html/rfc6716#section-3.1
var _a;
const capabilities = RTCRtpSender.getCapabilities("video");
if (capabilities) {
const { codecs } = capabilities;
const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8");
const selectedCodec = codecs[selectedCodecIndex];
codecs.splice(selectedCodecIndex, 1);
codecs.unshift(selectedCodec);
for (const t of call.connection.getTransceivers()) {
if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video") {
t.setCodecPreferences(codecs);
}
}
}
}
async function replaceMedia(call, camera) {
const videos = getVideoElements();
if (!videos)
throw Error("no video elements");
const pc = call.connection;
for (const t of call.localStream.getTracks())
t.stop();
call.localCamera = camera;
const localStream = await getLocalMediaStream(call.localMedia, camera);
replaceTracks(pc, localStream.getVideoTracks());
replaceTracks(pc, localStream.getAudioTracks());
call.localStream = localStream;
videos.local.srcObject = localStream;
}
function replaceTracks(pc, tracks) {
if (!tracks.length)
return;
const sender = pc.getSenders().find((s) => { var _a; return ((_a = s.track) === null || _a === void 0 ? void 0 : _a.kind) === tracks[0].kind; });
if (sender)
for (const t of tracks)
sender.replaceTrack(t);
}
function setupPeerTransform(operation, peer, worker, aesKey, key) {
if (worker && "RTCRtpScriptTransform" in window) {
console.log(`${operation} with worker & RTCRtpScriptTransform`);
peer.transform = new RTCRtpScriptTransform(worker, { operation, aesKey });
}
else if ("createEncodedStreams" in peer) {
const { readable, writable } = peer.createEncodedStreams();
if (worker) {
console.log(`${operation} with worker`);
worker.postMessage({ operation, readable, writable, aesKey }, [readable, writable]);
}
else {
console.log(`${operation} without worker`);
const transform = callCrypto.transformFrame[operation](key);
readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable);
}
}
else {
console.log(`no ${operation}`);
}
}
function getLocalMediaStream(mediaType, facingMode) {
const constraints = callMediaConstraints(mediaType, facingMode);
return navigator.mediaDevices.getUserMedia(constraints);
}
function callMediaConstraints(mediaType, facingMode) {
switch (mediaType) {
case CallMediaType.Audio:
return { audio: true, video: false };
case CallMediaType.Video:
return {
audio: true,
video: {
frameRate: 24,
width: {
min: 480,
ideal: 720,
max: 1280,
},
aspectRatio: 1.33,
facingMode,
},
};
}
}
function supportsInsertableStreams(useWorker) {
return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) ||
(!!useWorker && "RTCRtpScriptTransform" in window));
}
function resetVideoElements() {
const videos = getVideoElements();
if (!videos)
return;
videos.local.srcObject = null;
videos.remote.srcObject = null;
}
function getVideoElements() {
const local = document.getElementById("local-video-stream");
const remote = document.getElementById("remote-video-stream");
if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement))
return;
return { local, remote };
}
// function setupVideoElement(video: HTMLElement) {
// // TODO use display: none
// video.style.opacity = "0"
// video.onplaying = () => {
// video.style.opacity = "1"
// }
// }
function enableMedia(s, media, enable) {
const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks();
for (const t of tracks)
t.enabled = enable;
}
return processCommand;
})();
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
function callCryptoFunction() {
const initialPlainTextRequired = {
key: 10,
delta: 3,
};
const IV_LENGTH = 12;
function encryptFrame(key) {
return async (frame, controller) => {
const data = new Uint8Array(frame.data);
const n = initialPlainTextRequired[frame.type] || 1;
const iv = randomIV();
const initial = data.subarray(0, n);
const plaintext = data.subarray(n, data.byteLength);
try {
const ciphertext = new Uint8Array(plaintext.length ? await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext) : 0);
frame.data = concatN(initial, ciphertext, iv).buffer;
controller.enqueue(frame);
}
catch (e) {
console.log(`encryption error ${e}`);
throw e;
}
};
}
function decryptFrame(key) {
return async (frame, controller) => {
const data = new Uint8Array(frame.data);
const n = initialPlainTextRequired[frame.type] || 1;
const initial = data.subarray(0, n);
const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH);
const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength);
try {
const plaintext = new Uint8Array(ciphertext.length ? await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext) : 0);
frame.data = concatN(initial, plaintext).buffer;
controller.enqueue(frame);
}
catch (e) {
console.log(`decryption error ${e}`);
throw e;
}
};
}
function decodeAesKey(aesKey) {
const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(aesKey));
return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
}
function concatN(...bs) {
const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0));
bs.reduce((offset, b) => {
a.set(b, offset);
return offset + b.byteLength;
}, 0);
return a;
}
function randomIV() {
return crypto.getRandomValues(new Uint8Array(IV_LENGTH));
}
const base64urlChars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("").map((c) => c.charCodeAt(0)));
const base64urlLookup = new Array(256);
base64urlChars.forEach((c, i) => (base64urlLookup[c] = i));
const char_equal = "=".charCodeAt(0);
function encodeAscii(s) {
const a = new Uint8Array(s.length);
let i = s.length;
while (i--)
a[i] = s.charCodeAt(i);
return a;
}
function decodeAscii(a) {
let s = "";
for (let i = 0; i < a.length; i++)
s += String.fromCharCode(a[i]);
return s;
}
function encodeBase64url(a) {
const len = a.length;
const b64len = Math.ceil(len / 3) * 4;
const b64 = new Uint8Array(b64len);
let j = 0;
for (let i = 0; i < len; i += 3) {
b64[j++] = base64urlChars[a[i] >> 2];
b64[j++] = base64urlChars[((a[i] & 3) << 4) | (a[i + 1] >> 4)];
b64[j++] = base64urlChars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)];
b64[j++] = base64urlChars[a[i + 2] & 63];
}
if (len % 3)
b64[b64len - 1] = char_equal;
if (len % 3 === 1)
b64[b64len - 2] = char_equal;
return b64;
}
function decodeBase64url(b64) {
let len = b64.length;
if (len % 4)
return;
let bLen = (len * 3) / 4;
if (b64[len - 1] === char_equal) {
len--;
bLen--;
if (b64[len - 1] === char_equal) {
len--;
bLen--;
}
}
const bytes = new Uint8Array(bLen);
let i = 0;
let pos = 0;
while (i < len) {
const enc1 = base64urlLookup[b64[i++]];
const enc2 = i < len ? base64urlLookup[b64[i++]] : 0;
const enc3 = i < len ? base64urlLookup[b64[i++]] : 0;
const enc4 = i < len ? base64urlLookup[b64[i++]] : 0;
if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined)
return;
bytes[pos++] = (enc1 << 2) | (enc2 >> 4);
bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2);
bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63);
}
return bytes;
}
return {
transformFrame: { encrypt: encryptFrame, decrypt: decryptFrame },
decodeAesKey,
encodeAscii,
decodeAscii,
encodeBase64url,
decodeBase64url,
};
}
// If the worker is used for decryption, this function code (as string) is used to load the worker via Blob
// We have to use worker optionally, as it crashes in Android web view, regardless of how it is loaded
function workerFunction() {
// encryption with createEncodedStreams support
self.addEventListener("message", async ({ data }) => {
await setupTransform(data);
});
// encryption using RTCRtpScriptTransform.
if ("RTCTransformEvent" in self) {
self.addEventListener("rtctransform", async ({ transformer }) => {
try {
const { operation, aesKey } = transformer.options;
const { readable, writable } = transformer;
await setupTransform({ operation, aesKey, readable, writable });
self.postMessage({ result: "setupTransform success" });
}
catch (e) {
self.postMessage({ message: `setupTransform error: ${e.message}` });
}
});
}
async function setupTransform({ operation, aesKey, readable, writable }) {
const key = await callCrypto.decodeAesKey(aesKey);
const transform = callCrypto.transformFrame[operation](key);
readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable);
}
}
//# sourceMappingURL=call.js.map

View File

@@ -0,0 +1 @@
var LZString=function(){function o(o,r){if(!t[o]){t[o]={};for(var n=0;n<o.length;n++)t[o][o.charAt(n)]=n}return t[o][r]}var r=String.fromCharCode,n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$",t={},i={compressToBase64:function(o){if(null==o)return"";var r=i._compress(o,6,function(o){return n.charAt(o)});switch(r.length%4){default:case 0:return r;case 1:return r+"===";case 2:return r+"==";case 3:return r+"="}},decompressFromBase64:function(r){return null==r?"":""==r?null:i._decompress(r.length,32,function(e){return o(n,r.charAt(e))})},compressToUTF16:function(o){return null==o?"":i._compress(o,15,function(o){return r(o+32)})+" "},decompressFromUTF16:function(o){return null==o?"":""==o?null:i._decompress(o.length,16384,function(r){return o.charCodeAt(r)-32})},compressToUint8Array:function(o){for(var r=i.compress(o),n=new Uint8Array(2*r.length),e=0,t=r.length;t>e;e++){var s=r.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null===o||void 0===o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;t>e;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(o){return null==o?"":i._compress(o,6,function(o){return e.charAt(o)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(n){return o(e,r.charAt(n))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(o,r,n){if(null==o)return"";var e,t,i,s={},p={},u="",c="",a="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;i<o.length;i+=1)if(u=o.charAt(i),Object.prototype.hasOwnProperty.call(s,u)||(s[u]=f++,p[u]=!0),c=a+u,Object.prototype.hasOwnProperty.call(s,c))a=c;else{if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++),s[c]=f++,a=String(u)}if(""!==a){if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==r-1){d.push(n(m));break}v++}return d.join("")},decompress:function(o){return null==o?"":""==o?null:i._decompress(o.length,32768,function(r){return o.charCodeAt(r)})},_decompress:function(o,n,e){var t,i,s,p,u,c,a,l,f=[],h=4,d=4,m=3,v="",w=[],A={val:e(0),position:n,index:1};for(i=0;3>i;i+=1)f[i]=i;for(p=0,c=Math.pow(2,2),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(t=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 2:return""}for(f[3]=l,s=l,w.push(l);;){if(A.index>o)return"";for(p=0,c=Math.pow(2,m),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(l=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 2:return w.join("")}if(0==h&&(h=Math.pow(2,m),m++),f[l])v=f[l];else{if(l!==d)return null;v=s+s.charAt(0)}w.push(v),f[d++]=s+v.charAt(0),h--,s=v,0==h&&(h=Math.pow(2,m),m++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module&&(module.exports=LZString);

View File

@@ -0,0 +1,41 @@
html,
body {
padding: 0;
margin: 0;
background-color: black;
}
#remote-video-stream {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
#local-video-stream {
position: absolute;
width: 30%;
max-width: 30%;
object-fit: cover;
margin: 16px;
border-radius: 16px;
top: 0;
right: 0;
}
*::-webkit-media-controls {
display: none !important;
-webkit-appearance: none !important;
}
*::-webkit-media-controls-panel {
display: none !important;
-webkit-appearance: none !important;
}
*::-webkit-media-controls-play-button {
display: none !important;
-webkit-appearance: none !important;
}
*::-webkit-media-controls-start-playback-button {
display: none !important;
-webkit-appearance: none !important;
}

View File

@@ -0,0 +1,71 @@
# 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)
add_library( crypto SHARED IMPORTED )
set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libcrypto.so)
# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.
target_link_libraries( # Specifies the target library.
app-lib
simplex support crypto
# Links the target library to the log library
# included in the NDK.
${log-lib})

View File

@@ -0,0 +1,87 @@
#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 char *chat_migrate_db(const char *path, const char *key);
extern chat_ctrl chat_init_key(const char *path, const char *key);
extern chat_ctrl chat_init(const char *path); // deprecated
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatMigrateDB(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_migrate_db(_dbPath, _dbKey));
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
return res;
}
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_SimplexAppKt_chatInitKey(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
jlong ctrl = (jlong)chat_init_key(_dbPath, _dbKey);
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
return ctrl;
}
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring dbPath) {
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
jlong ctrl = (jlong)chat_init(_dbPath);
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
return ctrl;
}
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));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) {
return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,417 @@
package chat.simplex.app
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.SystemClock.elapsedRealtime
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Replay
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.SplashView
import chat.simplex.app.views.call.ActiveCallView
import chat.simplex.app.views.call.IncomingCallAlertView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.database.DatabaseErrorView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.connectViaUri
import chat.simplex.app.views.newchat.withUriAction
import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay
class MainActivity: FragmentActivity() {
companion object {
/**
* We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user
* clicks on new message in notification. In this case savedInstanceState will be null (this prevents restoring the values)
* See [SimplexService.onTaskRemoved] for another part of the logic which nullifies the values when app closed by the user
* */
val userAuthorized = mutableStateOf<Boolean?>(null)
val enteredBackground = mutableStateOf<Long?>(null)
// Remember result and show it after orientation change
private val laFailed = mutableStateOf(false)
fun clearAuthState() {
userAuthorized.value = null
enteredBackground.value = null
}
}
private val vm by viewModels<SimplexViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// testJson()
val m = vm.chatModel
// When call ended and orientation changes, it re-process old intent, it's unneeded.
// Only needed to be processed on first creation of activity
if (savedInstanceState == null) {
processNotificationIntent(intent, m)
}
setContent {
SimpleXTheme {
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
MainPage(
m,
userAuthorized,
laFailed,
::runAuthenticate,
::setPerformLA,
showLANotice = { m.controller.showLANotice(this) }
)
}
}
}
SimplexApp.context.schedulePeriodicServiceRestartWorker()
SimplexApp.context.schedulePeriodicWakeUp()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
processIntent(intent, vm.chatModel)
}
override fun onStart() {
super.onStart()
val enteredBackgroundVal = enteredBackground.value
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
runAuthenticate()
}
}
override fun onStop() {
super.onStop()
enteredBackground.value = elapsedRealtime()
}
override fun onBackPressed() {
super.onBackPressed()
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
clearAuthState()
laFailed.value = true
}
}
private fun runAuthenticate() {
val m = vm.chatModel
if (!m.controller.appPrefs.performLA.get()) {
userAuthorized.value = true
} else {
userAuthorized.value = false
ModalManager.shared.closeModals()
authenticate(
generalGetString(R.string.auth_unlock),
generalGetString(R.string.auth_log_in_using_credential),
this@MainActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success -> {
userAuthorized.value = true
}
is LAResult.Error -> {
laFailed.value = true
laErrorToast(applicationContext, laResult.errString)
}
LAResult.Failed -> {
laFailed.value = true
laFailedToast(applicationContext)
}
LAResult.Unavailable -> {
userAuthorized.value = true
m.performLA.value = false
m.controller.appPrefs.performLA.set(false)
laUnavailableTurningOffAlert()
}
}
}
)
}
}
private fun setPerformLA(on: Boolean) {
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
if (on) {
enableLA()
} else {
disableLA()
}
}
private fun enableLA() {
val m = vm.chatModel
authenticate(
generalGetString(R.string.auth_enable_simplex_lock),
generalGetString(R.string.auth_confirm_credential),
this@MainActivity,
completed = { laResult ->
val prefPerformLA = m.controller.appPrefs.performLA
when (laResult) {
LAResult.Success -> {
m.performLA.value = true
prefPerformLA.set(true)
laTurnedOnAlert()
}
is LAResult.Error -> {
m.performLA.value = false
prefPerformLA.set(false)
laErrorToast(applicationContext, laResult.errString)
}
LAResult.Failed -> {
m.performLA.value = false
prefPerformLA.set(false)
laFailedToast(applicationContext)
}
LAResult.Unavailable -> {
m.performLA.value = false
prefPerformLA.set(false)
laUnavailableInstructionAlert()
}
}
}
)
}
private fun disableLA() {
val m = vm.chatModel
authenticate(
generalGetString(R.string.auth_disable_simplex_lock),
generalGetString(R.string.auth_confirm_credential),
this@MainActivity,
completed = { laResult ->
val prefPerformLA = m.controller.appPrefs.performLA
when (laResult) {
LAResult.Success -> {
m.performLA.value = false
prefPerformLA.set(false)
}
is LAResult.Error -> {
m.performLA.value = true
prefPerformLA.set(true)
laErrorToast(applicationContext, laResult.errString)
}
LAResult.Failed -> {
m.performLA.value = true
prefPerformLA.set(true)
laFailedToast(applicationContext)
}
LAResult.Unavailable -> {
m.performLA.value = false
prefPerformLA.set(false)
laUnavailableTurningOffAlert()
}
}
}
)
}
}
class SimplexViewModel(application: Application): AndroidViewModel(application) {
val app = getApplication<SimplexApp>()
val chatModel = app.chatModel
}
@Composable
fun MainPage(
chatModel: ChatModel,
userAuthorized: MutableState<Boolean?>,
laFailed: MutableState<Boolean>,
runAuthenticate: () -> Unit,
setPerformLA: (Boolean) -> Unit,
showLANotice: () -> Unit
) {
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(userAuthorized.value) {
if (chatModel.controller.appPrefs.performLA.get()) {
delay(500L)
}
chatsAccessAuthorized = userAuthorized.value == true
}
var showChatDatabaseError by rememberSaveable {
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
}
LaunchedEffect(chatModel.chatDbStatus.value) {
showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null
}
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
LaunchedEffect(showAdvertiseLAAlert) {
if (
!chatModel.controller.appPrefs.laNoticeShown.get()
&& showAdvertiseLAAlert
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
&& chatModel.chats.isNotEmpty()
&& chatModel.activeCallInvitation.value == null
) {
showLANotice()
}
}
LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) {
if (chatModel.showAdvertiseLAUnavailableAlert.value) {
laUnavailableInstructionAlert()
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value) {
ModalManager.shared.closeModals()
chatModel.clearOverlays.value = false
}
}
@Composable
fun retryAuthView() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(R.string.auth_retry),
icon = Icons.Outlined.Replay,
click = {
laFailed.value = false
runAuthenticate()
}
)
}
}
Box {
val onboarding = chatModel.onboardingStage.value
val userCreated = chatModel.userCreated.value
when {
showChatDatabaseError -> {
chatModel.chatDbStatus.value?.let {
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
}
}
onboarding == null || userCreated == null -> SplashView()
!chatsAccessAuthorized -> {
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
retryAuthView()
} else {
SplashView()
}
}
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
Box {
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else {
showAdvertiseLAAlert = true
val stopped = chatModel.chatRunning.value == false
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA, stopped)
else ChatView(chatModel)
}
}
}
onboarding == OnboardingStage.Step1_SimpleXInfo ->
Box(Modifier.padding(horizontal = 20.dp)) {
SimpleXInfo(chatModel, onboarding = true)
}
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
}
ModalManager.shared.showInView()
val invitation = chatModel.activeCallInvitation.value
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
AlertManager.shared.showInView()
}
}
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
when (intent?.action) {
NtfManager.OpenChatAction -> {
val chatId = intent.getStringExtra("chatId")
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
if (chatId != null) {
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
if (cInfo != null) withApi { openChat(cInfo, chatModel) }
}
}
NtfManager.ShowChatsAction -> {
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
chatModel.chatId.value = null
chatModel.clearOverlays.value = true
}
NtfManager.AcceptCallAction -> {
val chatId = intent.getStringExtra("chatId")
if (chatId == null || chatId == "") return
Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
chatModel.clearOverlays.value = true
val invitation = chatModel.callInvitations[chatId]
if (invitation == null) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended))
} else {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
}
}
}
fun processIntent(intent: Intent?, chatModel: ChatModel) {
when (intent?.action) {
"android.intent.action.VIEW" -> {
val uri = intent.data
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
}
}
}
fun 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 ->
val title = when (action) {
"contact" -> generalGetString(R.string.connect_via_contact_link)
"invitation" -> generalGetString(R.string.connect_via_invitation_link)
else -> {
Log.e(TAG, "URI has unexpected action. Alert shown.")
action
}
}
AlertManager.shared.showAlertMsg(
title = title,
text = generalGetString(R.string.profile_will_be_sent_to_contact_sending_link),
confirmText = generalGetString(R.string.connect_via_link_verb),
onConfirm = {
withApi {
Log.d(TAG, "connectIfOpenedViaUri: connecting")
connectViaUri(chatModel, action, uri)
}
}
)
}
}
}
//fun testJson() {
// val str: String = """
// """.trimIndent()
//
// println(json.decodeFromString<APIResponse>(str))
//}

View File

@@ -0,0 +1,195 @@
package chat.simplex.app
import android.app.Application
import android.net.LocalServerSocket
import android.util.Log
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
const val TAG = "SIMPLEX"
// ghc's rts
external fun initHS()
// android-support
external fun pipeStdOutToSocket(socketName: String) : Int
// SimpleX API
typealias ChatCtrl = Long
external fun chatMigrateDB(dbPath: String, dbKey: String): String
external fun chatInitKey(dbPath: String, dbKey: String): ChatCtrl
external fun chatInit(dbPath: String): ChatCtrl
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
external fun chatRecvMsg(ctrl: ChatCtrl): String
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
class SimplexApp: Application(), LifecycleEventObserver {
lateinit var chatController: ChatController
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
val dbKey = useKey ?: DatabaseUtils.getDatabaseKey() ?: ""
val res = DatabaseUtils.migrateChatDatabase(dbKey)
val ctrl = if (res.second is DBMigrationResult.OK) {
chatInitKey(getFilesDirectory(applicationContext), dbKey)
} else null
if (::chatController.isInitialized) {
chatController.ctrl = ctrl
} else {
chatController = ChatController(ctrl, ntfManager, applicationContext, appPreferences)
}
chatModel.chatDbEncrypted.value = res.first
chatModel.chatDbStatus.value = res.second
if (res.second != DBMigrationResult.OK) {
Log.d(TAG, "Unable to migrate successfully: ${res.second}")
} else if (startChat) {
// If we migrated successfully means previous re-encryption process on database level finished successfully too
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
withApi {
val user = chatController.apiGetActiveUser()
if (user == null) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
chatController.startChat(user)
chatController.showBackgroundServiceNoticeIfNeeded()
if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
SimplexService.start(applicationContext)
}
}
}
}
val chatModel: ChatModel
get() = chatController.chatModel
private val ntfManager: NtfManager by lazy {
NtfManager(applicationContext, appPreferences)
}
private val appPreferences: AppPreferences by lazy {
AppPreferences(applicationContext)
}
override fun onCreate() {
super.onCreate()
context = this
initChatController()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Log.d(TAG, "onStateChanged: $event")
withApi {
when (event) {
Lifecycle.Event.ON_RESUME -> {
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
chatController.showBackgroundServiceNoticeIfNeeded()
}
/**
* We're starting service here instead of in [Lifecycle.Event.ON_START] because
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
* It can happen when app was started and a user enables battery optimization while app in background
* */
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
SimplexService.start(applicationContext)
}
else -> {}
}
}
}
fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name &&
(!NotificationsMode.SERVICE.requiresIgnoringBattery || isIgnoringBatteryOptimizations(chatModel.controller.appContext))
}
private fun allowToStartPeriodically() = with(chatModel.controller) {
appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC.name &&
(!NotificationsMode.PERIODIC.requiresIgnoringBattery || isIgnoringBatteryOptimizations(chatModel.controller.appContext))
}
/*
* It takes 1-10 milliseconds to process this function. Better to do it in a background thread
* */
fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch {
if (!allowToStartServiceAfterAppExit()) {
return@launch
}
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
ExistingPeriodicWorkPolicy.KEEP
} else {
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
ExistingPeriodicWorkPolicy.REPLACE
}
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
.addTag(SimplexService.TAG)
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
.build()
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
}
fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch {
if (!allowToStartPeriodically()) {
return@launch
}
MessagesFetcherWorker.scheduleWork()
}
companion object {
lateinit var context: SimplexApp private set
init {
val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2"
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d(TAG, "starting server")
val server = LocalServerSocket(socketName)
Log.d(TAG, "started server")
s.release()
val receiver = server.accept()
Log.d(TAG, "started receiver")
val logbuffer = FifoQueue<String>(500)
if (receiver != null) {
val inStream = receiver.inputStream
val inStreamReader = InputStreamReader(inStream)
val input = BufferedReader(inStreamReader)
Log.d(TAG, "starting receiver loop")
while (true) {
val line = input.readLine() ?: break
Log.w("$TAG (stdout/stderr)", line)
logbuffer.add(line)
}
Log.w(TAG, "exited receiver loop")
}
}
System.loadLibrary("app-lib")
s.acquire()
pipeStdOutToSocket(socketName)
initHS()
}
}
}
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
override fun add(element: E): Boolean {
if(size > capacity) removeFirst()
return super.add(element)
}
}

View File

@@ -0,0 +1,314 @@
package chat.simplex.app
import android.app.*
import android.content.*
import android.content.pm.PackageManager
import android.os.*
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
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
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand startId: $startId")
if (intent != null) {
val action = intent.action
Log.d(TAG, "intent action $action")
when (action) {
Action.START.name -> startService()
else -> Log.e(TAG, "No action in the intent")
}
} else {
Log.d(TAG, "null intent. Probably restarted by the system.")
}
return START_STICKY // to restart if killed
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Simplex service created")
val title = getString(R.string.simplex_service_notification_title)
val text = getString(R.string.simplex_service_notification_text)
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
}
override fun onDestroy() {
Log.d(TAG, "Simplex service destroyed")
stopService()
// If notification service is enabled and battery optimization is disabled, restart the service
if (SimplexApp.context.allowToStartServiceAfterAppExit())
sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
super.onDestroy()
}
private fun startService() {
Log.d(TAG, "SimplexService startService")
if (isServiceStarted || isStartingService) return
val self = this
isStartingService = true
withApi {
val chatController = (application as SimplexApp).chatController
try {
Log.w(TAG, "Starting foreground service")
val chatDbStatus = chatController.chatModel.chatDbStatus.value
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
showPassphraseNotification(chatDbStatus)
stopService()
return@withApi
}
isServiceStarted = true
saveServiceState(self, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
acquire()
}
}
} finally {
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)
}
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_service_icon)
.setColor(0x88FFFF)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setSilent(true)
.setShowWhen(false) // no date/time
// Shows a button which opens notification channel settings
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val setupIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
setupIntent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
setupIntent.putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL_ID)
val setup = PendingIntent.getActivity(this, 0, setupIntent, flags)
builder.addAction(0, getString(R.string.hide_notification), setup)
}
return builder.build()
}
override fun onBind(intent: Intent): IBinder? {
return null // no binding
}
// re-schedules the task when "Clear recent apps" is pressed
override fun onTaskRemoved(rootIntent: Intent) {
// Just to make sure that after restart of the app the user will need to re-authenticate
MainActivity.clearAuthState()
// If notification service isn't enabled or battery optimization isn't disabled, we shouldn't restart the service
if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
return
}
val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
it.setPackage(packageName)
};
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE);
applicationContext.getSystemService(Context.ALARM_SERVICE);
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
}
// restart on reboot
class StartReceiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "StartReceiver: onReceive called")
scheduleStart(context)
}
companion object {
fun toggleReceiver(enable: Boolean) {
Log.d(TAG, "StartReceiver: toggleReceiver enabled: $enable")
val component = ComponentName(BuildConfig.APPLICATION_ID, StartReceiver::class.java.name)
SimplexApp.context.packageManager.setComponentEnabledSetting(
component,
if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
}
}
}
// restart on destruction
class AutoRestartReceiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "AutoRestartReceiver: onReceive called")
scheduleStart(context)
}
}
class ServiceStartWorker(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val id = this.id
if (context.applicationContext !is Application) {
Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: $id)")
return Result.failure()
}
if (getServiceState(context) == ServiceState.STARTED) {
Log.d(TAG, "ServiceStartWorker: Starting foreground service (work ID: $id)")
start(context)
}
return Result.success()
}
}
enum class Action {
START,
}
enum class ServiceState {
STARTED,
STOPPED,
}
companion object {
const val TAG = "SIMPLEX_SERVICE"
const val NOTIFICATION_CHANNEL_ID = "chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION"
const val NOTIFICATION_CHANNEL_NAME = "SimpleX Chat service"
const val SIMPLEX_SERVICE_ID = 6789
const val SERVICE_START_WORKER_VERSION = BuildConfig.VERSION_CODE
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change!
private const val PASSPHRASE_NOTIFICATION_ID = 1535
private const val WAKE_LOCK_TAG = "SimplexService::lock"
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS"
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
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)
fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java))
private suspend fun serviceAction(context: Context, action: Action) {
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
withContext(Dispatchers.IO) {
Intent(context, SimplexService::class.java).also {
it.action = action.name
ContextCompat.startForegroundService(context, it)
}
}
}
fun restart(context: Context) {
Intent(context, SimplexService::class.java).also { intent ->
context.stopService(intent) // Service will auto-restart
}
}
fun saveServiceState(context: Context, state: ServiceState) {
getPreferences(context).edit()
.putString(SHARED_PREFS_SERVICE_STATE, state.name)
.apply()
}
fun getServiceState(context: Context): ServiceState {
val value = getPreferences(context)
.getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name)
return ServiceState.valueOf(value!!)
}
fun showPassphraseNotification(chatDbStatus: DBMigrationResult?) {
val pendingIntent: PendingIntent = Intent(SimplexApp.context, MainActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(SimplexApp.context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
}
val title = when(chatDbStatus) {
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_title)
is DBMigrationResult.OK -> return
else -> generalGetString(R.string.database_initialization_error_title)
}
val description = when(chatDbStatus) {
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_desc)
is DBMigrationResult.OK -> return
else -> generalGetString(R.string.database_initialization_error_desc)
}
val builder = NotificationCompat.Builder(SimplexApp.context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_service_icon)
.setColor(0x88FFFF)
.setContentTitle(title)
.setContentText(description)
.setContentIntent(pendingIntent)
.setSilent(true)
.setShowWhen(false)
val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(PASSPHRASE_NOTIFICATION_ID, builder.build())
}
fun cancelPassphraseNotification() {
val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(PASSPHRASE_NOTIFICATION_ID)
}
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,192 @@
package chat.simplex.app.model
import android.app.*
import android.content.*
import android.graphics.BitmapFactory
import android.media.AudioAttributes
import android.net.Uri
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.helpers.base64ToBitmap
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.views.usersettings.NotificationPreviewMode
import kotlinx.datetime.Clock
class NtfManager(val context: Context, private val appPreferences: AppPreferences) {
companion object {
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
// DO NOT change notification channel settings / names
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
const val CallNotificationId: Int = -1
}
private 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, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(callNotificationChannel())
}
private fun callNotificationChannel(): NotificationChannel {
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
val attrs = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.build()
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
Log.d(TAG,"callNotificationChannel sound: $soundUri")
callChannel.setSound(soundUri, attrs)
callChannel.enableVibration(true)
return callChannel
}
fun cancelNotificationsForChat(chatId: String) {
prevNtfTime.remove(chatId)
manager.cancel(chatId.hashCode())
val msgNtfs = manager.activeNotifications.filter {
ntf -> ntf.notification.channelId == MessageChannel
}
if (msgNtfs.count() == 1) {
// Have a group notification with no children so cancel it
manager.cancel(0)
}
}
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
if (!cInfo.ntfsEnabled) return
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
}
fun notifyMessageReceived(chatId: String, displayName: String, msgText: String) {
Log.d(TAG, "notifyMessageReceived $chatId")
val now = Clock.System.now().toEpochMilliseconds()
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
prevNtfTime[chatId] = now
val previewMode = appPreferences.notificationPreviewMode.get()
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(R.string.notification_preview_somebody) else displayName
val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(R.string.notification_preview_new_message) else msgText
val notification = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(title)
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
.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)
.setContentIntent(chatPendingIntent(ShowChatsAction))
.build()
with(NotificationManagerCompat.from(context)) {
// using cInfo.id only shows one notification per chat and updates it when the message arrives
notify(chatId.hashCode(), notification)
notify(0, summary)
}
}
fun notifyCallInvitation(invitation: RcvCallInvitation) {
if (isAppOnForeground(context)) return
val contactId = invitation.contact.id
Log.d(TAG, "notifyCallInvitation $contactId")
val keyguardManager = getKeyguardManager(context)
val image = invitation.contact.image
var ntfBuilder =
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, LockScreenCallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setSilent(true)
} else {
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
NotificationCompat.Builder(context, CallChannel)
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
.setSound(soundUri)
}
val text = generalGetString(
if (invitation.callType.media == CallMediaType.Video) {
if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
} else {
if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
}
)
val previewMode = appPreferences.notificationPreviewMode.get()
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
generalGetString(R.string.notification_preview_somebody)
else
invitation.contact.displayName
ntfBuilder = ntfBuilder
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setSmallIcon(R.drawable.ntf_icon)
.setLargeIcon(if (image == null) BitmapFactory.decodeResource(context.resources, R.drawable.icon) else base64ToBitmap(image))
.setColor(0x88FFFF)
.setAutoCancel(true)
with(NotificationManagerCompat.from(context)) {
notify(CallNotificationId, ntfBuilder.build())
}
}
fun cancelCallNotification() {
manager.cancel(CallNotificationId)
}
private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText
return if (md == null) {
if (cItem.content.text != "") {
cItem.content.text
} else {
cItem.file?.fileName ?: ""
}
} else {
var res = ""
for (ft in md) {
res += if (ft.format is Format.Secret) "..." else ft.text
}
res
}
}
private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
Log.d(TAG, "chatPendingIntent for $intentAction")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
var intent = Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.setAction(intentAction)
if (chatId != null) intent = intent.putExtra("chatId", chatId)
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
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 Indigo = Color(0xff330099)
val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files
val SimplexGreen = Color(77, 218, 103, 255)
val SecretColor = Color(0x40808080)
val LightGray = Color(241, 242, 246, 255)
val DarkGray = Color(43, 44, 46, 255)
val HighOrLowlight = Color(139, 135, 134, 255)
val MessagePreviewDark = Color(179, 175, 174, 255)
val MessagePreviewLight = Color(49, 45, 44, 255)
val ToolbarLight = Color(220, 220, 220, 12)
val ToolbarDark = Color(80, 80, 80, 12)
val SettingsBackgroundLight = Color(220, 216, 215, 90)
val SettingsSecondaryLight = Color(200, 196, 195, 90)
val GroupDark = Color(80, 80, 80, 60)
val IncomingCallLight = Color(239, 237, 236, 255)
val IncomingCallDark = Color(34, 30, 29, 255)
val WarningOrange = Color(255, 127, 0, 255)
val WarningYellow = Color(255, 192, 0, 255)
val FileLight = Color(183, 190, 199, 255)
val FileDark = Color(101, 101, 106, 255)

View File

@@ -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)
)

View File

@@ -0,0 +1,71 @@
package chat.simplex.app.ui.theme
import android.app.UiModeManager
import android.content.Context
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import chat.simplex.app.SimplexApp
import kotlinx.coroutines.flow.MutableStateFlow
enum class DefaultTheme {
SYSTEM, DARK, LIGHT
}
val DarkColorPalette = darkColors(
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
primaryVariant = SimplexGreen,
secondary = DarkGray,
// background = Color.Black,
// surface = Color.Black,
// background = Color(0xFF121212),
// surface = Color(0xFF121212),
// error = Color(0xFFCF6679),
onBackground = Color(0xFFFFFBFA),
onSurface = Color(0xFFFFFBFA),
// onError: Color = Color.Black,
)
val LightColorPalette = lightColors(
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
primaryVariant = SimplexGreen,
secondary = LightGray,
// background = Color.White,
// surface = Color.White
// onPrimary = Color.White,
// onSecondary = Color.Black,
// onBackground = Color.Black,
// onSurface = Color.Black,
)
val CurrentColors: MutableStateFlow<Pair<Colors, DefaultTheme>> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
// Non-@Composable implementation
private fun isInNightMode() =
(SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
@Composable
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.first.isLight
@Composable
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
LaunchedEffect(darkTheme) {
// For preview
if (darkTheme != null)
CurrentColors.value = ThemeManager.currentColors(darkTheme)
}
val systemDark = isSystemInDarkTheme()
LaunchedEffect(systemDark) {
if (CurrentColors.value.second == DefaultTheme.SYSTEM && CurrentColors.value.first.isLight == systemDark) {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
}
}
val theme by CurrentColors.collectAsState()
MaterialTheme(
colors = theme.first,
typography = Typography,
shapes = Shapes,
content = content
)
}

View File

@@ -0,0 +1,64 @@
package chat.simplex.app.ui.theme
import androidx.compose.material.Colors
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.views.helpers.generalGetString
object ThemeManager {
private val appPrefs: AppPreferences by lazy {
AppPreferences(SimplexApp.context)
}
fun currentColors(darkForSystemTheme: Boolean): Pair<Colors, DefaultTheme> {
val theme = appPrefs.currentTheme.get()!!
val systemThemeColors = if (darkForSystemTheme) DarkColorPalette else LightColorPalette
val res = when (theme) {
DefaultTheme.SYSTEM.name -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
DefaultTheme.DARK.name -> Pair(DarkColorPalette, DefaultTheme.DARK)
DefaultTheme.LIGHT.name -> Pair(LightColorPalette, DefaultTheme.LIGHT)
else -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
}
return res.copy(first = res.first.copy(primary = Color(appPrefs.primaryColor.get())))
}
// colors, default theme enum, localized name of theme
fun allThemes(darkForSystemTheme: Boolean): List<Triple<Colors, DefaultTheme, String>> {
val allThemes = ArrayList<Triple<Colors, DefaultTheme, String>>()
allThemes.add(
Triple(
if (darkForSystemTheme) DarkColorPalette else LightColorPalette,
DefaultTheme.SYSTEM,
generalGetString(R.string.theme_system)
)
)
allThemes.add(
Triple(
LightColorPalette,
DefaultTheme.LIGHT,
generalGetString(R.string.theme_light)
)
)
allThemes.add(
Triple(
DarkColorPalette,
DefaultTheme.DARK,
generalGetString(R.string.theme_dark)
)
)
return allThemes
}
fun applyTheme(name: String, darkForSystemTheme: Boolean) {
appPrefs.currentTheme.set(name)
CurrentColors.value = currentColors(darkForSystemTheme)
}
fun saveAndApplyPrimaryColor(color: Color) {
appPrefs.primaryColor.set(color.toArgb())
CurrentColors.value = currentColors(!CurrentColors.value.first.isLight)
}
}

View File

@@ -0,0 +1,56 @@
package chat.simplex.app.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
// https://github.com/rsms/inter
val Inter = FontFamily(
Font(R.font.inter_regular),
Font(R.font.inter_italic, style = FontStyle.Italic),
Font(R.font.inter_bold, weight = FontWeight.Bold),
Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold),
Font(R.font.inter_medium, weight = FontWeight.Medium),
Font(R.font.inter_light, weight = FontWeight.Light),
)
// Set of Material typography styles to start with
val Typography = Typography(
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 = 18.5.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
)
)

View File

@@ -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)
// )
}
}

View File

@@ -0,0 +1,182 @@
package chat.simplex.app.views
import android.content.Context
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
@Composable
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
BackHandler(onBack = close)
val authorized = remember { mutableStateOf(!chatModel.controller.appPrefs.performLA.get()) }
val context = LocalContext.current
LaunchedEffect(authorized.value) {
if (!authorized.value) {
runAuth(authorized = authorized, context)
}
}
if (authorized.value) {
TerminalLayout(
chatModel.terminalItems,
composeState,
sendCommand = { sendCommand(chatModel, composeState) },
close
)
} else {
Surface(Modifier.fillMaxSize()) {
Column(Modifier.background(MaterialTheme.colors.background)) {
CloseSheetBar(close)
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(R.string.auth_unlock),
icon = Icons.Outlined.Lock,
click = {
runAuth(authorized = authorized, context)
}
)
}
}
}
}
}
private fun runAuth(authorized: MutableState<Boolean>, context: Context) {
authenticate(
generalGetString(R.string.auth_open_chat_console),
generalGetString(R.string.auth_log_in_using_credential),
context as FragmentActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success, LAResult.Unavailable -> authorized.value = true
is LAResult.Error, LAResult.Failed -> authorized.value = false
}
}
)
}
private fun sendCommand(chatModel: ChatModel, composeState: MutableState<ComposeState>) {
val developerTools = chatModel.controller.appPrefs.developerTools.get()
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
val s = composeState.value.message
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
val resp = CR.ChatCmdError(ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
chatModel.terminalItems.add(TerminalItem.cmd(CC.Console(s)))
chatModel.terminalItems.add(TerminalItem.resp(resp))
composeState.value = ComposeState(useLinkPreviews = false)
} else {
withApi {
// show "in progress"
chatModel.controller.sendCmd(CC.Console(s))
composeState.value = ComposeState(useLinkPreviews = false)
// hide "in progress"
}
}
}
@Composable
fun TerminalLayout(
terminalItems: List<TerminalItem>,
composeState: MutableState<ComposeState>,
sendCommand: () -> Unit,
close: () -> Unit
) {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
fun onMessageChange(s: String) {
composeState.value = composeState.value.copy(message = s)
}
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { CloseSheetBar(close) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
}
},
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 reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
LazyColumn(state = listState, reverseLayout = true) {
items(reversedTerminalItems) { item ->
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable {
ModalManager.shared.showModal {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(item.details)
}
}
}
)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewTerminalLayout() {
SimpleXTheme {
TerminalLayout(
terminalItems = TerminalItem.sampleData,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
sendCommand = {},
close = {}
)
}
}

View File

@@ -0,0 +1,144 @@
package chat.simplex.app.views
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBackIosNew
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.SimplexService
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.onboarding.ReadableText
import com.google.accompanist.insets.navigationBarsWithImePadding
fun isValidDisplayName(name: String) : Boolean {
return (name.firstOrNull { it.isWhitespace() }) == null
}
@Composable
fun CreateProfilePanel(chatModel: ChatModel) {
val displayName = remember { mutableStateOf("") }
val fullName = remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
Column(
modifier = Modifier.fillMaxSize()
) {
Text(
stringResource(R.string.create_profile),
style = MaterialTheme.typography.h4,
modifier = Modifier.padding(vertical = 5.dp)
)
ReadableText(R.string.your_profile_is_stored_on_your_device)
ReadableText(R.string.profile_is_only_shared_with_your_contacts)
Spacer(Modifier.height(10.dp))
Text(
stringResource(R.string.display_name),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 3.dp)
)
ProfileNameField(displayName, focusRequester)
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
Text(
errorText,
fontSize = 15.sp,
color = MaterialTheme.colors.error
)
Spacer(Modifier.height(3.dp))
Text(
stringResource(R.string.full_name_optional__prompt),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 5.dp)
)
ProfileNameField(fullName)
Spacer(Modifier.fillMaxHeight().weight(1f))
Row {
SimpleButton(
text = stringResource(R.string.about_simplex),
icon = Icons.Outlined.ArrowBackIosNew
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
Spacer(Modifier.fillMaxWidth().weight(1f))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
val createModifier: Modifier
val createColor: Color
if (enabled) {
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value) }.padding(8.dp)
createColor = MaterialTheme.colors.primary
} else {
createModifier = Modifier.padding(8.dp)
createColor = HighOrLowlight
}
Surface(shape = RoundedCornerShape(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor)
Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor)
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName, null)
)
chatModel.controller.startChat(user)
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
SimplexService.start(chatModel.controller.appContext)
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
}
}
@Composable
fun ProfileNameField(name: MutableState<String>, focusRequester: FocusRequester? = null) {
val modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.secondary)
.height(40.dp)
.clip(RoundedCornerShape(5.dp))
.padding(8.dp)
.navigationBarsWithImePadding()
BasicTextField(
value = name.value,
onValueChange = { name.value = it },
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true,
cursorBrush = SolidColor(HighOrLowlight)
)
}

View File

@@ -0,0 +1,100 @@
package chat.simplex.app.views.call
import android.util.Log
import chat.simplex.app.TAG
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.withApi
import kotlinx.datetime.Clock
import kotlin.time.Duration.Companion.minutes
class CallManager(val chatModel: ChatModel) {
fun reportNewIncomingCall(invitation: RcvCallInvitation) {
Log.d(TAG, "CallManager.reportNewIncomingCall")
with (chatModel) {
callInvitations[invitation.contact.id] = invitation
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
activeCallInvitation.value = invitation
controller.ntfManager.notifyCallInvitation(invitation)
} else {
val contact = invitation.contact
controller.ntfManager.notifyMessageReceived(chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
}
}
}
fun acceptIncomingCall(invitation: RcvCallInvitation) {
ModalManager.shared.closeModals()
val call = chatModel.activeCall.value
if (call == null) {
justAcceptIncomingCall(invitation = invitation)
} else {
withApi {
chatModel.switchingCall.value = true
try {
endCall(call = call)
justAcceptIncomingCall(invitation = invitation)
} finally {
withApi { chatModel.switchingCall.value = false }
}
}
}
}
private fun justAcceptIncomingCall(invitation: RcvCallInvitation) {
with (chatModel) {
activeCall.value = Call(
contact = invitation.contact,
callState = CallState.InvitationAccepted,
localMedia = invitation.callType.media,
sharedKey = invitation.sharedKey
)
showCallView.value = true
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
callCommand.value = WCallCommand.Start (media = invitation.callType.media, aesKey = invitation.sharedKey, relay = useRelay)
callInvitations.remove(invitation.contact.id)
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
activeCallInvitation.value = null
controller.ntfManager.cancelCallNotification()
}
}
}
suspend fun endCall(call: Call) {
with (chatModel) {
if (call.callState == CallState.Ended) {
Log.d(TAG, "CallManager.endCall: call ended")
activeCall.value = null
showCallView.value = false
} else {
Log.d(TAG, "CallManager.endCall: ending call...")
callCommand.value = WCallCommand.End
showCallView.value = false
controller.apiEndCall(call.contact)
activeCall.value = null
}
}
}
fun endCall(invitation: RcvCallInvitation) {
with (chatModel) {
callInvitations.remove(invitation.contact.id)
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
activeCallInvitation.value = null
controller.ntfManager.cancelCallNotification()
}
withApi {
if (!controller.apiRejectCall(invitation.contact)) {
Log.e(TAG, "apiRejectCall error")
}
}
}
}
fun reportCallRemoteEnded(invitation: RcvCallInvitation) {
if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) {
chatModel.activeCallInvitation.value = null
chatModel.controller.ntfManager.cancelCallNotification()
}
}
}

View File

@@ -0,0 +1,483 @@
package chat.simplex.app.views.call
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.media.AudioManager
import android.util.Log
import android.view.ViewGroup
import android.webkit.*
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@SuppressLint("SourceLockedOrientationActivity")
@Composable
fun ActiveCallView(chatModel: ChatModel) {
BackHandler(onBack = {
val call = chatModel.activeCall.value
if (call != null) withApi { chatModel.callManager.endCall(call) }
})
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg")
val call = chatModel.activeCall.value
if (call != null) {
Log.d(TAG, "has active call $call")
when (val r = apiMsg.resp) {
is WCallResponse.Capabilities -> withApi {
val callType = CallType(call.localMedia, r.capabilities)
chatModel.controller.apiSendCallInvitation(call.contact, callType)
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
}
is WCallResponse.Offer -> withApi {
chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
}
is WCallResponse.Answer -> withApi {
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
}
is WCallResponse.Ice -> withApi {
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
}
is WCallResponse.Connection ->
try {
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
if (callStatus == WebRTCCallStatus.Connected) {
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
}
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
} catch (e: Error) {
Log.d(TAG,"call status ${r.state.connectionState} not used")
}
is WCallResponse.Connected -> {
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
scope.launch {
delay(2000L)
setCallSound(cxt, call)
}
}
is WCallResponse.Ended -> {
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
withApi { chatModel.callManager.endCall(call) }
chatModel.showCallView.value = false
}
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
is WCallCommand.Answer ->
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
is WCallCommand.Media -> {
when (cmd.media) {
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
}
}
is WCallCommand.Camera -> {
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
if (!call.audioEnabled) {
chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false)
}
}
is WCallCommand.End ->
chatModel.showCallView.value = false
else -> {}
}
is WCallResponse.Error -> {
Log.e(TAG, "ActiveCallView: command error ${r.message}")
}
}
}
}
val call = chatModel.activeCall.value
if (call != null) ActiveCallOverlay(call, chatModel)
}
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
// Lock orientation to portrait in order to have good experience with calls
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
onDispose {
// Unlock orientation
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}
@Composable
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
var cxt = LocalContext.current
ActiveCallOverlayLayout(
call = call,
dismiss = { withApi { chatModel.callManager.endCall(call) } },
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
toggleSound = {
var call = chatModel.activeCall.value
if (call != null) {
call = call.copy(soundSpeaker = !call.soundSpeaker)
chatModel.activeCall.value = call
setCallSound(cxt, call)
}
},
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
)
}
private fun setCallSound(cxt: Context, call: Call) {
Log.d(TAG, "setCallSound: set audio mode")
val am = cxt.getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (call.soundSpeaker) {
am.mode = AudioManager.MODE_NORMAL
am.isSpeakerphoneOn = true
} else {
am.mode = AudioManager.MODE_IN_CALL
am.isSpeakerphoneOn = false
}
}
@Composable
private fun ActiveCallOverlayLayout(
call: Call,
dismiss: () -> Unit,
toggleAudio: () -> Unit,
toggleVideo: () -> Unit,
toggleSound: () -> Unit,
flipCamera: () -> Unit
) {
Column(Modifier.padding(16.dp)) {
when (call.peerMedia ?: call.localMedia) {
CallMediaType.Video -> {
CallInfoView(call, alignment = Alignment.Start)
Spacer(Modifier.fillMaxHeight().weight(1f))
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
ToggleAudioButton(call, toggleAudio)
Spacer(Modifier.size(40.dp))
IconButton(onClick = dismiss) {
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
}
if (call.videoEnabled) {
ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera)
ControlButton(call, Icons.Filled.Videocam, R.string.icon_descr_video_off, toggleVideo)
} else {
Spacer(Modifier.size(48.dp))
ControlButton(call, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo)
}
}
}
CallMediaType.Audio -> {
Spacer(Modifier.fillMaxHeight().weight(1f))
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
ProfileImage(size = 192.dp, image = call.contact.profile.image)
CallInfoView(call, alignment = Alignment.CenterHorizontally)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().padding(bottom = 48.dp), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
IconButton(onClick = dismiss) {
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
}
}
Box(Modifier.padding(start = 32.dp)) {
ToggleAudioButton(call, toggleAudio)
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.padding(end = 32.dp)) {
ToggleSoundButton(call, toggleSound)
}
}
}
}
}
}
}
@Composable
private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit) {
if (call.hasMedia) {
IconButton(onClick = action) {
Icon(icon, stringResource(iconText), tint = Color(0xFFFFFFD8), modifier = Modifier.size(40.dp))
}
} else {
Spacer(Modifier.size(40.dp))
}
}
@Composable
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
if (call.audioEnabled) {
ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_audio_off, toggleAudio)
} else {
ControlButton(call, Icons.Outlined.MicOff, R.string.icon_descr_audio_on, toggleAudio)
}
}
@Composable
private fun ToggleSoundButton(call: Call, toggleSound: () -> Unit) {
if (call.soundSpeaker) {
ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound)
} else {
ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound)
}
}
@Composable
fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
Text(text, color = Color(0xFFFFFFD8), style = style)
Column(horizontalAlignment = alignment) {
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
InfoText(call.callState.text)
val connInfo =
if (call.connectionInfo == null) ""
else " (${call.connectionInfo.text})"
InfoText(call.encryptionStatus + connInfo)
}
}
//@Composable
//fun CallViewDebug(close: () -> Unit) {
// val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
// val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
// val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
//
// BackHandler(onBack = close)
// Column(
// horizontalAlignment = Alignment.CenterHorizontally,
// verticalArrangement = Arrangement.spacedBy(12.dp),
// modifier = Modifier
// .background(MaterialTheme.colors.background)
// .fillMaxSize()
// ) {
// WebRTCView(callCommand) { apiMsg ->
// // for debugging
// // commandText.value = apiMsg
// commandText.value = json.encodeToString(apiMsg)
// }
//
// TextEditor(Modifier.height(180.dp), text = commandText)
//
// Row(
// Modifier
// .fillMaxWidth()
// .padding(bottom = 6.dp),
// horizontalArrangement = Arrangement.SpaceBetween
// ) {
// Button(onClick = {
// val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
// clipboard?.setPrimaryClip(clip)
// }) { Text("Copy") }
// Button(onClick = {
// try {
// val apiCall: WVAPICall = json.decodeFromString(commandText.value)
// commandText.value = ""
// println("sending: ${commandText.value}")
// callCommand.value = apiCall.command
// } catch(e: Error) {
// println("error parsing command: ${commandText.value}")
// println(e)
// }
// }) { Text("Send") }
// Button(onClick = {
// commandText.value = ""
// }) { Text("Clear") }
// }
// }
//}
@Composable
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.MODIFY_AUDIO_SETTINGS,
Manifest.permission.INTERNET
)
)
fun processCommand(wv: WebView, cmd: WCallCommand) {
val apiCall = WVAPICall(command = cmd)
wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null)
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME || event == Lifecycle.Event.ON_START) {
permissionsState.launchMultiplePermissionRequest()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
val wv = webView.value
if (wv != null) processCommand(wv, WCallCommand.End)
lifecycleOwner.lifecycle.removeObserver(observer)
webView.value?.destroy()
webView.value = null
}
}
LaunchedEffect(callCommand.value, webView.value) {
val cmd = callCommand.value
val wv = webView.value
if (cmd != null && wv != null) {
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
processCommand(wv, cmd)
callCommand.value = null
}
}
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current))
.build()
if (permissionsState.allPermissionsGranted) {
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { AndroidViewContext ->
WebView(AndroidViewContext).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
this.webChromeClient = object: WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
if (request.origin.toString().startsWith("file:/")) {
request.grant(request.resources)
} else {
Log.d(TAG, "Permission request from webview denied.")
request.deny()
}
}
}
this.webViewClient = LocalContentWebViewClient(assetLoader)
this.clearHistory()
this.clearCache(true)
this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface")
val webViewSettings = this.settings
webViewSettings.allowFileAccess = true
webViewSettings.allowContentAccess = true
webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
this.loadUrl("file:android_asset/www/call.html")
}
}
) { wv ->
Log.d(TAG, "WebRTCView: webview ready")
// for debugging
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
withApi {
delay(2000L)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = wv
}
}
}
}
}
// for debugging
// class WebRTCInterface(private val onResponse: (String) -> Unit) {
class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
@JavascriptInterface
fun postMessage(message: String) {
Log.d(TAG, "WebRTCInterface.postMessage")
try {
// for debugging
// onResponse(message)
onResponse(json.decodeFromString(message))
} catch (e: Error) {
Log.e(TAG, "failed parsing WebView message: $message")
}
}
}
private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(request.url)
}
}
@Preview
@Composable
fun PreviewActiveCallOverlayVideo() {
SimpleXTheme {
ActiveCallOverlayLayout(
call = Call(
contact = Contact.sampleData,
callState = CallState.Negotiated,
localMedia = CallMediaType.Video,
peerMedia = CallMediaType.Video,
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
),
dismiss = {},
toggleAudio = {},
toggleVideo = {},
toggleSound = {},
flipCamera = {}
)
}
}
@Preview
@Composable
fun PreviewActiveCallOverlayAudio() {
SimpleXTheme {
ActiveCallOverlayLayout(
call = Call(
contact = Contact.sampleData,
callState = CallState.Negotiated,
localMedia = CallMediaType.Audio,
peerMedia = CallMediaType.Audio,
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
),
dismiss = {},
toggleAudio = {},
toggleVideo = {},
toggleSound = {},
flipCamera = {}
)
}
}

View File

@@ -0,0 +1,227 @@
package chat.simplex.app.views.call
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.onboarding.SimpleXLogo
import kotlinx.datetime.Clock
class IncomingCallActivity: ComponentActivity() {
private val vm by viewModels<SimplexViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val activity = this
setContent { IncomingCallActivityView(vm.chatModel, activity) }
unlockForIncomingCall()
}
override fun onDestroy() {
super.onDestroy()
lockAfterIncomingCall()
}
private fun unlockForIncomingCall() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
window.addFlags(activityFlags)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getKeyguardManager(this).requestDismissKeyguard(this, null)
}
}
private fun lockAfterIncomingCall() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
setTurnScreenOn(false)
} else {
window.clearFlags(activityFlags)
}
}
companion object {
const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
}
}
fun getKeyguardManager(context: Context): KeyguardManager =
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
@Composable
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
val switchingCall = m.switchingCall.value
val invitation = m.activeCallInvitation.value
val call = m.activeCall.value
val showCallView = m.showCallView.value
LaunchedEffect(invitation, call, switchingCall, showCallView) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
Log.d(TAG, "IncomingCallActivityView: finishing activity")
activity.finish()
}
}
SimpleXTheme {
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()) {
if (showCallView) {
Box {
ActiveCallView(m)
if (invitation != null) IncomingCallAlertView(invitation, m)
}
} else if (invitation != null) {
IncomingCallLockScreenAlert(invitation, m, activity)
}
}
}
}
@Composable
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
val cm = chatModel.callManager
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
IncomingCallLockScreenAlertLayout(
invitation,
callOnLockScreen,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = { chatModel.activeCallInvitation.value = null },
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
openApp = {
SoundPlayer.shared.stop()
var intent = Intent(activity, MainActivity::class.java)
.setAction(OpenChatAction)
.putExtra("chatId", invitation.contact.id)
activity.startActivity(intent)
activity.finish()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getKeyguardManager(activity).requestDismissKeyguard(activity, null)
}
}
)
}
@Composable
fun IncomingCallLockScreenAlertLayout(
invitation: RcvCallInvitation,
callOnLockScreen: CallOnLockScreen?,
rejectCall: () -> Unit,
ignoreCall: () -> Unit,
acceptCall: () -> Unit,
openApp: () -> Unit
) {
Column(
Modifier
.padding(30.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
IncomingCallInfo(invitation)
Spacer(Modifier.fillMaxHeight().weight(1f))
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
Spacer(Modifier.fillMaxHeight().weight(1f))
Row {
LockScreenCallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
Spacer(Modifier.size(48.dp))
LockScreenCallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
Spacer(Modifier.size(48.dp))
LockScreenCallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
}
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
SimpleXLogo()
Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
Spacer(Modifier.fillMaxHeight().weight(1f))
SimpleButton(text = stringResource(R.string.open_verb), icon = Icons.Filled.Check, click = openApp)
}
}
}
@Composable
private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
Surface(
shape = RoundedCornerShape(10.dp),
color = Color.Transparent
) {
Column(
Modifier
.defaultMinSize(minWidth = 50.dp)
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
IconButton(action) {
Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f))
}
Spacer(Modifier.height(16.dp))
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
}
}
}
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true
)
@Composable
fun PreviewIncomingCallLockScreenAlert() {
SimpleXTheme(true) {
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()) {
IncomingCallLockScreenAlertLayout(
invitation = RcvCallInvitation(
contact = Contact.sampleData,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
sharedKey = null,
callTs = Clock.System.now()
),
callOnLockScreen = null,
rejectCall = {},
ignoreCall = {},
acceptCall = {},
openApp = {},
)
}
}
}

View File

@@ -0,0 +1,111 @@
package chat.simplex.app.views.call
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Contact
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.usersettings.ProfilePreview
import kotlinx.datetime.Clock
@Composable
fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = !chatModel.showCallView.value) }
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
IncomingCallAlertLayout(
invitation,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = { chatModel.activeCallInvitation.value = null },
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
)
}
@Composable
fun IncomingCallAlertLayout(
invitation: RcvCallInvitation,
rejectCall: () -> Unit,
ignoreCall: () -> Unit,
acceptCall: () -> Unit
) {
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
Column(Modifier.fillMaxWidth().background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
IncomingCallInfo(invitation)
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
}
Row(verticalAlignment = Alignment.CenterVertically) {
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
}
}
}
}
@Composable
fun IncomingCallInfo(invitation: RcvCallInvitation) {
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
Row {
if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
Spacer(Modifier.width(4.dp))
Text(invitation.callTypeText)
}
}
@Composable
private fun CallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
Surface(
shape = RoundedCornerShape(10.dp),
color = Color.Transparent
) {
Column(
Modifier
.clickable(onClick = action)
.defaultMinSize(minWidth = 50.dp)
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
}
}
}
@Preview
@Composable
fun PreviewIncomingCallAlertLayout() {
SimpleXTheme {
IncomingCallAlertLayout(
invitation = RcvCallInvitation(
contact = Contact.sampleData,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
sharedKey = null,
callTs = Clock.System.now()
),
rejectCall = {},
ignoreCall = {},
acceptCall = {}
)
}
}

View File

@@ -0,0 +1,39 @@
package chat.simplex.app.views.call
import android.content.Context
import android.media.MediaPlayer
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.core.content.ContextCompat
import chat.simplex.app.R
import chat.simplex.app.views.helpers.withScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
class SoundPlayer {
var player: MediaPlayer? = null
var playing = false
fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) {
if (sound) player = MediaPlayer.create(cxt, R.raw.ring_once)
val vibrator = ContextCompat.getSystemService(cxt, Vibrator::class.java)
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
playing = true
withScope(scope) {
while (playing) {
if (sound) player?.start()
vibrator?.vibrate(effect)
delay(3500)
}
}
}
fun stop() {
playing = false
player?.stop()
}
companion object {
val shared = SoundPlayer()
}
}

View File

@@ -0,0 +1,156 @@
package chat.simplex.app.views.call
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.Contact
import chat.simplex.app.views.helpers.generalGetString
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
data class Call(
val contact: Contact,
val callState: CallState,
val localMedia: CallMediaType,
val localCapabilities: CallCapabilities? = null,
val peerMedia: CallMediaType? = null,
val sharedKey: String? = null,
val audioEnabled: Boolean = true,
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null
) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
val encryptionStatus: String @Composable get() = when(callState) {
CallState.WaitCapabilities -> ""
CallState.InvitationSent -> stringResource(if (localEncrypted) R.string.status_e2e_encrypted else R.string.status_no_e2e_encryption)
CallState.InvitationAccepted -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption)
else -> stringResource(if (!localEncrypted) R.string.status_no_e2e_encryption else if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_e2e_encrypted)
}
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
}
enum class CallState {
WaitCapabilities,
InvitationSent,
InvitationAccepted,
OfferSent,
OfferReceived,
AnswerReceived,
Negotiated,
Connected,
Ended;
val text: String @Composable get() = when(this) {
WaitCapabilities -> stringResource(R.string.callstate_starting)
InvitationSent -> stringResource(R.string.callstate_waiting_for_answer)
InvitationAccepted -> stringResource(R.string.callstate_starting)
OfferSent -> stringResource(R.string.callstate_waiting_for_confirmation)
OfferReceived -> stringResource(R.string.callstate_received_answer)
AnswerReceived -> stringResource(R.string.callstate_received_confirmation)
Negotiated -> stringResource(R.string.callstate_connecting)
Connected -> stringResource(R.string.callstate_connected)
Ended -> stringResource(R.string.callstate_ended)
}
}
@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
@Serializable
sealed class WCallCommand {
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@Serializable @SerialName("camera") class Camera(val camera: VideoCamera): WCallCommand()
@Serializable @SerialName("end") object End: WCallCommand()
}
@Serializable
sealed class WCallResponse {
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
@Serializable @SerialName("connected") class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
@Serializable @SerialName("ended") object Ended: WCallResponse()
@Serializable @SerialName("ok") object Ok: WCallResponse()
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
}
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
@Serializable class RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?, val callTs: Instant) {
val callTypeText: String get() = generalGetString(when(callType.media) {
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
})
val callTitle: String get() = generalGetString(when(callType.media) {
CallMediaType.Video -> R.string.incoming_video_call
CallMediaType.Audio -> R.string.incoming_audio_call
})
}
@Serializable class CallCapabilities(val encryption: Boolean)
@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
val text: String @Composable get() = when {
localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
stringResource(R.string.call_connection_peer_to_peer)
localCandidate?.candidateType == RTCIceCandidateType.Relay && remoteCandidate?.candidateType == RTCIceCandidateType.Relay ->
stringResource(R.string.call_connection_via_relay)
else ->
"${localCandidate?.candidateType?.value ?: "unknown"} / ${remoteCandidate?.candidateType?.value ?: "unknown"}"
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
@Serializable class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type
@Serializable
enum class RTCIceCandidateType(val value: String) {
@SerialName("host") Host("host"),
@SerialName("srflx") ServerReflexive("srflx"),
@SerialName("prflx") PeerReflexive("prflx"),
@SerialName("relay") Relay("relay")
}
@Serializable
enum class WebRTCCallStatus(val value: String) {
@SerialName("connected") Connected("connected"),
@SerialName("connecting") Connecting("connecting"),
@SerialName("disconnected") Disconnected("disconnected"),
@SerialName("failed") Failed("failed")
}
@Serializable
enum class CallMediaType {
@SerialName("video") Video,
@SerialName("audio") Audio
}
@Serializable
enum class VideoCamera {
@SerialName("user") User,
@SerialName("environment") Environment;
val flipped: VideoCamera get() = if (this == User) Environment else User
}
@Serializable
class ConnectionState(
val connectionState: String,
val iceConnectionState: String,
val iceGatheringState: String,
val signalingState: String
)

View File

@@ -0,0 +1,370 @@
package chat.simplex.app.views.chat
import InfoRow
import InfoRowEllipsis
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@Composable
fun ChatInfoView(
chatModel: ChatModel,
contact: Contact,
connStats: ConnectionStats?,
customUserProfile: Profile?,
localAlias: String,
close: () -> Unit,
onChatUpdated: (Chat) -> Unit,
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null) {
ChatInfoLayout(
chat,
contact,
connStats,
customUserProfile,
localAlias,
developerTools,
onLocalAliasChanged = {
setContactAlias(chat.chatInfo.apiId, it, chatModel, onChatUpdated)
},
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
)
}
}
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_contact_question),
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
}
}
)
}
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.clear_chat_question),
text = generalGetString(R.string.clear_chat_warning),
confirmText = generalGetString(R.string.clear_verb),
onConfirm = {
withApi {
val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId)
if (updatedChatInfo != null) {
chatModel.clearChat(updatedChatInfo)
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
}
}
)
}
@Composable
fun ChatInfoLayout(
chat: Chat,
contact: Contact,
connStats: ConnectionStats?,
customUserProfile: Profile?,
localAlias: String,
developerTools: Boolean,
onLocalAliasChanged: (String) -> Unit,
deleteContact: () -> Unit,
clearChat: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
ChatInfoHeader(chat.chatInfo, contact)
}
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
if (customUserProfile != null) {
SectionSpacer()
SectionView(generalGetString(R.string.incognito).uppercase()) {
InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
}
}
SectionSpacer()
if (connStats != null) {
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SectionItemView {
NetworkStatusRow(chat.serverInfo.networkStatus)
}
val rcvServers = connStats.rcvServers
if (rcvServers != null && rcvServers.isNotEmpty()) {
SectionDivider()
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
}
val sndServers = connStats.sndServers
if (sndServers != null && sndServers.isNotEmpty()) {
SectionDivider()
SimplexServers(stringResource(R.string.sending_via), sndServers)
}
}
SectionSpacer()
}
SectionView {
SectionItemView {
ClearChatButton(clearChat)
}
SectionDivider()
SectionItemView {
DeleteContactButton(deleteContact)
}
}
SectionSpacer()
if (developerTools) {
SectionView(title = stringResource(R.string.section_title_for_console)) {
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
SectionDivider()
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
}
SectionSpacer()
}
}
}
@Composable
fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
Column(
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 8.dp)
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
color = MaterialTheme.colors.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun LocalAliasEditor(initialValue: String, updateValue: (String) -> Unit) {
var value by rememberSaveable { mutableStateOf(initialValue) }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
DefaultBasicTextField(
Modifier.padding(horizontal = 10.dp).widthIn(min = 100.dp),
value,
{
Text(
generalGetString(R.string.text_field_set_contact_placeholder),
textAlign = TextAlign.Center,
color = HighOrLowlight
)
},
color = HighOrLowlight,
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty()) TextAlign.Start else TextAlign.Center),
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
) {
value = it
}
}
LaunchedEffect(Unit) {
snapshotFlow { value }
.onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing
.conflate() // get the latest value
.filter { it == value } // don't process old ones
.collect {
updateValue(value)
}
}
DisposableEffect(Unit) {
onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
}
}
@Composable
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
Row(
Modifier
.fillMaxSize()
.clickable {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.network_status),
networkStatus.statusExplanation
)
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(stringResource(R.string.network_status))
Icon(
Icons.Outlined.Info,
stringResource(R.string.network_status),
tint = MaterialTheme.colors.primary
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
networkStatus.statusString,
color = HighOrLowlight
)
ServerImage(networkStatus)
}
}
}
@Composable
fun ServerImage(networkStatus: Chat.NetworkStatus) {
Box(Modifier.size(18.dp)) {
when (networkStatus) {
is Chat.NetworkStatus.Connected ->
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
is Chat.NetworkStatus.Disconnected ->
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
is Chat.NetworkStatus.Error ->
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
}
}
}
@Composable
fun SimplexServers(text: String, servers: List<String>) {
val info = servers.joinToString(separator = ", ") { it.substringAfter("@") }
val clipboardManager: ClipboardManager = LocalClipboardManager.current
InfoRowEllipsis(text, info) {
clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ",")))
Toast.makeText(SimplexApp.context, generalGetString(R.string.copied), Toast.LENGTH_SHORT).show()
}
}
@Composable
fun ClearChatButton(clearChat: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { clearChat() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Restore,
stringResource(R.string.clear_chat_button),
tint = WarningOrange
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.clear_chat_button), color = WarningOrange)
}
}
@Composable
fun DeleteContactButton(deleteContact: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { deleteContact() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.button_delete_contact),
tint = Color.Red
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_delete_contact), color = Color.Red)
}
}
private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel, onChatUpdated: (Chat) -> Unit) = withApi {
chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let {
chatModel.updateContact(it)
onChatUpdated(chatModel.getChat(chatModel.chatId.value ?: return@withApi) ?: return@withApi)
}
}
@Preview
@Composable
fun PreviewChatInfoLayout() {
SimpleXTheme {
ChatInfoLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
Contact.sampleData,
localAlias = "",
developerTools = false,
connStats = null,
onLocalAliasChanged = {},
customUserProfile = null,
deleteContact = {}, clearChat = {}
)
}
}

View File

@@ -0,0 +1,925 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.*
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.graphics.Color
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.chat.group.*
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.chatlist.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.helpers.AppBarHeight
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
@Composable
fun ChatView(chatModel: ChatModel) {
var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = rememberSaveable(saver = ComposeState.saver()) {
mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews))
}
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
activeChat = if (chatModel.chatId.value == null) {
null
} else {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
chatModel.getChat(chatModel.chatId.value!!)
}
}
}
if (activeChat == null || user == null) {
chatModel.chatId.value = null
} else {
val chat = activeChat!!
BackHandler { chatModel.chatId.value = null }
// We need to have real unreadCount value for displaying it inside top right button
// Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember {
derivedStateOf {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
}
}
ChatLayout(
user,
chat,
unreadCount,
composeState,
composeView = {
if (chat.chatInfo.sendMsgEnabled) {
ComposeView(
chatModel, chat, composeState, attachmentOption,
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
)
}
},
attachmentOption,
scope,
attachmentBottomSheetState,
chatModel.chatItems,
searchText,
useLinkPreviews = useLinkPreviews,
chatModelIncognito = chatModel.incognito.value,
back = { chatModel.chatId.value = null },
info = {
withApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) {
activeChat = it
}
}
}
} else if (cInfo is ChatInfo.Group) {
setGroupMembers(cInfo.groupInfo, chatModel)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupChatInfoView(chatModel, close)
}
}
}
}
},
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
}
}
}
},
loadPrevMessages = { cInfo ->
val c = chatModel.getChat(cInfo.id)
val firstId = chatModel.chatItems.firstOrNull()?.id
if (c != null && firstId != null) {
withApi {
apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.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)
}
},
receiveFile = { fileId ->
withApi { chatModel.controller.receiveFile(fileId) }
},
joinGroup = { groupId ->
withApi { chatModel.controller.apiJoinGroup(groupId) }
},
startCall = { media ->
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
chatModel.showCallView.value = true
chatModel.callCommand.value = WCallCommand.Capabilities
}
},
acceptCall = { contact ->
val invitation = chatModel.callInvitations.remove(contact.id)
if (invitation == null) {
AlertManager.shared.showAlertMsg("Call already ended!")
} else {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
},
addMembers = { groupInfo ->
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AddGroupMembersView(groupInfo, chatModel, close)
}
}
}
},
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
range
)
}
},
changeNtfsState = { enabled, currentValue -> changeNtfsStatePerChat(enabled, currentValue, chat, chatModel) },
onSearchValueChanged = { value ->
if (searchText.value == value) return@ChatLayout
val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout
withApi {
apiFindMessages(c.chatInfo, chatModel, value)
searchText.value = value
}
}
)
}
}
@Composable
fun ChatLayout(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
chatModelIncognito: Boolean,
back: () -> Unit,
info: () -> Unit,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit,
addMembers: (GroupInfo) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
) {
Surface(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background)
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetContent = {
ChooseAttachmentView(
attachmentOption,
hide = { scope.launch { attachmentBottomSheetState.hide() } }
)
},
sheetState = attachmentBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
val setFloatingButton = { button: @Composable () -> Unit ->
floatingButton.value = button
}
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, changeNtfsState, onSearchValueChanged) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
) { contentPadding ->
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
ChatItemsList(
user, chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
)
}
}
}
}
}
}
@Composable
fun ChatInfoToolbar(
chat: Chat,
back: () -> Unit,
info: () -> Unit,
startCall: (CallMediaType) -> Unit,
addMembers: (GroupInfo) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
) {
val scope = rememberCoroutineScope()
var showMenu by rememberSaveable { mutableStateOf(false) }
var showSearch by rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch) {
back()
} else {
onSearchValueChanged("")
showSearch = false
}
}
BackHandler(onBack = onBackClicked)
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val menuItems = arrayListOf<@Composable () -> Unit>()
menuItems.add {
ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), Icons.Outlined.Search, onClick = {
showMenu = false
showSearch = true
})
}
if (chat.chatInfo is ChatInfo.Direct) {
barButtons.add {
IconButton({
showMenu = false
startCall(CallMediaType.Audio)
}) {
Icon(Icons.Outlined.Phone, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
}
}
menuItems.add {
ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), Icons.Outlined.Videocam, onClick = {
showMenu = false
startCall(CallMediaType.Video)
})
}
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
barButtons.add {
IconButton({
showMenu = false
addMembers(chat.chatInfo.groupInfo)
}) {
Icon(Icons.Outlined.PersonAdd, stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
}
}
}
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
menuItems.add {
ItemAction(
if (ntfsEnabled.value) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
if (ntfsEnabled.value) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
onClick = {
showMenu = false
// Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu
scope.launch {
delay(200)
changeNtfsState(!ntfsEnabled.value, ntfsEnabled)
}
}
)
}
barButtons.add {
IconButton({ showMenu = true }) {
Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
}
}
DefaultTopAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { ChatInfoToolbarTitle(chat.chatInfo) },
onTitleClick = info,
showSearch = showSearch,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
)
Divider(Modifier.padding(top = AppBarHeight))
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
Modifier.widthIn(min = 220.dp)
) {
menuItems.forEach { it() }
}
}
}
@Composable
fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondary) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (cInfo.incognito) {
IncognitoImage(size = 36.dp, Indigo)
}
ChatInfoImage(cInfo, size = imageSize, iconColor)
Column(
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
Text(
cInfo.fullName,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
}
}
}
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 BoxWithConstraintsScope.ChatItemsList(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
chatModelIncognito: Boolean,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
var shouldAutoScroll by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType, shouldAutoScroll) {
if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) {
scope.launch { listState.scrollToItem(0) }
}
// Don't autoscroll next time until it will be needed
shouldAutoScroll = false
}
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
LaunchedEffect(searchValue.value.isEmpty()) {
// They are equal when orientation was changed, don't need to scroll.
// LaunchedEffect unaware of this event since it uses remember, not rememberSaveable
if (prevSearchEmptiness == searchValue.value.isEmpty()) return@LaunchedEffect
prevSearchEmptiness = searchValue.value.isEmpty()
if (listState.firstVisibleItemIndex != 0) {
scope.launch { listState.scrollToItem(0) }
}
}
PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, chat, chatItems) { c ->
loadPrevMessages(c.chatInfo)
}
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems) { i, cItem ->
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
) {
val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false }
val directions = setOf(DismissDirection.EndToStart)
val swipeableModifier = SwipeToDismissModifier(
state = dismissState,
directions = directions,
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
)
val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd))
val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart))
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
LaunchedEffect(Unit) {
scope.launch {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
}
}
}
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
val member = cItem.chatDir.groupMember
val showMember = showMemberImage(member, prevItem)
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
if (showMember) {
val contactId = member.memberContactId
if (contactId == null) {
MemberImage(member)
} else {
Box(
Modifier
.clip(CircleShape)
.clickable {
showMemberInfo(chat.chatInfo.groupInfo, member)
}
) {
MemberImage(member)
}
}
Spacer(Modifier.size(4.dp))
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
}
} else { // direct message
val sent = cItem.chatDir.sent
Box(
Modifier.padding(
start = if (sent) 76.dp else 12.dp,
end = if (sent) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
}
}
if (cItem.isRcvNew) {
LaunchedEffect(cItem.id) {
scope.launch {
delay(750)
markRead(CC.ItemRange(cItem.id, cItem.id), null)
}
}
}
}
}
}
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState)
}
@Composable
fun BoxWithConstraintsScope.FloatingButtons(
chatItems: List<ChatItem>,
unreadCount: State<Int>,
minUnreadItemId: Long,
searchValue: State<String>,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
listState: LazyListState
) {
val scope = rememberCoroutineScope()
var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) }
var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) }
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect {
firstVisibleIndex = it
firstItemIsVisible = firstVisibleIndex == 0
}
}
LaunchedEffect(listState) {
// When both snapshotFlows located in one LaunchedEffect second block will never be called because coroutine is paused on first block
// so separate them into two LaunchedEffects
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
.distinctUntilChanged()
.collect {
lastIndexOfVisibleItems = it
}
}
val bottomUnreadCount by remember {
derivedStateOf {
if (unreadCount.value == 0) return@derivedStateOf 0
val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
if (chatItems.size <= from || from < 0) return@derivedStateOf 0
chatItems.subList(from, chatItems.size).count { it.isRcvNew }
}
}
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty()
val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
setFloatingButton(
bottomEndFloatingButton(
bottomUnreadCount,
showButtonWithCounter,
showButtonWithArrow,
onClickArrowDown = {
scope.launch { listState.animateScrollToItem(0) }
},
onClickCounter = {
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
}
))
}
// Don't show top FAB if is in search
if (searchValue.value.isNotEmpty()) return
val fabSize = 56.dp
val topUnreadCount by remember {
derivedStateOf { unreadCount.value - bottomUnreadCount }
}
val showButtonWithCounter = topUnreadCount > 0
val height = with(LocalDensity.current) { maxHeight.toPx() }
var showDropDown by remember { mutableStateOf(false) }
TopEndFloatingButton(
Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd),
topUnreadCount,
showButtonWithCounter,
onClick = { scope.launch { listState.animateScrollBy(height) } },
onLongClick = { showDropDown = true }
)
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
Modifier.width(220.dp),
offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)
) {
DropdownMenuItem(
onClick = {
markRead(
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
)
showDropDown = false
}
) {
Text(
generalGetString(R.string.mark_read),
maxLines = 1,
)
}
}
}
@Composable
fun PreloadItems(
listState: LazyListState,
remaining: Int = 10,
chat: Chat,
items: List<*>,
onLoadMore: (chat: Chat) -> Unit,
) {
LaunchedEffect(listState, chat, items) {
snapshotFlow { listState.layoutInfo }
.map {
val totalItemsNumber = it.totalItemsCount
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
totalItemsNumber
else
0
}
.distinctUntilChanged()
.filter { it > 0 }
.collect {
onLoadMore(chat)
}
}
}
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)
}
@Composable
private fun TopEndFloatingButton(
modifier: Modifier = Modifier,
unreadCount: Int,
showButtonWithCounter: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit
) = when {
showButtonWithCounter -> {
val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
FloatingActionButton(
{}, // no action here
modifier.size(48.dp),
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp),
interactionSource = interactionSource,
) {
Text(
unreadCountStr(unreadCount),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
}
else -> {
}
}
private fun bottomEndFloatingButton(
unreadCount: Int,
showButtonWithCounter: Boolean,
showButtonWithArrow: Boolean,
onClickArrowDown: () -> Unit,
onClickCounter: () -> Unit
): @Composable () -> Unit = when {
showButtonWithCounter -> {
{
FloatingActionButton(
onClick = onClickCounter,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp)
) {
Text(
unreadCountStr(unreadCount),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
}
}
showButtonWithArrow -> {
{
FloatingActionButton(
onClick = onClickArrowDown,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
}
}
else -> {
{}
}
}
private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
override val longPressTimeoutMillis
get() =
this@bigTouchSlop.longPressTimeoutMillis
override val doubleTapTimeoutMillis
get() =
this@bigTouchSlop.doubleTapTimeoutMillis
override val doubleTapMinTimeMillis
get() =
this@bigTouchSlop.doubleTapMinTimeMillis
override val touchSlop: Float get() = slop
}
@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"
)
)
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") }
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
unreadCount = unreadCount,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
chatModelIncognito = false,
back = {},
info = {},
showMemberInfo = {_, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
startCall = {},
acceptCall = { _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
)
}
}
@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"
)
)
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") }
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
unreadCount = unreadCount,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
chatModelIncognito = false,
back = {},
info = {},
showMemberInfo = {_, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
startCall = {},
acceptCall = { _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
)
}
}

View File

@@ -0,0 +1,60 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.SentColorLight
@Composable
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
Row(
Modifier
.height(60.dp)
.fillMaxWidth()
.padding(top = 8.dp)
.background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.InsertDriveFile,
stringResource(R.string.icon_descr_file),
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
tint = if (isInDarkTheme()) FileDark else FileLight
)
Text(fileName)
Spacer(Modifier.weight(1f))
if (cancelEnabled) {
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
}
@Preview
@Composable
fun PreviewComposeFileView() {
SimpleXTheme {
ComposeFileView(
"test.txt",
cancelFile = {},
cancelEnabled = true
)
}
}

View File

@@ -0,0 +1,47 @@
import androidx.compose.foundation.Image
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.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.views.chat.item.SentColorLight
import chat.simplex.app.views.helpers.base64ToBitmap
@Composable
fun ComposeImageView(image: String, cancelImage: () -> Unit, cancelEnabled: Boolean) {
Row(
Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
) {
val imageBitmap = base64ToBitmap(image).asImageBitmap()
Image(
imageBitmap,
"preview image",
modifier = Modifier
.width(80.dp)
.height(60.dp)
.padding(end = 8.dp)
)
Spacer(Modifier.weight(1f))
if (cancelEnabled) {
IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
}

View File

@@ -0,0 +1,542 @@
package chat.simplex.app.views.chat
import ComposeFileView
import ComposeImageView
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.graphics.drawable.AnimatedImageDrawable
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.CallSuper
import androidx.compose.foundation.clickable
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.AttachFile
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Reply
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import java.io.File
@Serializable
sealed class ComposePreview {
@Serializable object NoPreview: ComposePreview()
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
@Serializable class ImagePreview(val image: String): ComposePreview()
@Serializable class FilePreview(val fileName: String): ComposePreview()
}
@Serializable
sealed class ComposeContextItem {
@Serializable object NoContextItem: ComposeContextItem()
@Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
}
@Serializable
data class ComposeState(
val message: String = "",
val preview: ComposePreview = ComposePreview.NoPreview,
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
val inProgress: Boolean = false,
val useLinkPreviews: Boolean
) {
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this (
editingItem.content.text,
chatItemPreview(editingItem),
ComposeContextItem.EditingItem(editingItem),
useLinkPreviews = useLinkPreviews
)
val editing: Boolean
get() =
when (contextItem) {
is ComposeContextItem.EditingItem -> true
else -> false
}
val sendEnabled: () -> Boolean
get() = {
val hasContent = when (preview) {
is ComposePreview.ImagePreview -> true
is ComposePreview.FilePreview -> true
else -> message.isNotEmpty()
}
hasContent && !inProgress
}
val linkPreviewAllowed: Boolean
get() =
when (preview) {
is ComposePreview.ImagePreview -> false
is ComposePreview.FilePreview -> false
else -> useLinkPreviews
}
val linkPreview: LinkPreview?
get() =
when (preview) {
is ComposePreview.CLinkPreview -> preview.linkPreview
else -> null
}
companion object {
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
save = { json.encodeToString(serializer(), it.value) },
restore = {
mutableStateOf(json.decodeFromString(it))
}
)
}
}
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
return when (val mc = chatItem.content.msgContent) {
is MsgContent.MCText -> ComposePreview.NoPreview
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image)
is MsgContent.MCFile -> {
val fileName = chatItem.file?.fileName ?: ""
ComposePreview.FilePreview(fileName)
}
else -> ComposePreview.NoPreview
}
}
@Composable
fun ComposeView(
chatModel: ChatModel,
chat: Chat,
composeState: MutableState<ComposeState>,
attachmentOption: MutableState<AttachmentOption?>,
showChooseAttachment: () -> Unit
) {
val context = LocalContext.current
val linkUrl = remember { mutableStateOf<String?>(null) }
val prevLinkUrl = remember { mutableStateOf<String?>(null) }
val pendingLinkUrl = remember { mutableStateOf<String?>(null) }
val cancelledLinks = remember { mutableSetOf<String>() }
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
// attachments
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val chosenAnimImage = remember { mutableStateOf<Uri?>(null) }
val chosenFile = remember { mutableStateOf<Uri?>(null) }
val photoUri = remember { mutableStateOf<Uri?>(null) }
val photoTmpFile = remember { mutableStateOf<File?>(null) }
class ComposeTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
@CallSuper
override fun createIntent(context: Context, input: Void?): Intent {
photoTmpFile.value = File.createTempFile("image", ".bmp", SimplexApp.context.filesDir)
photoUri.value = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", photoTmpFile.value!!)
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
.putExtra(MediaStore.EXTRA_OUTPUT, photoUri.value)
}
override fun getSynchronousResult(
context: Context,
input: Void?
): SynchronousResult<Bitmap?>? = null
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
val photoUriVal = photoUri.value
val photoTmpFileVal = photoTmpFile.value
return if (resultCode == Activity.RESULT_OK && photoUriVal != null && photoTmpFileVal != null) {
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, photoUriVal)
val bitmap = ImageDecoder.decodeBitmap(source)
photoTmpFileVal.delete()
bitmap
} else {
Log.e(TAG, "Getting image from camera cancelled or failed.")
photoTmpFile.value?.delete()
null
}
}
}
val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? ->
if (bitmap != null) {
chosenImage.value = bitmap
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
}
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launch(null)
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
val processPickedImage = { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val drawable = ImageDecoder.decodeDrawable(source)
val bitmap = ImageDecoder.decodeBitmap(source)
if (drawable is AnimatedImageDrawable) {
// It's a gif or webp
val fileSize = getFileSize(context, uri)
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
chosenAnimImage.value = uri
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
)
}
} else {
chosenImage.value = bitmap
}
if (chosenImage.value != null || chosenAnimImage.value != null) {
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
}
}
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery(), processPickedImage)
val galleryLauncherFallback = rememberGetContentLauncher(processPickedImage)
val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val fileSize = getFileSize(context, uri)
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
val fileName = getFileName(SimplexApp.context, uri)
if (fileName != null) {
chosenFile.value = uri
composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
}
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
)
}
}
}
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
AttachmentOption.TakePhoto -> {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launch(null)
}
else -> {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
attachmentOption.value = null
}
AttachmentOption.PickImage -> {
try {
galleryLauncher.launch(0)
} catch (e: ActivityNotFoundException) {
galleryLauncherFallback.launch("image/*")
}
attachmentOption.value = null
}
AttachmentOption.PickFile -> {
filesLauncher.launch("*/*")
attachmentOption.value = null
}
else -> {}
}
}
fun isSimplexLink(link: String): Boolean =
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
fun parseMessage(msg: String): String? {
val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) }
val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
return link?.text
}
fun loadLinkPreview(url: String, wait: Long? = null) {
if (pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
withApi {
if (wait != null) delay(wait)
val lp = getLinkPreview(url)
if (lp != null && pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp))
pendingLinkUrl.value = null
}
}
}
}
fun showLinkPreview(s: String) {
prevLinkUrl.value = linkUrl.value
linkUrl.value = parseMessage(s)
val url = linkUrl.value
if (url != null) {
if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) {
pendingLinkUrl.value = url
loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L)
}
} else {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
}
}
fun resetLinkPreview() {
linkUrl.value = null
prevLinkUrl.value = null
pendingLinkUrl.value = null
cancelledLinks.clear()
}
fun checkLinkPreview(): MsgContent {
val cs = composeState.value
return when (val composePreview = cs.preview) {
is ComposePreview.CLinkPreview -> {
val url = parseMessage(cs.message)
val lp = composePreview.linkPreview
if (lp != null && url == lp.uri) {
MsgContent.MCLink(cs.message, preview = lp)
} else {
MsgContent.MCText(cs.message)
}
}
else -> MsgContent.MCText(cs.message)
}
}
fun updateMsgContent(msgContent: MsgContent): MsgContent {
val cs = composeState.value
return when (msgContent) {
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
}
}
fun clearState() {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
textStyle.value = smallFont
chosenImage.value = null
chosenAnimImage.value = null
chosenFile.value = null
linkUrl.value = null
prevLinkUrl.value = null
pendingLinkUrl.value = null
cancelledLinks.clear()
}
fun sendMessage() {
composeState.value = composeState.value.copy(inProgress = true)
val cInfo = chat.chatInfo
val cs = composeState.value
when (val contextItem = cs.contextItem) {
is ComposeContextItem.EditingItem -> {
val ei = contextItem.chatItem
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
withApi {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
mc = updateMsgContent(oldMsgContent)
)
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
clearState()
}
}
}
else -> {
var mc: MsgContent? = null
var file: String? = null
when (val preview = cs.preview) {
ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
is ComposePreview.ImagePreview -> {
val chosenImageVal = chosenImage.value
if (chosenImageVal != null) {
file = saveImage(context, chosenImageVal)
if (file != null) {
mc = MsgContent.MCImage(cs.message, preview.image)
}
}
val chosenGifImageVal = chosenAnimImage.value
if (chosenGifImageVal != null) {
file = saveAnimImage(context, chosenGifImageVal)
if (file != null) {
mc = MsgContent.MCImage(cs.message, preview.image)
}
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
mc = MsgContent.MCFile(cs.message)
}
}
}
}
val quotedItemId: Long? = when (contextItem) {
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
else -> null
}
if (mc != null) {
withApi {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
file = file,
quotedItemId = quotedItemId,
mc = mc
)
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
clearState()
}
} else {
clearState()
}
}
}
}
fun onMessageChange(s: String) {
composeState.value = composeState.value.copy(message = s)
if (isShortEmoji(s)) {
textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
} else {
textStyle.value = smallFont
if (composeState.value.linkPreviewAllowed) {
if (s.isNotEmpty()) showLinkPreview(s)
else resetLinkPreview()
}
}
}
fun cancelLinkPreview() {
val uri = composeState.value.linkPreview?.uri
if (uri != null) {
cancelledLinks.add(uri)
}
pendingLinkUrl.value = null
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
}
fun cancelImage() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenImage.value = null
chosenAnimImage.value = null
}
fun cancelFile() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenFile.value = null
}
@Composable
fun previewView() {
when (val preview = composeState.value.preview) {
ComposePreview.NoPreview -> {}
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
is ComposePreview.ImagePreview -> ComposeImageView(
preview.image,
::cancelImage,
cancelEnabled = !composeState.value.editing
)
is ComposePreview.FilePreview -> ComposeFileView(
preview.fileName,
::cancelFile,
cancelEnabled = !composeState.value.editing
)
}
}
@Composable
fun contextItemView() {
when (val contextItem = composeState.value.contextItem) {
ComposeContextItem.NoContextItem -> {}
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
}
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) {
clearState()
}
}
}
Column {
contextItemView()
when {
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
else -> previewView()
}
Row(
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
val attachEnabled = !composeState.value.editing
Box(Modifier.padding(bottom = 12.dp)) {
Icon(
Icons.Filled.AttachFile,
contentDescription = stringResource(R.string.attach),
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.clickable {
if (attachEnabled) {
showChooseAttachment()
}
}
)
}
SendMsgView(
composeState,
sendMessage = {
sendMessage()
resetLinkPreview()
},
::onMessageChange,
textStyle
)
}
}
}
class PickFromGallery: ActivityResultContract<Int, Uri?>() {
override fun createIntent(context: Context, input: Int) = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI)
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
}

View File

@@ -0,0 +1,79 @@
package chat.simplex.app.views.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.*
import kotlinx.datetime.Clock
@Composable
fun ContextItemView(
contextItem: ChatItem,
contextIcon: ImageVector,
cancelContextItem: () -> Unit
) {
val sent = contextItem.chatDir.sent
Row(
Modifier
.padding(top = 8.dp)
.background(if (sent) SentColorLight else ReceivedColorLight),
verticalAlignment = Alignment.CenterVertically
) {
Row(
Modifier
.padding(vertical = 12.dp)
.fillMaxWidth()
.weight(1F),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
contextIcon,
modifier = Modifier
.padding(horizontal = 8.dp)
.height(20.dp)
.width(20.dp),
contentDescription = stringResource(R.string.icon_descr_context),
tint = HighOrLowlight,
)
MarkdownText(
contextItem.text, contextItem.formattedText,
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3
)
}
IconButton(onClick = cancelContextItem) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.cancel_verb),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
@Preview
@Composable
fun PreviewContextItemView() {
SimpleXTheme {
ContextItemView(
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
contextIcon = Icons.Filled.Edit,
cancelContextItem = {}
)
}
}

View File

@@ -0,0 +1,176 @@
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.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.coroutines.delay
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
sendMessage: () -> Unit,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
val cs = composeState.value
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(cs.contextItem) {
if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect
// In replying state
focusRequester.requestFocus()
delay(50)
keyboard?.show()
}
BasicTextField(
value = cs.message,
onValueChange = onMessageChange,
textStyle = textStyle.value,
maxLines = 16,
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
),
modifier = Modifier.padding(vertical = 8.dp).focusRequester(focusRequester),
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 icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
) {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp),
color = HighOrLowlight,
strokeWidth = 3.dp
)
} else {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cs.sendEnabled()) {
sendMessage()
}
}
)
}
}
}
}
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewSendMsgView() {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewSendMsgViewEditing() {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData(), useLinkPreviews = true)
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateEditing) },
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewSendMsgViewInProgress() {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true, useLinkPreviews = true)
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) },
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
)
}
}

View File

@@ -0,0 +1,356 @@
package chat.simplex.app.views.chat.group
import SectionCustomFooter
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
import chat.simplex.app.views.helpers.*
@Composable
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
val selectedContacts = remember { mutableStateListOf<Long>() }
val selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) }
BackHandler(onBack = close)
AddGroupMembersLayout(
groupInfo = groupInfo,
contactsToAdd = getContactsToAdd(chatModel),
selectedContacts = selectedContacts,
selectedRole = selectedRole,
inviteMembers = {
withApi {
selectedContacts.forEach {
val member = chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value)
if (member != null) {
chatModel.upsertGroupMember(groupInfo, member)
}
}
close.invoke()
}
},
clearSelection = { selectedContacts.clear() },
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
)
}
fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
val memberContactIds = chatModel.groupMembers
.filter { it.memberCurrent }
.mapNotNull { it.memberContactId }
return chatModel.chats
.asSequence()
.map { it.chatInfo }
.filterIsInstance<ChatInfo.Direct>()
.map { it.contact }
.filter { it.contactId !in memberContactIds }
.sortedBy { it.displayName.lowercase() }
.toList()
}
@Composable
fun AddGroupMembersLayout(
groupInfo: GroupInfo,
contactsToAdd: List<Contact>,
selectedContacts: SnapshotStateList<Long>,
selectedRole: MutableState<GroupMemberRole>,
inviteMembers: () -> Unit,
clearSelection: () -> Unit,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
ChatInfoToolbarTitle(
ChatInfo.Group(groupInfo),
imageSize = 60.dp,
iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight
)
}
SectionSpacer()
if (contactsToAdd.isEmpty()) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
stringResource(R.string.no_contacts_to_add),
Modifier.padding(),
color = HighOrLowlight
)
}
} else {
SectionView {
SectionItemView {
RoleSelectionRow(groupInfo, selectedRole)
}
SectionDivider()
SectionItemView {
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty())
}
}
SectionCustomFooter {
InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection)
}
SectionSpacer()
SectionView {
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, addContact, removeContact)
}
SectionSpacer()
}
}
}
@Composable
fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(R.string.new_member_role))
RoleDropdownMenu(groupInfo, selectedRole)
}
}
@Composable
fun RoleDropdownMenu(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
val options = GroupMemberRole.values()
.filter { it <= groupInfo.membership.memberRole }
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
Row(
Modifier.fillMaxWidth(0.7f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
selectedRole.value.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
Spacer(Modifier.size(4.dp))
Icon(
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
generalGetString(R.string.invite_to_group_button),
modifier = Modifier.padding(start = 8.dp),
tint = HighOrLowlight
)
}
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
options.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
selectedRole.value = selectionOption
expanded = false
}
) {
Text(
selectionOption.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
fun InviteMembersButton(inviteMembers: () -> Unit, disabled: Boolean) {
val modifier = if (disabled) Modifier else Modifier.clickable { inviteMembers() }
Row(
modifier.fillMaxSize(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
val color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Text(stringResource(R.string.invite_to_group_button), color = color)
Spacer(Modifier.size(8.dp))
Icon(
Icons.Outlined.Check,
stringResource(R.string.invite_to_group_button),
tint = color
)
}
}
@Composable
fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (selectedContactsCount >= 1) Arrangement.SpaceBetween else Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (selectedContactsCount >= 1) {
Box(
Modifier.clickable { clearSelection() }
) {
Text(
stringResource(R.string.clear_contacts_selection_button),
color = MaterialTheme.colors.primary,
fontSize = 12.sp
)
}
Text(
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
color = HighOrLowlight,
fontSize = 12.sp
)
} else {
Text(
stringResource(R.string.no_contacts_selected),
color = HighOrLowlight,
fontSize = 12.sp
)
}
}
}
@Composable
fun ContactList(
contacts: List<Contact>,
selectedContacts: SnapshotStateList<Long>,
groupInfo: GroupInfo,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit
) {
Column {
contacts.forEachIndexed { index, contact ->
SectionItemView {
ContactCheckRow(
contact, groupInfo, addContact, removeContact,
checked = selectedContacts.contains(contact.apiId)
)
}
if (index < contacts.lastIndex) {
SectionDivider()
}
}
}
}
@Composable
fun ContactCheckRow(
contact: Contact,
groupInfo: GroupInfo,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit,
checked: Boolean
) {
val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
val icon: ImageVector
val iconColor: Color
if (prohibitedToInviteIncognito) {
icon = Icons.Filled.TheaterComedy
iconColor = HighOrLowlight
} else if (checked) {
icon = Icons.Filled.CheckCircle
iconColor = MaterialTheme.colors.primary
} else {
icon = Icons.Outlined.Circle
iconColor = HighOrLowlight
}
Row(
Modifier
.fillMaxSize()
.clickable {
if (prohibitedToInviteIncognito) {
showProhibitedToInviteIncognitoAlertDialog()
} else if (!checked)
addContact(contact.apiId)
else
removeContact(contact.apiId)
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
ProfileImage(size = 36.dp, contact.image)
Text(
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified
)
}
Icon(
icon,
contentDescription = stringResource(R.string.icon_descr_contact_checked),
tint = iconColor
)
}
}
fun showProhibitedToInviteIncognitoAlertDialog() {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.invite_prohibited),
text = generalGetString(R.string.invite_prohibited_description),
confirmText = generalGetString(R.string.ok),
)
}
@Preview
@Composable
fun PreviewAddGroupMembersLayout() {
SimpleXTheme {
AddGroupMembersLayout(
groupInfo = GroupInfo.sampleData,
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
selectedContacts = remember { mutableStateListOf() },
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
inviteMembers = {},
clearSelection = {},
addContact = {},
removeContact = {}
)
}
}

View File

@@ -0,0 +1,362 @@
package chat.simplex.app.views.chat.group
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
import chat.simplex.app.views.chatlist.setGroupMembers
import chat.simplex.app.views.helpers.*
@Composable
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null && chat.chatInfo is ChatInfo.Group) {
val groupInfo = chat.chatInfo.groupInfo
GroupChatInfoLayout(
chat,
groupInfo,
members = chatModel.groupMembers
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedBy { it.displayName.lowercase() },
developerTools,
addMembers = {
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AddGroupMembersView(groupInfo, chatModel, close)
}
}
}
},
showMemberInfo = { member ->
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
ModalManager.shared.showCustomModal { closeCurrent ->
ModalView(
close = closeCurrent, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
}
}
}
},
editGroupProfile = {
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
)
}
}
fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
val alertTextKey =
if (groupInfo.membership.memberCurrent) R.string.delete_group_for_all_members_cannot_undo_warning
else R.string.delete_group_for_self_cannot_undo_warning
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_group_question),
text = generalGetString(alertTextKey),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
}
}
)
}
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.leave_group_question),
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
confirmText = generalGetString(R.string.leave_group_button),
onConfirm = {
withApi {
chatModel.controller.leaveGroup(groupInfo.groupId)
close?.invoke()
}
}
)
}
@Composable
fun GroupChatInfoLayout(
chat: Chat,
groupInfo: GroupInfo,
members: List<GroupMember>,
developerTools: Boolean,
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
deleteGroup: () -> Unit,
clearChat: () -> Unit,
leaveGroup: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
GroupChatInfoHeader(chat.chatInfo)
}
SectionSpacer()
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
SectionItemView {
val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary
val onClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
AddMembersButton(tint, onClick)
}
SectionDivider()
}
SectionItemView(height = 50.dp) {
MemberRow(groupInfo.membership, user = true)
}
if (members.isNotEmpty()) {
SectionDivider()
}
MembersList(members, showMemberInfo)
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView {
EditGroupProfileButton(editGroupProfile)
}
SectionDivider()
}
SectionItemView {
ClearChatButton(clearChat)
}
if (groupInfo.canDelete) {
SectionDivider()
SectionItemView {
DeleteGroupButton(deleteGroup)
}
}
if (groupInfo.membership.memberCurrent) {
SectionDivider()
SectionItemView {
LeaveGroupButton(leaveGroup)
}
}
}
SectionSpacer()
if (developerTools) {
SectionView(title = stringResource(R.string.section_title_for_console)) {
InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
SectionDivider()
InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
}
SectionSpacer()
}
}
}
@Composable
fun GroupChatInfoHeader(cInfo: ChatInfo) {
Column(
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
color = MaterialTheme.colors.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, addMembers: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { addMembers() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Add,
stringResource(R.string.button_add_members),
tint = tint
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_add_members), color = tint)
}
}
@Composable
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
Column {
members.forEachIndexed { index, member ->
SectionItemView(height = 50.dp) {
MemberRow(member, showMemberInfo)
}
if (index < members.lastIndex) {
SectionDivider()
}
}
}
}
@Composable
fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = null, user: Boolean = false) {
val modifier = if (showMemberInfo != null) Modifier.clickable { showMemberInfo(member) } else Modifier
Row(
modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
ProfileImage(size = 46.dp, member.image)
Column {
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
val s = member.memberStatus.shortText
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
Text(
statusDescr,
color = HighOrLowlight,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
val role = member.memberRole
if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) {
Text(role.text, color = HighOrLowlight)
}
}
}
@Composable
fun EditGroupProfileButton(editGroupProfile: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { editGroupProfile() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Edit,
stringResource(R.string.button_edit_group_profile),
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
}
}
@Composable
fun LeaveGroupButton(leaveGroup: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { leaveGroup() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Logout,
stringResource(R.string.button_leave_group),
tint = Color.Red
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_leave_group), color = Color.Red)
}
}
@Composable
fun DeleteGroupButton(deleteGroup: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { deleteGroup() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.button_delete_group),
tint = Color.Red
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_delete_group), color = Color.Red)
}
}
@Preview
@Composable
fun PreviewGroupChatInfoLayout() {
SimpleXTheme {
GroupChatInfoLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
groupInfo = GroupInfo.sampleData,
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {},
)
}
}

View File

@@ -0,0 +1,242 @@
package chat.simplex.app.views.chat.group
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.SimplexServers
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.*
@Composable
fun GroupMemberInfoView(
groupInfo: GroupInfo,
member: GroupMember,
connStats: ConnectionStats?,
chatModel: ChatModel,
close: () -> Unit,
closeAll: () -> Unit, // Close all open windows up to ChatView
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null) {
GroupMemberInfoLayout(
groupInfo,
member,
connStats,
developerTools,
openDirectChat = {
withApi {
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
if (oldChat != null) {
openChat(oldChat.chatInfo, chatModel)
} else {
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
newChat = newChat.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
chatModel.addChat(newChat)
chatModel.chatItems.clear()
chatModel.chatId.value = newChat.id
}
closeAll()
}
},
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }
)
}
}
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.button_remove_member),
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
confirmText = generalGetString(R.string.remove_member_confirmation),
onConfirm = {
withApi {
val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
if (removedMember != null) {
chatModel.upsertGroupMember(groupInfo, removedMember)
}
close?.invoke()
}
}
)
}
@Composable
fun GroupMemberInfoLayout(
groupInfo: GroupInfo,
member: GroupMember,
connStats: ConnectionStats?,
developerTools: Boolean,
openDirectChat: () -> Unit,
removeMember: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
GroupMemberInfoHeader(member)
}
SectionSpacer()
SectionView {
SectionItemView {
OpenChatButton(openDirectChat)
}
}
SectionSpacer()
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
val conn = member.activeConn
if (conn != null) {
SectionDivider()
val connLevelDesc =
if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
}
}
SectionSpacer()
if (connStats != null) {
val rcvServers = connStats.rcvServers
val sndServers = connStats.sndServers
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
if (rcvServers != null && rcvServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
if (sndServers != null && sndServers.isNotEmpty()) {
SectionDivider()
SimplexServers(stringResource(R.string.sending_via), sndServers)
}
} else if (sndServers != null && sndServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.sending_via), sndServers)
}
}
SectionSpacer()
}
}
if (member.canBeRemoved(groupInfo.membership)) {
SectionView {
SectionItemView {
RemoveMemberButton(removeMember)
}
}
SectionSpacer()
}
if (developerTools) {
SectionView(title = stringResource(R.string.section_title_for_console)) {
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
SectionDivider()
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
}
SectionSpacer()
}
}
}
@Composable
fun GroupMemberInfoHeader(member: GroupMember) {
Column(
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (member.fullName != "" && member.fullName != member.displayName) {
Text(
member.fullName, style = MaterialTheme.typography.h2,
color = MaterialTheme.colors.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
fun RemoveMemberButton(removeMember: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { removeMember() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.button_remove_member),
tint = Color.Red
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_remove_member), color = Color.Red)
}
}
@Composable
fun OpenChatButton(onClick: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { onClick() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Message,
stringResource(R.string.button_send_direct_message),
Modifier.padding(top = 5.dp),
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_send_direct_message), color = MaterialTheme.colors.primary)
}
}
@Preview
@Composable
fun PreviewGroupMemberInfoLayout() {
SimpleXTheme {
GroupMemberInfoLayout(
groupInfo = GroupInfo.sampleData,
member = GroupMember.sampleData,
connStats = null,
developerTools = false,
openDirectChat = {},
removeMember = {}
)
}
}

View File

@@ -0,0 +1,173 @@
package chat.simplex.app.views.chat.group
import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.ProfileNameField
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.isValidDisplayName
import chat.simplex.app.views.usersettings.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@Composable
fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
GroupProfileLayout(
close = close,
groupProfile = groupInfo.groupProfile,
saveProfile = { p ->
withApi {
val gInfo = chatModel.controller.apiUpdateGroup(groupInfo.groupId, p)
if (gInfo != null) {
chatModel.updateGroup(gInfo)
close.invoke()
}
}
}
)
}
@Composable
fun GroupProfileLayout(
close: () -> Unit,
groupProfile: GroupProfile,
saveProfile: (GroupProfile) -> Unit,
) {
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val displayName = remember { mutableStateOf(groupProfile.displayName) }
val fullName = remember { mutableStateOf(groupProfile.fullName) }
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val profileImage = remember { mutableStateOf(groupProfile.image) }
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val focusRequester = remember { FocusRequester() }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetContent = {
GetImageBottomSheet(
chosenImage,
onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
hideBottomSheet = {
scope.launch { bottomSheetModalState.hide() }
})
},
sheetState = bottomSheetModalState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
ModalView(close = close) {
Column(
Modifier
.verticalScroll(scrollState)
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.Start
) {
Text(
stringResource(R.string.group_profile_is_stored_on_members_devices),
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground,
lineHeight = 22.sp
)
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
contentAlignment = Alignment.Center
) {
Box(contentAlignment = Alignment.TopEnd) {
Box(contentAlignment = Alignment.Center) {
ProfileImage(192.dp, profileImage.value)
EditImageButton { scope.launch { bottomSheetModalState.show() } }
}
if (profileImage.value != null) {
DeleteImageButton { profileImage.value = null }
}
}
}
Text(
stringResource(R.string.group_display_name_field),
Modifier.padding(bottom = 3.dp)
)
ProfileNameField(displayName, focusRequester)
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
Text(
errorText,
fontSize = 15.sp,
color = MaterialTheme.colors.error
)
Spacer(Modifier.height(3.dp))
Text(
stringResource(R.string.group_full_name_field),
Modifier.padding(bottom = 5.dp)
)
ProfileNameField(fullName)
Spacer(Modifier.height(16.dp))
Row {
TextButton(stringResource(R.string.cancel_verb)) {
close.invoke()
}
Spacer(Modifier.padding(horizontal = 8.dp))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
if (enabled) {
Text(
stringResource(R.string.save_group_profile),
modifier = Modifier.clickable { saveProfile(GroupProfile(displayName.value, fullName.value, profileImage.value)) },
color = MaterialTheme.colors.primary
)
} else {
Text(
stringResource(R.string.save_group_profile),
color = HighOrLowlight
)
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewGroupProfileLayout() {
SimpleXTheme {
GroupProfileLayout(
close = {},
groupProfile = GroupProfile.sampleData,
saveProfile = { _ -> }
)
}
}

View File

@@ -0,0 +1,157 @@
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.PhoneInTalk
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
@Composable
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
val sent = cItem.chatDir.sent
Column(
Modifier
.padding(horizontal = 4.dp)
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green)
when (status) {
CICallStatus.Pending -> if (sent) {
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
} else {
AcceptCallButton(cInfo, acceptCall)
}
CICallStatus.Missed -> Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
CICallStatus.Accepted -> ConnectingCallIcon()
CICallStatus.Negotiated -> ConnectingCallIcon()
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
CICallStatus.Ended -> Row {
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
Text(status.duration(duration), color = HighOrLowlight)
}
CICallStatus.Error -> {}
}
Text(
cItem.timestampText,
color = HighOrLowlight,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
}
}
@Composable
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
if (cInfo is ChatInfo.Direct) {
SimpleButton(stringResource(R.string.answer_call), Icons.Outlined.RingVolume) { acceptCall(cInfo.contact) }
} else {
Icon(Icons.Outlined.RingVolume, stringResource(R.string.answer_call), tint = HighOrLowlight)
}
// if case let .direct(contact) = chatInfo {
// Button {
// if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
// m.activeCallInvitation = nil
// m.activeCall = Call(
// contact: contact,
// callState: .invitationReceived,
// localMedia: invitation.peerMedia,
// sharedKey: invitation.sharedKey
// )
// m.showCallView = true
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
// } else {
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
// }
// } label: {
// Label("Answer call", systemImage: "phone.arrow.down.left")
// }
// } else {
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
// }
}
//struct CICallItemView: View {
// @EnvironmentObject var m: ChatModel
// var chatInfo: ChatInfo
// var chatItem: ChatItem
// var status: CICallStatus
// var duration: Int
//
// var body: some View {
// switch status {
// case .pending:
// if sent {
// Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
// } else {
// acceptCallButton()
// }
// case .missed: missedCallIcon(sent).foregroundColor(.red)
// case .rejected: Image(systemName: "phone.down").foregroundColor(.secondary)
// case .accepted: connectingCallIcon()
// case .negotiated: connectingCallIcon()
// case .progress: Image(systemName: "phone.and.waveform.fill").foregroundColor(.green)
// case .ended: endedCallIcon(sent)
// case .error: missedCallIcon(sent).foregroundColor(.orange)
// }
//
// chatItem.timestampText
// .font(.caption)
// .foregroundColor(.secondary)
// .padding(.bottom, 8)
// .padding(.horizontal, 12)
// }
// }
//
// private func missedCallIcon(_ sent: Bool) -> some View {
// Image(systemName: sent ? "phone.arrow.up.right" : "phone.arrow.down.left")
// }
//
// private func connectingCallIcon() -> some View {
// Image(systemName: "phone.connection").foregroundColor(.green)
// }
//
// @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
// HStack {
// Image(systemName: "phone.down")
// Text(CICallStatus.durationText(duration)).foregroundColor(.secondary)
// }
// }
//
//
// @ViewBuilder private func acceptCallButton() -> some View {
// if case let .direct(contact) = chatInfo {
// Button {
// if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
// m.activeCallInvitation = nil
// m.activeCall = Call(
// contact: contact,
// callState: .invitationReceived,
// localMedia: invitation.peerMedia,
// sharedKey: invitation.sharedKey
// )
// m.showCallView = true
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
// } else {
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
// }
// } label: {
// Label("Answer call", systemImage: "phone.arrow.down.left")
// }
// } else {
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
// }
// }
//}

View File

@@ -0,0 +1,210 @@
package chat.simplex.app.views.chat.item
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
@Composable
fun CIFileView(
file: CIFile?,
edited: Boolean,
receiveFile: (Long) -> Unit
) {
val context = LocalContext.current
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = file)
@Composable
fun fileIcon(
innerIcon: ImageVector? = null,
color: Color = if (isInDarkTheme()) FileDark else FileLight
) {
Box(
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.InsertDriveFile,
stringResource(R.string.icon_descr_file),
Modifier.fillMaxSize(),
tint = color
)
if (innerIcon != null) {
Icon(
innerIcon,
stringResource(R.string.icon_descr_file),
Modifier
.size(32.dp)
.padding(top = 12.dp),
tint = Color.White
)
}
}
}
fun fileSizeValid(): Boolean {
if (file != null) {
return file.fileSize <= MAX_FILE_SIZE
}
return false
}
fun fileAction() {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation -> {
if (fileSizeValid()) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
)
}
}
CIFileStatus.RcvAccepted ->
AlertManager.shared.showAlertMsg(
generalGetString(R.string.waiting_for_file),
String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
)
CIFileStatus.RcvComplete -> {
val filePath = getLoadedFilePath(context, file)
if (filePath != null) {
saveFileLauncher.launch(file.fileName)
} else {
Toast.makeText(context, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
}
}
else -> {}
}
}
}
@Composable
fun progressIndicator() {
CircularProgressIndicator(
Modifier.size(32.dp),
color = if (isInDarkTheme()) FileDark else FileLight,
strokeWidth = 4.dp
)
}
@Composable
fun fileIndicator() {
Box(
Modifier
.size(42.dp)
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = { fileAction() }),
contentAlignment = Alignment.Center
) {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.SndStored -> fileIcon()
CIFileStatus.SndTransfer -> progressIndicator()
CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
CIFileStatus.RcvInvitation ->
if (fileSizeValid())
fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary)
else
fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
CIFileStatus.RcvTransfer -> progressIndicator()
CIFileStatus.RcvComplete -> fileIcon()
CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
}
} else {
fileIcon()
}
}
}
Row(
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
fileIndicator()
val metaReserve = if (edited)
" "
else
" "
if (file != null) {
Column(
horizontalAlignment = Alignment.Start
) {
Text(
file.fileName,
maxLines = 1
)
Text(
formatBytes(file.fileSize) + metaReserve,
color = HighOrLowlight,
fontSize = 14.sp,
maxLines = 1
)
}
} else {
Text(metaReserve)
}
}
}
class ChatItemProvider: PreviewParameterProvider<ChatItem> {
private val sentFile = ChatItem(
chatDir = CIDirection.DirectSnd(),
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
quotedItem = null,
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
)
private val fileChatItemWtFile = ChatItem(
chatDir = CIDirection.DirectRcv(),
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
quotedItem = null,
file = null
)
override val values = listOf(
sentFile,
ChatItem.getFileMsgContentSample(),
ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
ChatItem.getFileMsgContentSample(text = "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.", fileStatus = CIFileStatus.RcvInvitation),
fileChatItemWtFile
).asSequence()
}
@Preview
@Composable
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(ChatInfo.Direct.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
}
}

View File

@@ -0,0 +1,59 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun CIGroupEventView(ci: ChatItem) {
fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
}
Surface {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
buildAnnotatedString {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
withGroupEventStyle(this, memberDisplayName)
append(" ")
}
withGroupEventStyle(this, ci.content.text)
append(" ")
withGroupEventStyle(this, ci.timestampText)
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun CIGroupEventViewPreview() {
SimpleXTheme {
CIGroupEventView(
ChatItem.getGroupEventSample()
)
}
}

View File

@@ -0,0 +1,167 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun CIGroupInvitationView(
ci: ChatItem,
groupInvitation: CIGroupInvitation,
memberRole: GroupMemberRole,
chatIncognito: Boolean = false,
joinGroup: (Long) -> Unit
) {
val sent = ci.chatDir.sent
val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending
@Composable
fun groupInfoView() {
val p = groupInvitation.groupProfile
val iconColor =
if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary
else if (isInDarkTheme()) FileDark else FileLight
Row(
Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(vertical = 4.dp)
.padding(end = 2.dp)
) {
ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = Icons.Filled.SupervisedUserCircle, color = iconColor)
Spacer(Modifier.padding(horizontal = 3.dp))
Column(
Modifier.defaultMinSize(minHeight = 60.dp),
verticalArrangement = Arrangement.Center
) {
Text(p.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (p.fullName != "" && p.displayName != p.fullName) {
Text(p.fullName, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
@Composable
fun groupInvitationText() {
when {
sent -> Text(stringResource(R.string.you_sent_group_invitation))
!sent && groupInvitation.status == CIGroupInvitationStatus.Pending -> Text(stringResource(R.string.you_are_invited_to_group))
!sent && groupInvitation.status == CIGroupInvitationStatus.Accepted -> Text(stringResource(R.string.you_joined_this_group))
!sent && groupInvitation.status == CIGroupInvitationStatus.Rejected -> Text(stringResource(R.string.you_rejected_group_invitation))
!sent && groupInvitation.status == CIGroupInvitationStatus.Expired -> Text(stringResource(R.string.group_invitation_expired))
}
}
Surface(
modifier = if (action) Modifier.clickable(onClick = {
joinGroup(groupInvitation.groupId)
}) else Modifier,
shape = RoundedCornerShape(18.dp),
color = if (sent) SentColorLight else ReceivedColorLight,
) {
Box(
Modifier
.width(IntrinsicSize.Min)
.padding(vertical = 3.dp)
.padding(start = 8.dp, end = 12.dp),
contentAlignment = Alignment.BottomEnd
) {
Column(
Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 4.dp),
horizontalAlignment = Alignment.Start
) {
groupInfoView()
Column(Modifier.padding(top = 2.dp, start = 5.dp)) {
Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp))
if (action) {
groupInvitationText()
Text(stringResource(
if (chatIncognito) R.string.group_invitation_tap_to_join_incognito else R.string.group_invitation_tap_to_join),
color = if (chatIncognito) Indigo else MaterialTheme.colors.primary)
} else {
Box(Modifier.padding(end = 48.dp)) {
groupInvitationText()
}
}
}
}
Text(
ci.timestampText,
color = HighOrLowlight,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun PendingCIGroupInvitationViewPreview() {
SimpleXTheme {
CIGroupInvitationView(
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun CIGroupInvitationViewAcceptedPreview() {
SimpleXTheme {
CIGroupInvitationView(
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun CIGroupInvitationViewLongNamePreview() {
SimpleXTheme {
CIGroupInvitationView(
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(
groupProfile = GroupProfile("group_with_a_really_really_really_long_name", "Group With A Really Really Really Long Name"),
status = CIGroupInvitationStatus.Accepted
),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
)
}
}

View File

@@ -0,0 +1,188 @@
import android.graphics.Bitmap
import android.os.Build.VERSION.SDK_INT
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.outlined.ArrowDownward
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import chat.simplex.app.BuildConfig
import chat.simplex.app.R
import chat.simplex.app.model.CIFile
import chat.simplex.app.model.CIFileStatus
import chat.simplex.app.views.helpers.*
import coil.ImageLoader
import coil.compose.rememberAsyncImagePainter
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import java.io.File
@Composable
fun CIImageView(
image: String,
file: CIFile?,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
) {
@Composable
fun loadingIndicator() {
if (file != null) {
Box(
Modifier
.padding(8.dp)
.size(20.dp),
contentAlignment = Alignment.Center
) {
when (file.fileStatus) {
CIFileStatus.SndTransfer ->
CircularProgressIndicator(
Modifier.size(16.dp),
color = Color.White,
strokeWidth = 2.dp
)
CIFileStatus.SndComplete ->
Icon(
Icons.Filled.Check,
stringResource(R.string.icon_descr_image_snd_complete),
Modifier.fillMaxSize(),
tint = Color.White
)
CIFileStatus.RcvAccepted ->
Icon(
Icons.Outlined.MoreHoriz,
stringResource(R.string.icon_descr_waiting_for_image),
Modifier.fillMaxSize(),
tint = Color.White
)
CIFileStatus.RcvTransfer ->
CircularProgressIndicator(
Modifier.size(16.dp),
color = Color.White,
strokeWidth = 2.dp
)
CIFileStatus.RcvInvitation ->
Icon(
Icons.Outlined.ArrowDownward,
stringResource(R.string.icon_descr_asked_to_receive),
Modifier.fillMaxSize(),
tint = Color.White
)
else -> {}
}
}
}
}
@Composable
fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
Image(
imageBitmap.asImageBitmap(),
contentDescription = stringResource(R.string.image_descr),
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
// if text is short and take all available width if text is long
modifier = Modifier
.width(1000.dp)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
),
contentScale = ContentScale.FillWidth,
)
}
@Composable
fun imageView(painter: Painter, onClick: () -> Unit) {
Image(
painter,
contentDescription = stringResource(R.string.image_descr),
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
// if text is short and take all available width if text is long
modifier = Modifier
.width(1000.dp)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
),
contentScale = ContentScale.FillWidth,
)
}
fun fileSizeValid(): Boolean {
if (file != null) {
return file.fileSize <= MAX_FILE_SIZE
}
return false
}
Box(contentAlignment = Alignment.TopEnd) {
val context = LocalContext.current
val imageBitmap: Bitmap? = getLoadedImage(context, file)
val filePath = getLoadedFilePath(context, file)
if (imageBitmap != null && filePath != null) {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
val imageLoader = ImageLoader.Builder(context)
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
val imagePainter = rememberAsyncImagePainter(
ImageRequest.Builder(context).data(data = uri).size(coil.size.Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
imageLoader = imageLoader
)
imageView(imagePainter, onClick = {
if (getLoadedFilePath(context, file) != null) {
ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, uri, close) }
}
})
} else {
imageView(base64ToBitmap(image), onClick = {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
if (fileSizeValid()) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
)
}
CIFileStatus.RcvAccepted ->
AlertManager.shared.showAlertMsg(
generalGetString(R.string.waiting_for_image),
generalGetString(R.string.image_will_be_received_when_contact_is_online)
)
CIFileStatus.RcvTransfer -> {} // ?
CIFileStatus.RcvComplete -> {} // ?
CIFileStatus.RcvCancelled -> {} // TODO
else -> {}
}
}
})
}
loadingIndicator()
}
}

View File

@@ -0,0 +1,156 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import kotlinx.datetime.Clock
@Composable
fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!chatItem.isDeletedContent) {
if (chatItem.meta.itemEdited) {
Icon(
Icons.Filled.Edit,
modifier = Modifier.height(12.dp).padding(end = 1.dp),
contentDescription = stringResource(R.string.icon_descr_edited),
tint = metaColor,
)
}
CIStatusView(chatItem.meta.itemStatus, metaColor)
}
Text(
chatItem.timestampText,
color = metaColor,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
}
}
@Composable
fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
when (status) {
is CIStatus.SndSent -> {
Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
}
is CIStatus.SndErrorAuth -> {
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
}
is CIStatus.SndError -> {
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
}
is CIStatus.RcvNew -> {
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
}
else -> {}
}
}
@Preview
@Composable
fun PreviewCIMetaView() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
@Preview
@Composable
fun PreviewCIMetaViewUnread() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.RcvNew()
)
)
}
@Preview
@Composable
fun PreviewCIMetaViewSendFailed() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX()))
)
)
}
@Preview
@Composable
fun PreviewCIMetaViewSendNoAuth() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
)
)
}
@Preview
@Composable
fun PreviewCIMetaViewSendSent() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
)
)
}
@Preview
@Composable
fun PreviewCIMetaViewEdited() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true
)
)
}
@Preview
@Composable
fun PreviewCIMetaViewEditedUnread() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
itemEdited = true,
status=CIStatus.RcvNew()
)
)
}
@Preview
@Composable
fun PreviewCIMetaViewEditedSent() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true,
status=CIStatus.SndSent()
)
)
}
@Preview
@Composable
fun PreviewCIMetaViewDeletedContent() {
CIMetaView(
chatItem = ChatItem.getDeletedContentSampleData()
)
}

View File

@@ -0,0 +1,244 @@
package chat.simplex.app.views.chat.item
import android.content.*
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ComposeContextItem
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
@Composable
fun ChatItemView(
user: User,
cInfo: ChatInfo,
cItem: ChatItem,
composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
showMember: Boolean = false,
chatModelIncognito: Boolean,
useLinkPreviews: Boolean,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit
) {
val context = LocalContext.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
Box(
modifier = Modifier
.padding(bottom = 4.dp)
.fillMaxWidth(),
contentAlignment = alignment,
) {
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
) {
@Composable fun ContentItem() {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
val onLinkLongClick = { _: String -> showMenu.value = true }
FramedItemView(cInfo, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick)
}
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
shareText(cxt, cItem.content.text)
showMenu.value = false
})
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
copyText(cxt, cItem.content.text)
showMenu.value = false
})
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
val filePath = getLoadedFilePath(context, cItem.file)
if (filePath != null) {
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> saveImage(context, cItem.file)
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
else -> {}
}
showMenu.value = false
})
}
}
if (cItem.meta.editable) {
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false
})
}
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, showMember = showMember)
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
}
@Composable fun CallItem(status: CICallStatus, duration: Int) {
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
when (val c = cItem.content) {
is CIContent.SndMsgContent -> ContentItem()
is CIContent.RcvMsgContent -> ContentItem()
is CIContent.SndDeleted -> DeletedItem()
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.RcvGroupEventContent -> CIGroupEventView(cItem)
is CIContent.SndGroupEventContent -> CIGroupEventView(cItem)
}
}
}
}
@Composable
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
DropdownMenuItem(onClick) {
Row {
Text(
text,
modifier = Modifier
.fillMaxWidth()
.weight(1F)
.padding(end = 15.dp),
color = color
)
Icon(icon, text, tint = color)
}
}
}
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(R.string.delete_message__question),
text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
buttons = {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
AlertManager.shared.hideAlert()
}) { Text(stringResource(R.string.for_me_only)) }
if (chatItem.meta.editable) {
Spacer(Modifier.padding(horizontal = 4.dp))
TextButton(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
AlertManager.shared.hideAlert()
}) { Text(stringResource(R.string.for_everybody)) }
}
}
}
)
}
@Preview
@Composable
fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
acceptCall = { _ -> }
)
}
}
@Preview
@Composable
fun PreviewChatItemViewDeletedContent() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(),
useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
acceptCall = { _ -> }
)
}
}

View File

@@ -0,0 +1,55 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.ChatItem
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()
)
}
}

View File

@@ -0,0 +1,49 @@
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)
}
// https://stackoverflow.com/a/46279500
private const val emojiStr = "^(" +
"(?:[\\u2700-\\u27bf]|" +
"(?:[\\ud83c\\udde6-\\ud83c\\uddff]){2}|" +
"[\\ud800\\udc00-\\uDBFF\\uDFFF]|[\\u2600-\\u26FF])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0]|[\\ud83c\\udffb-\\ud83c\\udfff])?" +
"(?:\\u200d(?:[^\\ud800-\\udfff]|" +
"(?:[\\ud83c\\udde6-\\ud83c\\uddff]){2}|" +
"[\\ud800\\udc00-\\uDBFF\\uDFFF]|[\\u2600-\\u26FF])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0]|[\\ud83c\\udffb-\\ud83c\\udfff])?)*|" +
"[\\u0023-\\u0039]\\ufe0f?\\u20e3|\\u3299|\\u3297|\\u303d|\\u3030|\\u24c2|[\\ud83c\\udd70-\\ud83c\\udd71]|[\\ud83c\\udd7e-\\ud83c\\udd7f]|\\ud83c\\udd8e|[\\ud83c\\udd91-\\ud83c\\udd9a]|[\\ud83c\\udde6-\\ud83c\\uddff]|[\\ud83c\\ude01-\\ud83c\\ude02]|\\ud83c\\ude1a|\\ud83c\\ude2f|[\\ud83c\\ude32-\\ud83c\\ude3a]|[\\ud83c\\ude50-\\ud83c\\ude51]|\\u203c|\\u2049|[\\u25aa-\\u25ab]|\\u25b6|\\u25c0|[\\u25fb-\\u25fe]|\\u00a9|\\u00ae|\\u2122|\\u2139|\\ud83c\\udc04|[\\u2600-\\u26FF]|\\u2b05|\\u2b06|\\u2b07|\\u2b1b|\\u2b1c|\\u2b50|\\u2b55|\\u231a|\\u231b|\\u2328|\\u23cf|[\\u23e9-\\u23f3]|[\\u23f8-\\u23fa]|\\ud83c\\udccf|\\u2934|\\u2935|[\\u2190-\\u21ff]" +
")+$" // Multiple matches with emojis where one follows another without interruptions from other characters
private val emojiRegex = Regex(emojiStr)
fun isShortEmoji(str: String): Boolean {
val s = str.trim()
return s.codePoints().count() in 1..5 && emojiRegex.matches(str)
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,80 @@
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import coil.ImageLoader
import coil.compose.rememberAsyncImagePainter
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import coil.size.Size
@Composable
fun ImageFullScreenView(imageBitmap: Bitmap, uri: Uri, close: () -> Unit) {
BackHandler(onBack = close)
Column(
Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = close)
) {
var scale by remember { mutableStateOf(1f) }
var translationX by remember { mutableStateOf(0f) }
var translationY by remember { mutableStateOf(0f) }
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
val imageLoader = ImageLoader.Builder(LocalContext.current)
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
Image(
rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
imageLoader = imageLoader
),
contentDescription = stringResource(R.string.image_descr),
contentScale = ContentScale.Fit,
modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = translationX,
translationY = translationY,
)
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, gestureZoom, _ ->
scale = (scale * gestureZoom).coerceIn(1f, 20f)
if (scale > 1) {
translationX += pan.x * scale
translationY += pan.y * scale
} else {
translationX = 0f
translationY = 0f
}
}
)
}
.fillMaxSize(),
)
}
}

View File

@@ -0,0 +1,65 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
Surface(
Modifier.clickable(onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.alert_title_skipped_messages),
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
)
}),
shape = RoundedCornerShape(18.dp),
color = ReceivedColorLight,
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
buildAnnotatedString {
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun IntegrityErrorItemViewPreview() {
SimpleXTheme {
IntegrityErrorItemView(
ChatItem.getDeletedContentSampleData()
)
}
}

View File

@@ -0,0 +1,150 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.text.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.detectGesture
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
if (chatItem.chatDir is CIDirection.GroupRcv) {
val name = chatItem.chatDir.groupMember.memberProfile.displayName
if (groupMemberBold) b.withStyle(boldFont) { append(name) }
else b.append(name)
b.append(": ")
}
}
fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) {
if (sender != null) {
if (senderBold) b.withStyle(boldFont) { append(sender) }
else b.append(sender)
b.append(": ")
}
}
@Composable
fun MarkdownText (
text: String,
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,
onLinkLongClick: (link: String) -> Unit = {}
) {
val reserve = if (edited) " " else " "
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
append(text)
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
} else {
var hasLinks = false
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
for (ft in formattedText) {
if (ft.format == null) append(ft.text)
else {
val link = ft.link
if (link != null) {
hasLinks = true
val ftStyle = ft.format.style
withAnnotation(tag = "URL", annotation = link) {
withStyle(ftStyle) { append(ft.text) }
}
} 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,
onLongClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
},
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
}
)
} else {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
}
}
@Composable
fun ClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
onClick: (Int) -> Unit,
onLongClick: (Int) -> Unit = {},
shouldConsumeEvent: (Int) -> Boolean
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick, onLongClick) {
detectGesture(onLongPress = { pos ->
layoutResult.value?.let { layoutResult ->
onLongClick(layoutResult.getOffsetForPosition(pos))
}
}, onPress = { pos ->
layoutResult.value?.let { layoutResult ->
val res = tryAwaitRelease()
if (res) {
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}, shouldConsumeEvent = { pos ->
var consume = false
layoutResult.value?.let { layoutResult ->
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
}
consume
}
)
}
BasicText(
text = text,
modifier = modifier.then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onTextLayout = {
layoutResult.value = it
onTextLayout(it)
}
)
}

View File

@@ -0,0 +1,93 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.annotatedStringResource
import chat.simplex.app.views.usersettings.simplexTeamUri
val bold = SpanStyle(fontWeight = FontWeight.Bold)
@Composable
fun ChatHelpView(addContact: (() -> Unit)? = null) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
val uriHandler = LocalUriHandler.current
Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp)
Text(
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
modifier = Modifier.clickable(onClick = {
uriHandler.openUri(simplexTeamUri)
}),
lineHeight = 22.sp
)
Column(
Modifier.padding(top = 24.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
stringResource(R.string.to_start_a_new_chat_help_header),
style = MaterialTheme.typography.h2,
lineHeight = 22.sp
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(stringResource(R.string.chat_help_tap_button))
Icon(
Icons.Outlined.PersonAdd,
stringResource(R.string.add_contact),
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
)
Text(stringResource(R.string.above_then_preposition_continuation))
}
Text(annotatedStringResource(R.string.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp)
Text(annotatedStringResource(R.string.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp)
}
Column(
Modifier.padding(top = 24.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
Text(stringResource(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp)
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatHelpLayout() {
SimpleXTheme {
ChatHelpView {}
}
}

View File

@@ -0,0 +1,586 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chat.group.deleteGroupDialog
import chat.simplex.app.views.chat.group.leaveGroupDialog
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
@Composable
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
val showMenu = remember { mutableStateOf(false) }
var showMarkRead by remember { mutableStateOf(false) }
val stopped = chatModel.chatRunning.value == false
LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) {
showMenu.value = false
delay(500L)
showMarkRead = chat.chatStats.unreadCount > 0
}
when (chat.chatInfo) {
is ChatInfo.Direct ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
click = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
stopped
)
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
showMenu,
stopped
)
is ChatInfo.ContactRequest ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactRequestView(chatModel.incognito.value, chat.chatInfo) },
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu,
stopped
)
is ChatInfo.ContactConnection ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) },
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu,
stopped
)
}
}
fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) {
if (chatInfo.ready) {
withApi { openChat(chatInfo, chatModel) }
} else {
pendingContactAlertDialog(chatInfo, chatModel)
}
}
fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel)
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert()
else -> withApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
}
}
suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
if (chat != null) {
chatModel.chatItems.clear()
chatModel.chatItems.addAll(chat.chatItems)
chatModel.chatId.value = chatInfo.id
}
}
suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) {
val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT)
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
chatModel.chatItems.addAll(0, chat.chatItems)
}
suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) {
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return
chatModel.chatItems.clear()
chatModel.chatItems.addAll(0, chat.chatItems)
}
suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId)
chatModel.groupMembers.clear()
chatModel.groupMembers.addAll(groupMembers)
}
@Composable
fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
}
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
ClearChatAction(chat, chatModel, showMenu)
DeleteContactAction(chat, chatModel, showMenu)
}
@Composable
fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> {
JoinGroupAction(chat, groupInfo, chatModel, showMenu)
if (groupInfo.canDelete) {
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
}
}
else -> {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
}
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
ClearChatAction(chat, chatModel, showMenu)
if (groupInfo.membership.memberCurrent) {
LeaveGroupAction(groupInfo, chatModel, showMenu)
}
if (groupInfo.canDelete) {
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
}
}
}
}
@Composable
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.mark_read),
Icons.Outlined.Check,
onClick = {
markChatRead(chat, chatModel)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
showMenu.value = false
}
)
}
@Composable
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
ItemAction(
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
if (ntfsEnabled) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
onClick = {
changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
showMenu.value = false
}
)
}
@Composable
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.clear_verb),
Icons.Outlined.Restore,
onClick = {
clearChatDialog(chat.chatInfo, chatModel)
showMenu.value = false
},
color = WarningOrange
)
}
@Composable
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteContactDialog(chat.chatInfo, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
ItemAction(
if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button),
if (chat.chatInfo.incognito) Icons.Filled.TheaterComedy else Icons.Outlined.Login,
color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground,
onClick = {
joinGroup()
showMenu.value = false
}
)
}
@Composable
fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.leave_group_button),
Icons.Outlined.Logout,
onClick = {
leaveGroupDialog(groupInfo, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button),
if (chatModel.incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.Check,
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
onClick = {
acceptContactRequest(chatInfo, chatModel)
showMenu.value = false
}
)
ItemAction(
stringResource(R.string.reject_contact_button),
Icons.Outlined.Close,
onClick = {
rejectContactRequest(chatInfo, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
fun markChatRead(chat: Chat, chatModel: ChatModel) {
// Just to be sure
if (chat.chatStats.unreadCount == 0) return
val minUnreadItemId = chat.chatStats.minUnreadItemId
chatModel.markChatItemsRead(chat.chatInfo)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(minUnreadItemId, chat.chatItems.last().id)
)
}
}
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.accept_connection_request__question),
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button),
onConfirm = { acceptContactRequest(contactRequest, chatModel) },
dismissText = generalGetString(R.string.reject_contact_button),
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
)
}
fun acceptContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
withApi {
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
if (contact != null) {
val chat = Chat(ChatInfo.Direct(contact), listOf())
chatModel.replaceChat(contactRequest.id, chat)
}
}
}
fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
withApi {
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
chatModel.removeChat(contactRequest.id)
}
}
fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(
if (connection.initiated) R.string.you_invited_your_contact
else R.string.you_accepted_connection
),
text = generalGetString(
if (connection.viaContactUri) R.string.you_will_be_connected_when_your_connection_request_is_accepted
else R.string.you_will_be_connected_when_your_contacts_device_is_online
),
buttons = {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = {
AlertManager.shared.hideAlert()
deleteContactConnectionAlert(connection, chatModel)
}) {
Text(stringResource(R.string.delete_verb))
}
Spacer(Modifier.padding(horizontal = 4.dp))
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
Text(stringResource(R.string.ok))
}
}
}
)
}
fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.delete_pending_connection__question),
text = generalGetString(
if (connection.initiated) R.string.contact_you_shared_link_with_wont_be_able_to_connect
else R.string.connection_you_accepted_will_be_cancelled
),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
AlertManager.shared.hideAlert()
if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) {
chatModel.removeChat(connection.id)
}
}
}
)
}
fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.alert_title_contact_connection_pending),
text = generalGetString(R.string.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry),
confirmText = generalGetString(R.string.button_delete_contact),
onConfirm = {
withApi {
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
}
}
},
dismissText = generalGetString(R.string.cancel_verb),
)
}
fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.join_group_question),
text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members),
confirmText = if (groupInfo.membership.memberIncognito) generalGetString(R.string.join_group_incognito_button) else generalGetString(R.string.join_group_button),
onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } },
dismissText = generalGetString(R.string.delete_verb),
onDismiss = { deleteGroup(groupInfo, chatModel) }
)
}
fun cantInviteIncognitoAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.alert_title_cant_invite_contacts),
text = generalGetString(R.string.alert_title_cant_invite_contacts_descr),
confirmText = generalGetString(R.string.ok),
)
}
fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
withApi {
val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId)
if (r) {
chatModel.removeChat(groupInfo.id)
chatModel.chatId.value = null
chatModel.controller.ntfManager.cancelNotificationsForChat(groupInfo.id)
}
}
}
fun groupInvitationAcceptedAlert() {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.joining_group),
generalGetString(R.string.youve_accepted_group_invitation_connecting_to_inviting_group_member)
)
}
fun changeNtfsStatePerChat(enabled: Boolean, currentState: MutableState<Boolean>, chat: Chat, chatModel: ChatModel) {
val newChatInfo = when(chat.chatInfo) {
is ChatInfo.Direct -> with (chat.chatInfo) {
ChatInfo.Direct(contact.copy(chatSettings = contact.chatSettings.copy(enableNtfs = enabled)))
}
is ChatInfo.Group -> with(chat.chatInfo) {
ChatInfo.Group(groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(enableNtfs = enabled)))
}
else -> null
}
withApi {
val res = when (newChatInfo) {
is ChatInfo.Direct -> with(newChatInfo) {
chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings)
}
is ChatInfo.Group -> with(newChatInfo) {
chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings)
}
else -> false
}
if (res && newChatInfo != null) {
chatModel.updateChatInfo(newChatInfo)
if (!enabled) {
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
}
currentState.value = enabled
}
}
}
@Composable
fun ChatListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
click: () -> Unit,
dropdownMenuItems: (@Composable () -> Unit)?,
showMenu: MutableState<Boolean>,
stopped: Boolean
) {
var modifier = Modifier.fillMaxWidth().height(88.dp)
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
Surface(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.padding(start = 8.dp)
.padding(end = 12.dp),
verticalAlignment = Alignment.Top
) {
chatLinkPreview()
}
if (dropdownMenuItems != null) {
Box(Modifier.padding(horizontal = 16.dp)) {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
dropdownMenuItems()
}
}
}
}
Divider(Modifier.padding(horizontal = 8.dp))
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatListNavLinkDirect() {
SimpleXTheme {
ChatListNavLinkLayout(
chatLinkPreview = {
ChatPreviewView(
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()
),
false,
null,
stopped = false
)
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) },
stopped = false
)
}
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatListNavLinkGroup() {
SimpleXTheme {
ChatListNavLinkLayout(
chatLinkPreview = {
ChatPreviewView(
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()
),
false,
null,
stopped = false
)
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) },
stopped = false
)
}
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {
ChatListNavLinkLayout(
chatLinkPreview = {
ContactRequestView(false, ChatInfo.ContactRequest.sampleData)
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) },
stopped = false
)
}
}

View File

@@ -0,0 +1,187 @@
package chat.simplex.app.views.chatlist
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.Indigo
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.onboarding.MakeConnection
import chat.simplex.app.views.usersettings.SettingsView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ScaffoldController(val scope: CoroutineScope) {
lateinit var state: BottomSheetScaffoldState
val expanded = mutableStateOf(false)
fun expand() {
expanded.value = true
scope.launch { state.bottomSheetState.expand() }
}
fun collapse() {
expanded.value = false
scope.launch { state.bottomSheetState.collapse() }
}
fun toggleSheet() {
if (state.bottomSheetState.isExpanded) collapse() else expand()
}
fun toggleDrawer() = scope.launch {
state.drawerState.apply { if (isClosed) open() else close() }
}
}
@Composable
fun scaffoldController(): ScaffoldController {
val ctrl = ScaffoldController(scope = rememberCoroutineScope())
val bottomSheetState = rememberBottomSheetState(
BottomSheetValue.Collapsed,
confirmStateChange = {
ctrl.expanded.value = it == BottomSheetValue.Expanded
true
}
)
ctrl.state = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
return ctrl
}
@Composable
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val scaffoldCtrl = scaffoldController()
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
}
var searchInList by rememberSaveable { mutableStateOf("") }
BottomSheetScaffold(
topBar = { ChatListToolbar(chatModel, scaffoldCtrl, stopped) { searchInList = it.trim() } },
scaffoldState = scaffoldCtrl.state,
drawerContent = { SettingsView(chatModel, setPerformLA) },
sheetPeekHeight = 0.dp,
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
) {
Box {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel, search = searchInList)
} else {
MakeConnection(chatModel)
}
}
if (scaffoldCtrl.expanded.value) {
Surface(
Modifier
.fillMaxSize()
.clickable { scaffoldCtrl.collapse() },
color = Color.Black.copy(alpha = 0.12F)
) {}
}
}
}
}
@Composable
fun ChatListToolbar(chatModel: ChatModel, scaffoldCtrl: ScaffoldController, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
var showSearch by rememberSaveable { mutableStateOf(false) }
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
if (showSearch) {
BackHandler(onBack = hideSearchOnBack)
}
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (chatModel.chats.size >= 8) {
barButtons.add {
IconButton({ showSearch = true }) {
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
}
}
}
if (!stopped) {
barButtons.add {
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.AddCircle,
stringResource(R.string.add_contact),
tint = MaterialTheme.colors.primary,
)
}
}
} else {
barButtons.add {
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication),
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
Icon(
Icons.Filled.Report,
generalGetString(R.string.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
DefaultTopAppBar(
navigationButton = { if (showSearch) NavigationButtonBack(hideSearchOnBack) else NavigationButtonMenu { scaffoldCtrl.toggleDrawer() } },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(R.string.your_chats),
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
if (chatModel.incognito.value) {
Icon(
Icons.Filled.TheaterComedy,
stringResource(R.string.incognito),
tint = Indigo,
modifier = Modifier.padding(10.dp).size(26.dp)
)
}
}
},
onTitleClick = null,
showSearch = showSearch,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
)
Divider()
}
@Composable
fun ChatList(chatModel: ChatModel, search: String) {
val filter: (Chat) -> Boolean = { chat: Chat ->
chat.chatInfo.chatViewName.lowercase().contains(search.lowercase())
}
val chats by remember(search) { derivedStateOf { if (search.isEmpty()) chatModel.chats else chatModel.chats.filter(filter) } }
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(chats) { chat ->
ChatListNavLinkView(chat, chatModel)
}
}
}

View File

@@ -0,0 +1,230 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.*
@Composable
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) {
val cInfo = chat.chatInfo
@Composable
fun groupInactiveIcon() {
Icon(
Icons.Filled.Cancel,
stringResource(R.string.icon_descr_group_inactive),
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
tint = HighOrLowlight
)
}
@Composable
fun chatPreviewImageOverlayIcon() {
if (cInfo is ChatInfo.Group) {
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemLeft -> groupInactiveIcon()
GroupMemberStatus.MemRemoved -> groupInactiveIcon()
GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon()
else -> {}
}
}
}
@Composable
fun chatPreviewTitleText(color: Color = Color.Unspecified) {
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = color
)
}
@Composable
fun chatPreviewTitle() {
when (cInfo) {
is ChatInfo.Direct ->
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight)
else -> chatPreviewTitleText()
}
else -> chatPreviewTitleText()
}
}
@Composable
fun chatPreviewText(chatModelIncognito: Boolean) {
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
MarkdownText(
ci.text, ci.formattedText, ci.memberDisplayName,
metaText = ci.timestampText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
)
} else {
when (cInfo) {
is ChatInfo.Direct ->
if (!cInfo.ready) {
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
}
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight)
else -> {}
}
else -> {}
}
}
}
Row {
Box(contentAlignment = Alignment.BottomEnd) {
ChatInfoImage(cInfo, size = 72.dp)
Box(Modifier.padding(end = 6.dp, bottom = 6.dp)) {
chatPreviewImageOverlayIcon()
}
}
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
chatPreviewTitle()
chatPreviewText(chatModelIncognito)
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
Box(
contentAlignment = Alignment.TopEnd
) {
Text(
ts,
color = HighOrLowlight,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 5.dp)
)
val n = chat.chatStats.unreadCount
val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group)
if (n > 0) {
Box(
Modifier.padding(top = 24.dp),
contentAlignment = Alignment.Center
) {
Text(
unreadCountStr(n),
color = MaterialTheme.colors.onPrimary,
fontSize = 11.sp,
modifier = Modifier
.background(if (stopped || showNtfsIcon) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
)
}
} else if (showNtfsIcon) {
Box(
Modifier.padding(top = 24.dp),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.NotificationsOff,
contentDescription = generalGetString(R.string.notifications),
tint = HighOrLowlight,
modifier = Modifier
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
.size(17.dp)
)
}
}
if (cInfo is ChatInfo.Direct) {
Box(
Modifier.padding(top = 52.dp),
contentAlignment = Alignment.Center
) {
ChatStatusImage(chat)
}
}
}
}
}
@Composable
private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
return if (groupInfo.membership.memberIncognito)
String.format(stringResource(R.string.group_preview_join_as), groupInfo.membership.memberProfile.displayName)
else if (chatModelIncognito)
String.format(stringResource(R.string.group_preview_join_as), currentUserProfileDisplayName ?: "")
else
stringResource(R.string.group_preview_you_are_invited)
}
@Composable
fun unreadCountStr(n: Int): String {
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation)
}
@Composable
fun ChatStatusImage(chat: Chat) {
val s = chat.serverInfo.networkStatus
val descr = s.statusString
if (s is Chat.NetworkStatus.Error) {
Icon(
Icons.Outlined.ErrorOutline,
contentDescription = descr,
tint = HighOrLowlight,
modifier = Modifier
.size(19.dp)
)
} else if (s !is Chat.NetworkStatus.Connected) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(15.dp),
color = HighOrLowlight,
strokeWidth = 1.5.dp
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
}
}

View File

@@ -0,0 +1,54 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddLink
import androidx.compose.material.icons.outlined.Link
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.unit.dp
import chat.simplex.app.model.PendingContactConnection
import chat.simplex.app.model.getTimestampText
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ProfileImage
@Composable
fun ContactConnectionView(contactConnection: PendingContactConnection) {
Row {
Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
ProfileImage(size = 54.dp, null, if (contactConnection.initiated) Icons.Outlined.AddLink else Icons.Outlined.Link)
}
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
contactConnection.displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = HighOrLowlight
)
Text(contactConnection.description, maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
}
val ts = getTimestampText(contactConnection.updatedAt)
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top
) {
Text(
ts,
color = HighOrLowlight,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 5.dp)
)
}
}
}

View File

@@ -0,0 +1,49 @@
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ChatInfoImage
@Composable
fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) {
Row {
ChatInfoImage(contactRequest, size = 72.dp)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
contactRequest.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = if (chatModelIncognito) Indigo else MaterialTheme.colors.primary
)
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
}
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top
) {
Text(
ts,
color = HighOrLowlight,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 5.dp)
)
}
}
}

View File

@@ -0,0 +1,146 @@
package chat.simplex.app.views.database
import SectionDivider
import SectionTextFooter
import SectionView
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
import kotlinx.datetime.*
import java.io.BufferedOutputStream
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun ChatArchiveView(m: ChatModel, title: String, archiveName: String, archiveTime: Instant) {
val context = LocalContext.current
val archivePath = "${getFilesDirectory(context)}/$archiveName"
val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, archivePath)
ChatArchiveLayout(
title,
archiveTime,
saveArchive = { saveArchiveLauncher.launch(archivePath.substringAfterLast("/")) },
deleteArchiveAlert = { deleteArchiveAlert(m, archivePath) }
)
}
@Composable
fun ChatArchiveLayout(
title: String,
archiveTime: Instant,
saveArchive: () -> Unit,
deleteArchiveAlert: () -> Unit
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
Text(
title,
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView(stringResource(R.string.chat_archive_section)) {
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.save_archive),
saveArchive,
textColor = MaterialTheme.colors.primary
)
SectionDivider()
SettingsActionItem(
Icons.Outlined.Delete,
stringResource(R.string.delete_archive),
deleteArchiveAlert,
textColor = Color.Red
)
}
val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
SectionTextFooter(
String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
)
}
}
@Composable
private fun rememberSaveArchiveLauncher(cxt: Context, chatArchivePath: String): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
try {
destination?.let {
val contentResolver = cxt.contentResolver
contentResolver.openOutputStream(destination)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
val file = File(chatArchivePath)
outputStream.write(file.readBytes())
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
}
}
} catch (e: Error) {
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e")
}
}
)
private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.delete_chat_archive_question),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
val fileDeleted = File(archivePath).delete()
if (fileDeleted) {
m.controller.appPrefs.chatArchiveName.set(null)
m.controller.appPrefs.chatArchiveTime.set(null)
ModalManager.shared.closeModal()
} else {
Log.e(TAG, "deleteArchiveAlert delete() error")
}
}
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatArchiveLayout() {
SimpleXTheme {
ChatArchiveLayout(
title = "New database archive",
archiveTime = Clock.System.now(),
saveArchive = {},
deleteArchiveAlert = {}
)
}
}

View File

@@ -0,0 +1,512 @@
package chat.simplex.app.views.database
import SectionItemView
import SectionItemViewSpaceBetween
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.foundation.text.*
import androidx.compose.material.*
import androidx.compose.material.TextFieldDefaults.indicatorLine
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
import kotlin.math.log2
@Composable
fun DatabaseEncryptionView(m: ChatModel) {
val progressIndicator = remember { mutableStateOf(false) }
val prefs = m.controller.appPrefs
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
val storedKey = remember { val key = DatabaseUtils.getDatabaseKey(); mutableStateOf(key != null && key != "") }
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.getDatabaseKey() ?: "" else "") }
val newKey = rememberSaveable { mutableStateOf("") }
val confirmNewKey = rememberSaveable { mutableStateOf("") }
Box(
Modifier.fillMaxSize(),
) {
DatabaseEncryptionLayout(
useKeychain,
prefs,
m.chatDbEncrypted.value,
currentKey,
newKey,
confirmNewKey,
storedKey,
initialRandomDBPassphrase,
progressIndicator,
onConfirmEncrypt = {
progressIndicator.value = true
withApi {
try {
prefs.encryptionStartedAt.set(Clock.System.now())
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
prefs.encryptionStartedAt.set(null)
val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
when {
sqliteError is SQLiteError.ErrorNotADatabase -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.wrong_passphrase_title),
generalGetString(R.string.enter_correct_current_passphrase)
)
}
}
error != null -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database),
"failed to set storage encryption: ${error.responseType} ${error.details}"
)
}
}
else -> {
prefs.initialRandomDBPassphrase.set(false)
initialRandomDBPassphrase.value = false
if (useKeychain.value) {
DatabaseUtils.setDatabaseKey(newKey.value)
}
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_encrypted))
}
}
}
} catch (e: Exception) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database), e.stackTraceToString())
}
}
}
}
)
if (progressIndicator.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
}
@Composable
fun DatabaseEncryptionLayout(
useKeychain: MutableState<Boolean>,
prefs: AppPreferences,
chatDbEncrypted: Boolean?,
currentKey: MutableState<String>,
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>,
onConfirmEncrypt: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
Text(
stringResource(R.string.database_passphrase),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView(null) {
SavePassphraseSetting(useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, progressIndicator.value) { checked ->
if (checked) {
setUseKeychain(true, useKeychain, prefs)
} else if (storedKey.value) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.remove_passphrase_from_keychain),
text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
confirmText = generalGetString(R.string.remove_passphrase),
onConfirm = {
DatabaseUtils.removeDatabaseKey()
setUseKeychain(false, useKeychain, prefs)
storedKey.value = false
},
destructive = true,
)
} else {
setUseKeychain(false, useKeychain, prefs)
}
}
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
DatabaseKeyField(
currentKey,
generalGetString(R.string.current_passphrase),
modifier = Modifier.padding(start = 8.dp),
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
}
DatabaseKeyField(
newKey,
generalGetString(R.string.new_passphrase),
modifier = Modifier.padding(start = 8.dp),
showStrength = true,
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
val onClickUpdate = {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
if (!progressIndicator.value) {
if (currentKey.value == "") {
if (useKeychain.value)
encryptDatabaseSavedAlert(onConfirmEncrypt)
else
encryptDatabaseAlert(onConfirmEncrypt)
} else {
if (useKeychain.value)
changeDatabaseKeySavedAlert(onConfirmEncrypt)
else
changeDatabaseKeyAlert(onConfirmEncrypt)
}
}
}
val disabled = currentKey.value == newKey.value ||
newKey.value != confirmNewKey.value ||
newKey.value.isEmpty() ||
!validKey(currentKey.value) ||
!validKey(newKey.value) ||
progressIndicator.value
DatabaseKeyField(
confirmNewKey,
generalGetString(R.string.confirm_new_passphrase),
modifier = Modifier.padding(start = 8.dp),
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = {
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
}),
)
SectionItemViewSpaceBetween(onClickUpdate, padding = PaddingValues(start = 8.dp, end = 12.dp), disabled = disabled) {
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
Column {
if (chatDbEncrypted == false) {
SectionTextFooter(generalGetString(R.string.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(R.string.keychain_is_storing_securely))
if (initialRandomDBPassphrase.value) {
SectionTextFooter(generalGetString(R.string.encrypted_with_random_passphrase))
} else {
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
}
} else {
SectionTextFooter(generalGetString(R.string.keychain_allows_to_receive_ntfs))
}
} else {
SectionTextFooter(generalGetString(R.string.you_have_to_enter_passphrase_every_time))
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
}
}
}
}
fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.encrypt_database_question),
text = generalGetString(R.string.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(R.string.encrypt_database),
onConfirm = onConfirm,
destructive = false,
)
}
fun encryptDatabaseAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.encrypt_database_question),
text = generalGetString(R.string.database_will_be_encrypted) +"\n" + storeSecurelyDanger(),
confirmText = generalGetString(R.string.encrypt_database),
onConfirm = onConfirm,
destructive = true,
)
}
fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.change_database_passphrase_question),
text = generalGetString(R.string.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(R.string.update_database),
onConfirm = onConfirm,
destructive = false,
)
}
fun changeDatabaseKeyAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.change_database_passphrase_question),
text = generalGetString(R.string.database_passphrase_will_be_updated) + "\n" + storeSecurelyDanger(),
confirmText = generalGetString(R.string.update_database),
onConfirm = onConfirm,
destructive = true,
)
}
@Composable
fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff,
stringResource(R.string.save_passphrase_in_keychain),
tint = if (storedKey) SimplexGreen else HighOrLowlight
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(R.string.save_passphrase_in_keychain),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
enabled = !initialRandomDBPassphrase && !progressIndicator
)
}
}
}
fun resetFormAfterEncryption(
m: ChatModel,
initialRandomDBPassphrase: MutableState<Boolean>,
currentKey: MutableState<String>,
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
storedKey: MutableState<Boolean>,
stored: Boolean = false,
) {
m.chatDbEncrypted.value = true
initialRandomDBPassphrase.value = false
m.controller.appPrefs.initialRandomDBPassphrase.set(false)
currentKey.value = ""
newKey.value = ""
confirmNewKey.value = ""
storedKey.value = stored
}
fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, prefs: AppPreferences) {
useKeychain.value = value
prefs.storeDBPassphrase.set(value)
}
fun storeSecurelySaved() = generalGetString(R.string.store_passphrase_securely)
fun storeSecurelyDanger() = generalGetString(R.string.store_passphrase_securely_without_recover)
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
m.chatDbChanged.value = true
progressIndicator.value = false
alert.invoke()
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DatabaseKeyField(
key: MutableState<String>,
placeholder: String,
modifier: Modifier = Modifier,
showStrength: Boolean = false,
isValid: (String) -> Boolean,
keyboardActions: KeyboardActions = KeyboardActions(),
) {
var valid by remember { mutableStateOf(validKey(key.value)) }
var showKey by remember { mutableStateOf(false) }
val icon = if (valid) {
if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
} else Icons.Outlined.Error
val iconColor = if (valid) {
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else HighOrLowlight
} else Color.Red
val keyboard = LocalSoftwareKeyboardController.current
val keyboardOptions = KeyboardOptions(
imeAction = if (keyboardActions.onNext != null) ImeAction.Next else ImeAction.Done,
autoCorrect = false,
keyboardType = KeyboardType.Password
)
val state = remember {
mutableStateOf(TextFieldValue(key.value))
}
val enabled = true
val colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Unspecified,
textColor = MaterialTheme.colors.onBackground,
focusedIndicatorColor = Color.Unspecified,
unfocusedIndicatorColor = Color.Unspecified,
)
val color = MaterialTheme.colors.onBackground
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
val interactionSource = remember { MutableInteractionSource() }
BasicTextField(
value = state.value,
modifier = modifier
.fillMaxWidth()
.background(colors.backgroundColor(enabled).value, shape)
.indicatorLine(enabled, false, interactionSource, colors)
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
),
onValueChange = {
state.value = it
key.value = it.text
valid = isValid(it.text)
},
cursorBrush = SolidColor(colors.cursorColor(false).value),
visualTransformation = if (showKey)
VisualTransformation.None
else
VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) },
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(onDone = {
keyboard?.hide()
keyboardActions.onDone?.invoke(this)
}),
singleLine = true,
textStyle = TextStyle.Default.copy(
color = color,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
interactionSource = interactionSource,
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = state.value.text,
innerTextField = innerTextField,
placeholder = { Text(placeholder, color = HighOrLowlight) },
singleLine = true,
enabled = enabled,
isError = !valid,
trailingIcon = {
IconButton({ showKey = !showKey }) {
Icon(icon, null, tint = iconColor)
}
},
interactionSource = interactionSource,
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
visualTransformation = VisualTransformation.None,
colors = colors
)
}
)
}
// based on https://generatepasswords.org/how-to-calculate-entropy/
private fun passphraseEntropy(s: String): Double {
var hasDigits = false
var hasUppercase = false
var hasLowercase = false
var hasSymbols = false
for (c in s) {
if (c.isDigit()) {
hasDigits = true
} else if (c.isLetter()) {
if (c.isUpperCase()) {
hasUppercase = true
} else {
hasLowercase = true
}
} else if (c.isASCII()) {
hasSymbols = true
}
}
val poolSize = (if (hasDigits) 10 else 0) + (if (hasUppercase) 26 else 0) + (if (hasLowercase) 26 else 0) + (if (hasSymbols) 32 else 0)
return s.length * log2(poolSize.toDouble())
}
private enum class PassphraseStrength(val color: Color) {
VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
companion object {
fun check(s: String) = with(passphraseEntropy(s)) {
when {
this > 100 -> STRONG
this > 70 -> REASONABLE
this > 40 -> WEAK
else -> VERY_WEAK
}
}
}
}
fun validKey(s: String): Boolean {
for (c in s) {
if (c.isWhitespace() || !c.isASCII()) {
return false
}
}
return true
}
private fun Char.isASCII() = code in 32..126
@Preview
@Composable
fun PreviewDatabaseEncryptionLayout() {
SimpleXTheme {
DatabaseEncryptionLayout(
useKeychain = remember { mutableStateOf(true) },
prefs = AppPreferences(SimplexApp.context),
chatDbEncrypted = true,
currentKey = remember { mutableStateOf("") },
newKey = remember { mutableStateOf("") },
confirmNewKey = remember { mutableStateOf("") },
storedKey = remember { mutableStateOf(true) },
initialRandomDBPassphrase = remember { mutableStateOf(true) },
progressIndicator = remember { mutableStateOf(false) },
onConfirmEncrypt = {},
)
}
}

View File

@@ -0,0 +1,254 @@
package chat.simplex.app.views.database
import SectionSpacer
import SectionView
import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import kotlin.io.path.Path
@Composable
fun DatabaseErrorView(
chatDbStatus: State<DBMigrationResult?>,
appPreferences: AppPreferences,
) {
val progressIndicator = remember { mutableStateOf(false) }
val dbKey = remember { mutableStateOf("") }
var storedDBKey by remember { mutableStateOf(DatabaseUtils.getDatabaseKey()) }
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
val context = LocalContext.current
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
val saveAndRunChatOnClick: () -> Unit = {
DatabaseUtils.setDatabaseKey(dbKey.value)
storedDBKey = dbKey.value
appPreferences.storeDBPassphrase.set(true)
useKeychain = true
appPreferences.initialRandomDBPassphrase.set(false)
runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
}
val title = when (chatDbStatus.value) {
is DBMigrationResult.OK -> ""
is DBMigrationResult.ErrorNotADatabase -> if (useKeychain && !storedDBKey.isNullOrEmpty())
generalGetString(R.string.wrong_passphrase)
else
generalGetString(R.string.encrypted_database)
is DBMigrationResult.Error -> generalGetString(R.string.database_error)
is DBMigrationResult.ErrorKeychain -> generalGetString(R.string.keychain_error)
is DBMigrationResult.Unknown -> generalGetString(R.string.database_error)
null -> "" // should never be here
}
Column(
Modifier.fillMaxWidth().fillMaxHeight().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Center,
) {
Text(
title,
Modifier.padding(start = 16.dp, top = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView(null) {
Column(
Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
) {
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
when (val status = chatDbStatus.value) {
is DBMigrationResult.ErrorNotADatabase -> {
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
Text(generalGetString(R.string.passphrase_is_different))
DatabaseKeyField(dbKey, buttonEnabled) {
saveAndRunChatOnClick()
}
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
SectionSpacer()
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
} else {
Text(generalGetString(R.string.database_passphrase_is_required))
DatabaseKeyField(dbKey, buttonEnabled) {
if (useKeychain) saveAndRunChatOnClick() else runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
}
if (useKeychain) {
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
} else {
OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) }
}
}
}
is DBMigrationResult.Error -> {
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
Text(String.format(generalGetString(R.string.error_with_info), status.migrationError))
}
is DBMigrationResult.ErrorKeychain -> {
Text(generalGetString(R.string.cannot_access_keychain))
}
is DBMigrationResult.Unknown -> {
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
}
is DBMigrationResult.OK -> {
}
null -> {
}
}
if (restoreDbFromBackup.value) {
SectionSpacer()
Text(generalGetString(R.string.database_backup_can_be_restored))
Spacer(Modifier.size(16.dp))
RestoreDbButton {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.restore_database_alert_title),
text = generalGetString(R.string.restore_database_alert_desc),
confirmText = generalGetString(R.string.restore_database_alert_confirm),
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
destructive = true,
)
}
}
}
}
}
if (progressIndicator.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
private fun runChat(
dbKey: String,
chatDbStatus: State<DBMigrationResult?>,
progressIndicator: MutableState<Boolean>,
prefs: AppPreferences
) = CoroutineScope(Dispatchers.Default).launch {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
if (progressIndicator.value) return@launch
progressIndicator.value = true
try {
SimplexApp.context.initChatController(dbKey)
} catch (e: Exception) {
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
}
progressIndicator.value = false
when (val status = chatDbStatus.value) {
is DBMigrationResult.OK -> {
SimplexService.cancelPassphraseNotification()
when (prefs.notificationsMode.get()) {
NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) }
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
}
}
is DBMigrationResult.ErrorNotADatabase -> {
AlertManager.shared.showAlertMsg( generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
}
is DBMigrationResult.Error -> {
AlertManager.shared.showAlertMsg( generalGetString(R.string.database_error), status.migrationError)
}
is DBMigrationResult.ErrorKeychain -> {
AlertManager.shared.showAlertMsg( generalGetString(R.string.keychain_error))
}
is DBMigrationResult.Unknown -> {
AlertManager.shared.showAlertMsg( generalGetString(R.string.unknown_error), status.json)
}
null -> {}
}
}
private fun shouldShowRestoreDbButton(prefs: AppPreferences, context: Context): Boolean {
val startedAt = prefs.encryptionStartedAt.get() ?: return false
/** Just in case there is any small difference between reported Java's [Clock.System.now] and Linux's time on a file */
val safeDiffInTime = 10_000L
val filesChat = File(context.dataDir.absolutePath + File.separator + "files_chat.db.bak")
val filesAgent = File(context.dataDir.absolutePath + File.separator + "files_agent.db.bak")
return filesChat.exists() &&
filesAgent.exists() &&
startedAt.toEpochMilliseconds() - safeDiffInTime <= filesChat.lastModified() &&
startedAt.toEpochMilliseconds() - safeDiffInTime <= filesAgent.lastModified()
}
private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPreferences, context: Context) {
val filesChatBase = context.dataDir.absolutePath + File.separator + "files_chat.db"
val filesAgentBase = context.dataDir.absolutePath + File.separator + "files_agent.db"
try {
Files.copy(Path("$filesChatBase.bak"), Path(filesChatBase), StandardCopyOption.REPLACE_EXISTING)
Files.copy(Path("$filesAgentBase.bak"), Path(filesAgentBase), StandardCopyOption.REPLACE_EXISTING)
restoreDbFromBackup.value = false
prefs.encryptionStartedAt.set(null)
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_restore_error), e.stackTraceToString())
}
}
@Composable
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
DatabaseKeyField(
text,
generalGetString(R.string.enter_passphrase),
isValid = ::validKey,
keyboardActions = KeyboardActions(onDone = if (enabled) {
{ onClick?.invoke() }
} else null
)
)
}
@Composable
private fun ColumnScope.SaveAndOpenButton(enabled: Boolean, onClick: () -> Unit) {
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
Text(generalGetString(R.string.save_passphrase_and_open_chat))
}
}
@Composable
private fun ColumnScope.OpenChatButton(enabled: Boolean, onClick: () -> Unit) {
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
Text(generalGetString(R.string.open_chat))
}
}
@Composable
private fun ColumnScope.RestoreDbButton(onClick: () -> Unit) {
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally)) {
Text(generalGetString(R.string.restore_database), color = MaterialTheme.colors.error)
}
}
@Preview
@Composable
fun PreviewChatInfoLayout() {
SimpleXTheme {
DatabaseErrorView(
remember { mutableStateOf(DBMigrationResult.ErrorNotADatabase("simplex_v1_chat.db")) },
AppPreferences(SimplexApp.context)
)
}
}

View File

@@ -0,0 +1,581 @@
package chat.simplex.app.views.database
import SectionDivider
import SectionTextFooter
import SectionItemView
import SectionSpacer
import SectionView
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
import android.os.FileUtils
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
import kotlinx.coroutines.*
import kotlinx.datetime.*
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun DatabaseView(
m: ChatModel,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val context = LocalContext.current
val progressIndicator = remember { mutableStateOf(false) }
val runChat = remember { mutableStateOf(m.chatRunning.value ?: true) }
val prefs = m.controller.appPrefs
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) }
val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) }
val chatArchiveFile = remember { mutableStateOf<String?>(null) }
val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, chatArchiveFile)
val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
importArchiveAlert(m, context, uri, progressIndicator)
}
}
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory(context))) }
LaunchedEffect(m.chatRunning) {
runChat.value = m.chatRunning.value ?: true
}
Box(
Modifier.fillMaxSize(),
) {
DatabaseLayout(
progressIndicator.value,
runChat.value,
useKeychain.value,
m.chatDbEncrypted.value,
m.controller.appPrefs.initialRandomDBPassphrase,
importArchiveLauncher,
chatArchiveName,
chatArchiveTime,
chatLastStart,
appFilesCountAndSize,
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
stopChatAlert = { stopChatAlert(m, runChat, context) },
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(context, appFilesCountAndSize) },
showSettingsModal
)
if (progressIndicator.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
}
@Composable
fun DatabaseLayout(
progressIndicator: Boolean,
runChat: Boolean,
useKeyChain: Boolean,
chatDbEncrypted: Boolean?,
initialRandomDBPassphrase: Preference<Boolean>,
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
startChat: () -> Unit,
stopChatAlert: () -> Unit,
exportArchive: () -> Unit,
deleteChatAlert: () -> Unit,
deleteAppFilesAndMedia: () -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val stopped = !runChat
val operationsDisabled = !stopped || progressIndicator
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
Text(
stringResource(R.string.your_chat_database),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView(stringResource(R.string.run_chat_section)) {
RunChatSetting(runChat, stopped, startChat, stopChatAlert)
}
SectionSpacer()
SectionView(stringResource(R.string.chat_database_section)) {
val unencrypted = chatDbEncrypted == false
SettingsActionItem(
if (unencrypted) Icons.Outlined.LockOpen else if (useKeyChain) Icons.Filled.VpnKey else Icons.Outlined.Lock,
stringResource(R.string.database_passphrase),
click = showSettingsModal { DatabaseEncryptionView(it) },
iconColor = if (unencrypted) WarningOrange else HighOrLowlight,
disabled = operationsDisabled
)
SectionDivider()
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.export_database),
click = {
if (initialRandomDBPassphrase.get()) {
exportProhibitedAlert()
} else {
exportArchive()
}
},
textColor = MaterialTheme.colors.primary,
disabled = operationsDisabled
)
SectionDivider()
SettingsActionItem(
Icons.Outlined.FileDownload,
stringResource(R.string.import_database),
{ importArchiveLauncher.launch("application/zip") },
textColor = Color.Red,
disabled = operationsDisabled
)
SectionDivider()
val chatArchiveNameVal = chatArchiveName.value
val chatArchiveTimeVal = chatArchiveTime.value
val chatLastStartVal = chatLastStart.value
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
SettingsActionItem(
Icons.Outlined.Inventory2,
title,
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
disabled = operationsDisabled
)
SectionDivider()
}
SettingsActionItem(
Icons.Outlined.DeleteForever,
stringResource(R.string.delete_database),
deleteChatAlert,
textColor = Color.Red,
disabled = operationsDisabled
)
}
SectionTextFooter(
if (stopped) {
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
} else {
stringResource(R.string.stop_chat_to_enable_database_actions)
}
)
SectionSpacer()
SectionView(stringResource(R.string.files_section)) {
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
SectionItemView(
deleteAppFilesAndMedia,
disabled = deleteFilesDisabled
) {
Text(
stringResource(R.string.delete_files_and_media),
color = if (deleteFilesDisabled) HighOrLowlight else Color.Red
)
}
}
val (count, size) = appFilesCountAndSize.value
SectionTextFooter(
if (count == 0) {
stringResource(R.string.no_received_app_files)
} else {
String.format(stringResource(R.string.total_files_count_and_size), count, formatBytes(size))
}
)
}
}
@Composable
fun RunChatSetting(
runChat: Boolean,
stopped: Boolean,
startChat: () -> Unit,
stopChatAlert: () -> Unit
) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
Icon(
if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow,
chatRunningText,
tint = if (stopped) Color.Red else MaterialTheme.colors.primary
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
chatRunningText,
Modifier.padding(end = 24.dp)
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = runChat,
onCheckedChange = { runChatSwitch ->
if (runChatSwitch) {
startChat()
} else {
stopChatAlert()
}
},
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
}
}
@Composable
fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
}
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
withApi {
try {
if (chatDbChanged.value) {
SimplexApp.context.initChatController()
chatDbChanged.value = false
}
if (m.chatDbStatus.value !is DBMigrationResult.OK) {
/** Hide current view and show [DatabaseErrorView] */
ModalManager.shared.closeModals()
return@withApi
}
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
val ts = Clock.System.now()
m.controller.appPrefs.chatLastStart.set(ts)
chatLastStart.value = ts
when (m.controller.appPrefs.notificationsMode.get()) {
NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) }
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
}
} catch (e: Error) {
runChat.value = false
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
}
}
}
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.stop_chat_question),
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
confirmText = generalGetString(R.string.stop_chat_confirmation),
onConfirm = { authStopChat(m, runChat, context) },
onDismiss = { runChat.value = true }
)
}
private fun exportProhibitedAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.set_password_to_export),
text = generalGetString(R.string.set_password_to_export_desc),
)
}
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
if (m.controller.appPrefs.performLA.get()) {
authenticate(
generalGetString(R.string.auth_stop_chat),
generalGetString(R.string.auth_log_in_using_credential),
context as FragmentActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success, LAResult.Unavailable -> {
stopChat(m, runChat, context)
}
is LAResult.Error -> {
}
LAResult.Failed -> {
runChat.value = true
}
}
}
)
} else {
stopChat(m, runChat, context)
}
}
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
withApi {
try {
m.controller.apiStopChat()
runChat.value = false
m.chatRunning.value = false
SimplexService.stop(context)
MessagesFetcherWorker.cancelAll()
} catch (e: Error) {
runChat.value = true
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
}
}
}
private fun exportArchive(
context: Context,
m: ChatModel,
progressIndicator: MutableState<Boolean>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatArchiveFile: MutableState<String?>,
saveArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>
) {
progressIndicator.value = true
withApi {
try {
val archiveFile = exportChatArchive(m, context, chatArchiveName, chatArchiveTime, chatArchiveFile)
chatArchiveFile.value = archiveFile
saveArchiveLauncher.launch(archiveFile.substringAfterLast("/"))
progressIndicator.value = false
} catch (e: Error) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_exporting_chat_database), e.toString())
progressIndicator.value = false
}
}
}
private suspend fun exportChatArchive(
m: ChatModel,
context: Context,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatArchiveFile: MutableState<String?>
): String {
val archiveTime = Clock.System.now()
val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
val archiveName = "simplex-chat.$ts.zip"
val archivePath = "${getFilesDirectory(context)}/$archiveName"
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
m.controller.apiExportArchive(config)
deleteOldArchive(m, context)
m.controller.appPrefs.chatArchiveName.set(archiveName)
chatArchiveName.value = archiveName
m.controller.appPrefs.chatArchiveTime.set(archiveTime)
chatArchiveTime.value = archiveTime
chatArchiveFile.value = archivePath
return archivePath
}
private fun deleteOldArchive(m: ChatModel, context: Context) {
val chatArchiveName = m.controller.appPrefs.chatArchiveName.get()
if (chatArchiveName != null) {
val file = File("${getFilesDirectory(context)}/$chatArchiveName")
val fileDeleted = file.delete()
if (fileDeleted) {
m.controller.appPrefs.chatArchiveName.set(null)
m.controller.appPrefs.chatArchiveTime.set(null)
} else {
Log.e(TAG, "deleteOldArchive file.delete() error")
}
}
}
@Composable
private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableState<String?>): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
try {
destination?.let {
val filePath = chatArchiveFile.value
if (filePath != null) {
val contentResolver = cxt.contentResolver
contentResolver.openOutputStream(destination)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
val file = File(filePath)
outputStream.write(file.readBytes())
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
}
}
} catch (e: Error) {
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e")
} finally {
chatArchiveFile.value = null
}
}
)
private fun importArchiveAlert(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState<Boolean>) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.import_database_question),
text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
confirmText = generalGetString(R.string.import_database_confirmation),
onConfirm = { importArchive(m, context, importedArchiveUri, progressIndicator) }
)
}
private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState<Boolean>) {
progressIndicator.value = true
val archivePath = saveArchiveFromUri(context, importedArchiveUri)
if (archivePath != null) {
withApi {
try {
m.controller.apiDeleteStorage()
try {
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
m.controller.apiImportArchive(config)
DatabaseUtils.removeDatabaseKey()
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
}
} catch (e: Error) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_importing_database), e.toString())
}
}
} catch (e: Error) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString())
}
} finally {
File(archivePath).delete()
}
}
}
}
private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): String? {
return try {
val inputStream = context.contentResolver.openInputStream(importedArchiveUri)
val archiveName = getFileName(context, importedArchiveUri)
if (inputStream != null && archiveName != null) {
val archivePath = "${context.cacheDir}/$archiveName"
val destFile = File(archivePath)
FileUtils.copy(inputStream, FileOutputStream(destFile))
archivePath
} else {
Log.e(TAG, "saveArchiveFromUri null inputStream")
null
}
} catch (e: Exception) {
Log.e(TAG, "saveArchiveFromUri error: ${e.message}")
null
}
}
private fun deleteChatAlert(m: ChatModel, progressIndicator: MutableState<Boolean>) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.delete_chat_profile_question),
text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = { deleteChat(m, progressIndicator) }
)
}
private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
progressIndicator.value = true
withApi {
try {
m.controller.apiDeleteStorage()
DatabaseUtils.removeDatabaseKey()
m.controller.appPrefs.storeDBPassphrase.set(true)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
}
} catch (e: Error) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString())
}
}
}
}
private fun deleteFilesAndMediaAlert(context: Context, appFilesCountAndSize: MutableState<Pair<Int, Long>>) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.delete_files_and_media_question),
text = generalGetString(R.string.delete_files_and_media_desc),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = { deleteFiles(appFilesCountAndSize, context) },
destructive = true
)
}
private fun deleteFiles(appFilesCountAndSize: MutableState<Pair<Int, Long>>, context: Context) {
deleteAppFiles(context)
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
}
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
m.chatDbChanged.value = true
progressIndicator.value = false
alert.invoke()
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewDatabaseLayout() {
SimpleXTheme {
DatabaseLayout(
progressIndicator = false,
runChat = true,
useKeyChain = false,
chatDbEncrypted = false,
initialRandomDBPassphrase = Preference({ true }, {}),
importArchiveLauncher = rememberGetContentLauncher {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
startChat = {},
stopChatAlert = {},
exportArchive = {},
deleteChatAlert = {},
deleteAppFilesAndMedia = {},
showSettingsModal = { {} }
)
}
}

View File

@@ -0,0 +1,103 @@
package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.app.R
import chat.simplex.app.TAG
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 = generalGetString(R.string.ok),
onConfirm: (() -> Unit)? = null,
dismissText: String = generalGetString(R.string.cancel_verb),
onDismiss: (() -> Unit)? = null,
onDismissRequest: (() -> Unit)? = null,
destructive: Boolean = false
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
title = { Text(title) },
text = alertText,
confirmButton = {
TextButton(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
},
dismissButton = {
TextButton(onClick = {
onDismiss?.invoke()
hideAlert()
}) { Text(dismissText) }
}
)
}
}
fun showAlertMsg(
title: String, text: String? = null,
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = this::hideAlert,
title = { Text(title) },
text = alertText,
confirmButton = {
TextButton(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText) }
}
)
}
}
@Composable
fun showInView() {
if (presentAlert.value) alertView.value?.invoke()
}
companion object {
val shared = AlertManager()
}
}

View File

@@ -0,0 +1,80 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatInfo
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
val icon =
if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
else Icons.Filled.AccountCircle
ProfileImage(size, chatInfo.image, icon, iconColor)
}
@Composable
fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
Box(Modifier.size(size)) {
Icon(
Icons.Filled.TheaterComedy, stringResource(R.string.incognito),
modifier = Modifier.size(size).padding(size / 12),
iconColor
)
}
}
@Composable
fun ProfileImage(
size: Dp,
image: String? = null,
icon: ImageVector = Icons.Filled.AccountCircle,
color: Color = MaterialTheme.colors.secondary
) {
Box(Modifier.size(size)) {
if (image == null) {
Icon(
icon,
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
tint = color,
modifier = Modifier.fillMaxSize()
)
} else {
val imageBitmap = base64ToBitmap(image).asImageBitmap()
Image(
imageBitmap,
stringResource(R.string.image_descr_profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier.size(size).padding(size / 12).clip(CircleShape)
)
}
}
}
@Preview
@Composable
fun PreviewChatInfoImage() {
SimpleXTheme {
ChatInfoImage(
chatInfo = ChatInfo.Direct.sampleData,
size = 55.dp
)
}
}

View File

@@ -0,0 +1,54 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.views.newchat.ActionButton
sealed class AttachmentOption {
object TakePhoto: AttachmentOption()
object PickImage: AttachmentOption()
object PickFile: AttachmentOption()
}
@Composable
fun ChooseAttachmentView(
attachmentOption: MutableState<AttachmentOption?>,
hide: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.onFocusChanged { focusState ->
if (!focusState.hasFocus) hide()
}
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 30.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
attachmentOption.value = AttachmentOption.TakePhoto
hide()
}
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
attachmentOption.value = AttachmentOption.PickImage
hide()
}
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
attachmentOption.value = AttachmentOption.PickFile
hide()
}
}
}
}

View File

@@ -0,0 +1,48 @@
package chat.simplex.app.views.helpers
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
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,
stringResource(R.string.icon_descr_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 = {})
}
}

View File

@@ -0,0 +1,12 @@
package chat.simplex.app.views.helpers
interface ValueTitle <T> {
val value: T
val title: String
}
data class ValueTitleDesc <T> (
override val value: T,
override val title: String,
val description: String
): ValueTitle<T>

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