Compare commits

...

432 Commits

Author SHA1 Message Date
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 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 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
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
347 changed files with 40712 additions and 6825 deletions
+1 -1
View File
@@ -1 +1 @@
* @epoberezkin @efim-poberezkin
* @epoberezkin @jr-simplex
+1
View File
@@ -42,3 +42,4 @@ stack.yaml.lock
# Temporary test files
tests/tmp
logs/
+134 -252
View File
@@ -1,8 +1,8 @@
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
# SimpleX - the first chat platform that is 100% private by design - it has no access to your connection graph!
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
[![GitHub build](https://github.com/simplex-chat/simplex-chat/workflows/build/badge.svg)](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild)
[![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)
@@ -12,286 +12,168 @@
&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="iOS TestFlight" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
- 🖲 Protects your messages and metadata - who you talk to and when.
- 🔐 Double ratchet encryption.
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). [See the announcement here](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220308-simplex-chat-mobile-apps.md).
- 🔐 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](https://github.com/simplex-chat/simplex-chat) on Linux, MacOS, Windows.
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
## Contents
### :zap: Quick installation of a terminal app
- [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)
- [For developers](#for-developers)
- [Roadmap](#roadmap)
- [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?_ This [post](https://github.com/dessalines/essays/blob/master/why_not_signal.md) shows why Signal cannot be considered a private messenger. Signal is a centralised platform that uses phone numbers to identify its users and their contacts.
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
[Jun 4, 2022. v2.2: the new Privacy and Security settings](./blog/20220604-simplex-chat-new-privacy-security-settings.md)
[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md)
[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md)
[Jan 12, 2022. SimpleX v1 released: the only messaging and application platform without user identities](./20220112-simplex-chat-v1-released.md)
[All updates](./blog)
## Make a private connection
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/master/install.sh | bash
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)
## Table of contents
Read more about [installing and using the terminal app](./docs/CLI.md).
- [Disclaimer](#disclaimer)
- [Network topology](#network-topology)
- [Terminal chat features](#terminal-chat-features)
- [Installation](#🚀-installation)
- [Download chat client](#download-chat-client)
- [Linux and MacOS](#linux-and-macos)
- [Windows](#windows)
- [Build from source](#build-from-source)
- [Using Docker](#using-docker)
- [Using Haskell stack](#using-haskell-stack)
- [Usage](#usage)
- [Running the chat client](#running-the-chat-client)
- [How to use SimpleX chat](#how-to-use-simplex-chat)
- [Groups](#groups)
- [Sending files](#sending-files)
- [User contact addresses](#user-contact-addresses)
- [Access chat history](#access-chat-history)
- [Roadmap](#Roadmap)
- [License](#license)
## SimpleX Platform design
## Disclaimer
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.
SimpleX Chat implements a new network topology for asynchronous communication combining the advantages and avoiding the disadvantages of federated and P2P networks.
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).
[SimpleXMQ security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) had many improvements in v1.0.0; the implementation has not been audited yet.
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.
We use SimpleX Chat all the time, but you may find some bugs. We would really appreciate if you use it and let us know anything that needs to be fixed or improved.
Only the client devices have information about users, their contacts and groups.
## Network topology
See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
SimpleX is a client-server network that uses redundant, disposable nodes to asynchronously pass messages via message queues, providing receiver and sender anonymity.
## For developers
Unlike P2P networks, all messages are passed through one or several (for redundancy) servers, 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) - it provides better metadata protection than P2P designs, as no global participant ID is required, and avoids many [problems of P2P networks](https://github.com/simplex-chat/simplex-chat/blob/master/simplex.md#comparison-with-p2p-messaging-protocols).
We plan that the SimpleX platform will grow into the platform supporting any distributed Internet application. This will allow you to build any service that people can access via chat, with custom web-based UI widgets that anybody with basic HTML/CSS/JavaScript knowledge can create in a few hours.
Unlike federated networks, the participating server nodes **do not have records of the users**, **do not communicate with each other**, **do not store messages** after they are delivered to the recipients, and there is no way to discover the full list of participating servers. SimpleX network avoids the problem of metadata visibility that federated networks have and better protects the network, as servers do not communicate with each other. Each server node provides unidirectional "dumb pipes" to the users, that do authorization without authentication, having no knowledge of the the users or their contacts. Each queue is assigned two Ed448 keys - one for receiver and one for sender - and each queue access is authorized with a signature created using a respective key's private counterpart.
You already can:
The routing of messages relies on the knowledge of client devices how user contacts and groups map at any given moment of time to these disposable queues on server nodes.
- use SimpleX Chat library to integrate chat functionality into your apps.
- use SimpleX Chat bot templates in Haskell to build your own chat bot services (TypeScript SDK is coming soon).
## Terminal chat features
- 1-to-1 chat with multiple people in the same terminal window.
- Group messaging.
- Sending files to contacts and groups.
- User contact addresses - establish connections via multiple-use contact links.
- Messages persisted in a local SQLite database.
- Auto-populated recipient name - just type your messages to reply to the sender once the connection is established.
- Demo SMP servers available and pre-configured in the app - or you can [deploy your own server](https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent).
- No global identity or any names visible to the server(s), ensuring full privacy of your contacts and conversations.
- Two layers of E2E encryption (double-ratchet for duplex connections, using X3DH key agreement with ephemeral Curve448 keys, and NaCl crypto_box for SMP queues, using Curve25519 keys) and out-of-band passing of recipient keys (see [How to use SimpleX chat](#how-to-use-simplex-chat)).
- Message integrity validation (via including the digests of the previous messages).
- Authentication of each command/message by SMP servers with automatically generated Ed448 keys.
- TLS 1.3 transport encryption.
- Additional encryption of messages from SMP server to recipient to reduce traffic correlation.
Public keys involved in key exchange are not used as identity, they are randomly generated for each contact.
See [Encryption Primitives Used](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#encryption-primitives-used) for technical details.
<a name="🚀-installation"></a>
## 🚀 Installation
### Download chat client
#### Linux and MacOS
To **install** or **update** `simplex-chat`, you should run the install script. To do that, use the following cURL or Wget command:
```sh
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
```
```sh
wget -qO- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
```
Once the chat client downloads, you can run it with `simplex-chat` command in your terminal.
Alternatively, you can manually download the chat binary for your system from the [latest stable release](https://github.com/simplex-chat/simplex-chat/releases) and make it executable as shown below.
```sh
chmod +x <binary>
mv <binary> ~/.local/bin/simplex-chat
```
(or any other preferred location on `PATH`).
On MacOS you also need to [allow Gatekeeper to run it](https://support.apple.com/en-us/HT202491).
#### Windows
```sh
move <binary> %APPDATA%/local/bin/simplex-chat.exe
```
### Build from source
> **Please note:** to build the app use source code from [stable branch](https://github.com/simplex-chat/simplex-chat/tree/stable).
#### Using Docker
On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs):
```shell
$ git clone git@github.com:simplex-chat/simplex-chat.git
$ cd simplex-chat
$ git checkout stable
$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
```
> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)).
#### Using Haskell stack
Install [Haskell stack](https://docs.haskellstack.org/en/stable/README/):
```shell
curl -sSL https://get.haskellstack.org/ | sh
```
and build the project:
```shell
$ git clone git@github.com:simplex-chat/simplex-chat.git
$ cd simplex-chat
$ git checkout stable
$ stack install
```
## Usage
### Running the chat client
To start the chat client, run `simplex-chat` from the terminal.
By default, app data directory is created in the home directory (`~/.simplex`, or `%APPDATA%/simplex` on Windows), and two SQLite database files `simplex_v1_chat.db` and `simplex_v1_agent.db` are initialized in it.
To specify a different file path prefix for the database files use `-d` command line option:
```shell
$ simplex-chat -d alice
```
Running above, for example, would create `alice_v1_chat.db` and `alice_v1_agent.db` database files in current directory.
Three default SMP servers are hosted on Linode - they are [pre-configured in the app](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat/Options.hs#L42).
If you deployed your own SMP server(s) you can configure client via `-s` option:
```shell
$ simplex-chat -s smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@smp.example.com
```
Base64url encoded string preceding the server address is the server's offline certificate fingerprint which is validated by client during TLS handshake.
You can still talk to people using default or any other server - it only affects the location of the message queue when you initiate the connection (and the reply queue can be on another server, as set by the other party's client).
Run `simplex-chat -h` to see all available options.
### How to use SimpleX chat
Once you have started the chat, you will be prompted to specify your "display name" and an optional "full name" to create a local chat profile. Your display name is an alias for your contacts to refer to you by - it is not unique and does not serve as a global identity. If some of your contacts chose the same display name, the chat client adds a numeric suffix to their local display name.
The diagram below shows how to connect and message a contact:
<div align="center">
<img align="center" src="images/how-to-use-simplex.svg">
</div>
Once you've set up your local profile, enter `/c` (for `/connect`) to create a new connection and generate an invitation. Send this invitation to your contact via any other channel.
You are able to create multiple invitations by entering `/connect` multiple times and sending these invitations to the corresponding contacts you'd like to connect with.
The invitation can only be used once and even if this is intercepted, the attacker would not be able to use it to send you the messages via this queue once your contact confirms that the connection is established. See agent protocol for explanation of [invitation format](https://github.com/simplex-chat/simplexmq/blob/master/protocol/agent-protocol.md#connection-request).
The contact who received the invitation should enter `/c <invitation>` to accept the connection. This establishes the connection, and both parties are notified.
They would then use `@<name> <message>` commands to send messages. You may also just start typing a message to send it to the contact that was the last.
Use `/help` in chat to see the list of available commands.
### Groups
To create a group use `/g <group>`, then add contacts to it with `/a <group> <name>`. You can then send messages to the group by entering `#<group> <message>`. Use `/help groups` for other commands.
![simplex-chat](./images/groups.gif)
> **Please note**: the groups are not stored on any server, they are maintained as a list of members in the app database to whom the messages will be sent.
### Sending files
You can send a file to your contact with `/f @<contact> <file_path>` - the recipient will have to accept it before it is sent. Use `/help files` for other commands.
![simplex-chat](./images/files.gif)
You can send files to a group with `/f #<group> <file_path>`.
### User contact addresses
As an alternative to one-time invitation links, you can create a long-term address with `/ad` (for `/address`). The created address can then be shared via any channel, and used by other users as a link to make a contact request with `/c <user_contact_address>`.
You can accept or reject incoming requests with `/ac <name>` and `/rc <name>` commands.
User address is "long-term" in a sense that it is a multiple-use connection link - it can be used until it is deleted by the user, in which case all established connections would still remain active (unlike how it works with email, when changing the address results in people not being able to message you).
Use `/help address` for other commands.
![simplex-chat](./images/user-addresses.gif)
### Access chat history
SimpleX chat stores all your contacts and conversations in a local SQLite database, making it private and portable by design, owned and controlled by user.
You can view and search your chat history by querying your database. Run the below script to create message views in your database.
```sh
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db
```
Open SQLite Command Line Shell:
```sh
sqlite3 ~/.simplex/simplex_v1_chat.db
```
See [Message queries](./message_queries.md) for examples.
> **Please note:** SQLite foreign key constraints are disabled by default, and must be **[enabled separately for each database connection](https://sqlite.org/foreignkeys.html#fk_enable)**. The latter can be achieved by running `PRAGMA foreign_keys = ON;` command on an open database connection. By running data altering queries without enabling foreign keys prior to that, you may risk putting your database in an inconsistent state.
**Convenience queries**
Get all messages from today (`chat_dt` is in UTC):
```sql
select * from all_messages_plain where date(chat_dt) > date('now', '-1 day') order by chat_dt;
```
Get overnight messages in the morning:
```sql
select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt;
```
If you are considering developing with SimpleX platform please get in touch for any advice and support.
## Roadmap
1. Mobile and desktop apps (in progress).
2. SMP protocol improvements:
- SMP queue redundancy and rotation.
- Message delivery confirmation.
- Support multiple devices.
3. 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.
4. Media server to optimize sending large files to groups.
5. Channels server for large groups and broadcast channels.
- ✅ 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 audio and video calls via the mobile apps (enable via Experimental Features).
- 🏗 Privacy preserving instant notifications for iOS using Apple Push Notification service (in progress).
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
- 🏗 Chat database portability and encryption.
- Groups support for mobile apps.
- Disappearing messages, with mutual agreement.
- 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.
- Media server to optimize sending large files to groups.
- Channels server for large groups and broadcast channels.
## 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)
+5
View File
@@ -1,5 +1,8 @@
<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" />
@@ -123,9 +126,11 @@
<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" />
-1
View File
@@ -13,7 +13,6 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings>
</option>
</component>
+16 -4
View File
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 11
versionName "1.2"
versionCode 41
versionName "3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -41,11 +41,13 @@ android {
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 {
@@ -74,11 +76,13 @@ dependencies {
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.navigation:navigation-compose:2.4.1"
implementation "com.google.accompanist:accompanist-insets:0.23.0"
implementation 'androidx.webkit:webkit:1.4.0'
def work_version = "2.7.1"
implementation "androidx.work:work-runtime-ktx:$work_version"
@@ -89,12 +93,20 @@ dependencies {
implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
//Barcode
implementation 'com.google.zxing:core:3.4.0'
implementation 'com.google.mlkit:barcode-scanning:17.0.2'
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'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
+80 -14
View File
@@ -1,38 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="chat.simplex.app">
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="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<activity
android:name=".MainActivity"
android:exported="true"
android:name="SimplexApp"
android:allowBackup="true"
android:icon="@mipmap/icon"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<!-- Main activity -->
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.SimpleX">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- open simplex:/ connection URI -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="simplex" />
</intent-filter>
</activity>
</application>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
</manifest>
<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 android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true"/>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="chat.simplex.app.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- SimplexService foreground service -->
<service
android:name=".SimplexService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false"></service>
<!-- SimplexService restart on reboot -->
<receiver
android:name=".SimplexService$StartReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- SimplexService restart on destruction -->
<receiver
android:name=".SimplexService$AutoRestartReceiver"
android:enabled="true"
android:exported="false" />
</application>
</manifest>
@@ -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
@@ -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>
@@ -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
+1
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);
@@ -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;
}
+16 -1
View File
@@ -24,9 +24,11 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass
// from simplex-chat
typedef void* chat_ctrl;
extern chat_ctrl chat_init(const char * path);
extern chat_ctrl chat_init(const char *path);
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl);
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
@@ -48,3 +50,16 @@ 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;
}
@@ -4,40 +4,54 @@ 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.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Replay
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.AndroidViewModel
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.SplashView
import chat.simplex.app.views.WelcomeView
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.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.connectViaUri
import chat.simplex.app.views.newchat.withUriAction
import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay
import java.util.concurrent.TimeUnit
//import kotlinx.serialization.decodeFromString
class MainActivity: ComponentActivity() {
class MainActivity: FragmentActivity(), LifecycleEventObserver {
private val vm by viewModels<SimplexViewModel>()
private val chatController by lazy { (application as SimplexApp).chatController }
private val userAuthorized = mutableStateOf<Boolean?>(null)
private val enteredBackground = mutableStateOf<Long?>(null)
private val laFailed = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// testJson()
processIntent(intent, vm.chatModel)
// vm.app.initiateBackgroundWork()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
// testJson()
val m = vm.chatModel
processNotificationIntent(intent, m)
setContent {
SimpleXTheme {
Surface(
@@ -45,10 +59,169 @@ class MainActivity: ComponentActivity() {
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
MainPage(vm.chatModel)
MainPage(
m,
userAuthorized,
laFailed,
::runAuthenticate,
::setPerformLA,
showLANotice = { m.controller.showLANotice(this) }
)
}
}
}
schedulePeriodicServiceRestartWorker()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
processIntent(intent, vm.chatModel)
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
withApi {
when (event) {
Lifecycle.Event.ON_STOP -> {
enteredBackground.value = elapsedRealtime()
}
Lifecycle.Event.ON_START -> {
val enteredBackgroundVal = enteredBackground.value
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
runAuthenticate()
}
}
}
}
}
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 schedulePeriodicServiceRestartWorker() {
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(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
}
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()
}
}
}
)
}
}
@@ -58,30 +231,132 @@ class SimplexViewModel(application: Application): AndroidViewModel(application)
}
@Composable
fun MainPage(chatModel: ChatModel) {
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 remember { mutableStateOf(false) }
LaunchedEffect(userAuthorized.value) {
if (chatModel.controller.appPrefs.performLA.get()) {
delay(500L)
}
chatsAccessAuthorized = userAuthorized.value == true
}
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 {
when (chatModel.userCreated.value) {
null -> SplashView()
false -> WelcomeView(chatModel)
true ->
if (chatModel.chatId.value == null) ChatListView(chatModel)
else ChatView(chatModel)
val onboarding = chatModel.onboardingStage.value
val userCreated = chatModel.userCreated.value
when {
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) {
NtfManager.OpenChatAction -> {
val chatId = intent.getStringExtra("chatId")
Log.d(TAG, "processIntent: OpenChatAction $chatId")
if (chatId != null) {
val cInfo = chatModel.getChat(chatId)?.chatInfo
if (cInfo != null) withApi { openChat(chatModel, cInfo) }
}
}
"android.intent.action.VIEW" -> {
val uri = intent.data
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
@@ -96,10 +371,18 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
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 = "Connect via $action link?",
text = "Your profile will be sent to the contact that you received this link from.",
confirmText = "Connect",
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")
@@ -110,11 +393,9 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
}
}
}
//fun testJson() {
// val str = """
// {}
// val str: String = """
// """.trimIndent()
//
// println(json.decodeFromString<ChatItem>(str))
// println(json.decodeFromString<APIResponse>(str))
//}
@@ -3,8 +3,11 @@ package chat.simplex.app
import android.app.Application
import android.net.LocalServerSocket
import android.util.Log
import androidx.lifecycle.*
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.getFilesDirectory
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.onboarding.OnboardingStage
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*
@@ -21,40 +24,66 @@ external fun pipeStdOutToSocket(socketName: String) : Int
// SimpleX API
typealias ChatCtrl = Long
external fun chatInit(path: String): ChatCtrl
external fun chatSendCmd(ctrl: ChatCtrl, msg: String) : String
external fun chatRecvMsg(ctrl: ChatCtrl) : String
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 {
class SimplexApp: Application() {
private lateinit var controller: ChatController
lateinit var chatModel: ChatModel
private lateinit var ntfManager: NtfManager
class SimplexApp: Application(), LifecycleEventObserver {
val chatController: ChatController by lazy {
val ctrl = chatInit(getFilesDirectory(applicationContext))
ChatController(ctrl, ntfManager, applicationContext, appPreferences)
}
val chatModel: ChatModel by lazy {
chatController.chatModel
}
private val ntfManager: NtfManager by lazy {
NtfManager(applicationContext, appPreferences)
}
private val appPreferences: AppPreferences by lazy {
AppPreferences(applicationContext)
}
override fun onCreate() {
super.onCreate()
// ProcessLifecycleOwner.get().lifecycle.addObserver(this)
ntfManager = NtfManager(applicationContext)
val ctrl = chatInit(applicationContext.filesDir.toString())
controller = ChatController(ctrl, ntfManager, applicationContext)
chatModel = controller.chatModel
context = this
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
withApi {
val user = controller.apiGetActiveUser()
if (user != null) controller.startChat(user)
val user = chatController.apiGetActiveUser()
if (user == null) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
chatController.startChat(user)
chatController.showBackgroundServiceNoticeIfNeeded()
}
}
}
// override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
// Log.d(TAG, "onStateChanged: $event")
// if (event == Lifecycle.Event.ON_STOP) {
// Log.e(TAG, "BGManager schedule ${Clock.System.now()}")
// BGManager.schedule(applicationContext)
// }
// }
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Log.d(TAG, "onStateChanged: $event")
withApi {
when (event) {
Lifecycle.Event.ON_STOP ->
if (appPreferences.runServiceInBackground.get() && chatModel.chatRunning.value != false) SimplexService.start(applicationContext)
Lifecycle.Event.ON_START ->
SimplexService.stop(applicationContext)
Lifecycle.Event.ON_RESUME ->
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
chatController.showBackgroundServiceNoticeIfNeeded()
}
else -> {}
}
}
}
companion object {
lateinit var context: SimplexApp private set
init {
val socketName = "local.socket.address.listen.native.cmd2"
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d(TAG, "starting server")
@@ -69,7 +98,7 @@ class SimplexApp: Application() {
val inStreamReader = InputStreamReader(inStream)
val input = BufferedReader(inStreamReader)
while(true) {
while (true) {
val line = input.readLine() ?: break
Log.w("$TAG (stdout/stderr)", line)
logbuffer.add(line)
@@ -0,0 +1,248 @@
package chat.simplex.app
import android.app.*
import android.content.*
import android.os.*
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.views.helpers.withApi
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 isStoppingService = false
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
private val chatController by lazy { (application as SimplexApp).chatController }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand startId: $startId")
if (intent != null) {
val action = intent.action
Log.d(TAG, "intent action $action")
when (action) {
Action.START.name -> startService()
Action.STOP.name -> stopService()
else -> Log.e(TAG, "No action in the intent")
}
} else {
Log.d(TAG, "null intent. Probably restarted by the system.")
}
return START_STICKY // to restart if killed
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Simplex service created")
val title = getString(R.string.simplex_service_notification_title)
val text = getString(R.string.simplex_service_notification_text)
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
}
override fun onDestroy() {
Log.d(TAG, "Simplex service destroyed")
stopService()
sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart if necessary!
super.onDestroy()
}
private fun startService() {
Log.d(TAG, "SimplexService startService")
if (isServiceStarted || isStartingService) return
val self = this
isStartingService = true
withApi {
try {
val user = chatController.apiGetActiveUser()
if (user == null) {
chatController.chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
Log.w(TAG, "Starting foreground service")
chatController.startChat(user)
isServiceStarted = true
saveServiceState(self, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
acquire()
}
}
}
} finally {
isStartingService = false
}
}
}
private fun stopService() {
Log.d(TAG, "Stopping foreground service")
if (isStoppingService) return
isStoppingService = true
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}")
}
isStoppingService = false
isServiceStarted = false
saveServiceState(this, ServiceState.STOPPED)
}
private fun createNotificationChannel(): NotificationManager? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).let {
it.setShowBadge(false) // no long-press badge
it
}
notificationManager.createNotificationChannel(channel)
return notificationManager
}
return null
}
private fun createNotification(title: String, text: String): Notification {
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
}
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_service_icon)
.setColor(0x88FFFF)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setSound(null)
.setShowWhen(false) // no date/time
.build()
}
override fun onBind(intent: Intent): IBinder? {
return null // no binding
}
// re-schedules the task when "Clear recent apps" is pressed
override fun onTaskRemoved(rootIntent: Intent) {
val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
it.setPackage(packageName)
};
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE);
applicationContext.getSystemService(Context.ALARM_SERVICE);
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
}
// restart on reboot
class StartReceiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "StartReceiver: onReceive called")
scheduleStart(context)
}
}
// restart on destruction
class AutoRestartReceiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "AutoRestartReceiver: onReceive called")
scheduleStart(context)
}
}
class ServiceStartWorker(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val id = this.id
if (context.applicationContext !is Application) {
Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: $id)")
return Result.failure()
}
if (getServiceState(context) == ServiceState.STARTED) {
Log.d(TAG, "ServiceStartWorker: Starting foreground service (work ID: $id)")
start(context)
}
return Result.success()
}
}
enum class Action {
START,
STOP
}
enum class ServiceState {
STARTED,
STOPPED,
}
companion object {
const val TAG = "SIMPLEX_SERVICE"
const val NOTIFICATION_CHANNEL_ID = "chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION"
const val NOTIFICATION_CHANNEL_NAME = "SimpleX Chat service"
const val SIMPLEX_SERVICE_ID = 6789
const val SERVICE_START_WORKER_VERSION = BuildConfig.VERSION_CODE
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change!
private const val WAKE_LOCK_TAG = "SimplexService::lock"
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS"
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
fun scheduleStart(context: Context) {
Log.d(TAG, "Enqueuing work to start subscriber service")
val workManager = WorkManager.getInstance(context)
val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
}
suspend fun start(context: Context) = serviceAction(context, Action.START)
suspend fun stop(context: Context) = serviceAction(context, Action.STOP)
private suspend fun serviceAction(context: Context, action: Action) {
if (!AppPreferences(context).runServiceInBackground.get()) { return }
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
withContext(Dispatchers.IO) {
Intent(context, SimplexService::class.java).also {
it.action = action.name
ContextCompat.startForegroundService(context, it)
}
}
}
fun restart(context: Context) {
Intent(context, SimplexService::class.java).also { intent ->
context.stopService(intent) // Service will auto-restart
}
}
fun saveServiceState(context: Context, state: ServiceState) {
getPreferences(context).edit()
.putString(SHARED_PREFS_SERVICE_STATE, state.name)
.apply()
}
fun getServiceState(context: Context): ServiceState {
val value = getPreferences(context)
.getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name)
return ServiceState.valueOf(value!!)
}
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
}
}
@@ -1,46 +0,0 @@
package chat.simplex.app.model
import android.content.Context
import android.util.Log
import androidx.work.*
import chat.simplex.app.TAG
import chat.simplex.app.chatRecvMsg
import kotlinx.datetime.Clock
import java.time.Duration
import java.util.concurrent.TimeUnit
class BGManager(appContext: Context, workerParams: WorkerParameters): //, ctrl: ChatCtrl):
Worker(appContext, workerParams) {
// val controller = ctrl
init {}
override fun doWork(): Result {
Log.e(TAG, "BGManager doWork ${Clock.System.now()}")
schedule(applicationContext)
getNewItems()
return Result.success()
}
private fun getNewItems() {
Log.e(TAG, "BGManager getNewItems")
// val json = chatRecvMsg(controller)
// val r = APIResponse.decodeStr(json).resp
// Log.d(TAG, "chatRecvMsg: ${r.responseType}")
}
companion object {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
fun schedule(appContext: Context) {
val request = OneTimeWorkRequestBuilder<BGManager>()
.setInitialDelay(Duration.ofMinutes(10))
.setConstraints(constraints)
.build()
WorkManager.getInstance(appContext)
.enqueue(request)
}
}
}
File diff suppressed because one or more lines are too long
@@ -1,19 +1,31 @@
package chat.simplex.app.model
import android.app.*
import android.content.Context
import android.content.Intent
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 kotlinx.datetime.Clock
class NtfManager(val context: Context) {
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
@@ -21,29 +33,56 @@ class NtfManager(val context: Context) {
private val msgNtfTimeoutMs = 30000L
init {
manager.createNotificationChannel(NotificationChannel(
MessageChannel,
"SimpleX Chat messages",
NotificationManager.IMPORTANCE_HIGH
))
manager.createNotificationChannel(NotificationChannel(MessageChannel, "SimpleX Chat messages", NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, "SimpleX Chat calls (lock screen)", NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(callNotificationChannel())
}
private fun callNotificationChannel(): NotificationChannel {
val callChannel = NotificationChannel(CallChannel, "SimpleX Chat 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) {
Log.d(TAG, "notifyMessageReceived ${cInfo.id}")
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(cInfo.id, 0) < msgNtfTimeoutMs)
prevNtfTime[cInfo.id] = now
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
prevNtfTime[chatId] = now
val notification = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(cInfo.displayName)
.setContentText(cItem.content.text)
.setContentTitle(displayName)
.setContentText(msgText)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setContentIntent(getMsgPendingIntent(cInfo))
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
.setSilent(recentNotification)
.build()
@@ -53,22 +92,86 @@ class NtfManager(val context: Context) {
.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(cInfo.id.hashCode(), notification)
notify(chatId.hashCode(), notification)
notify(0, summary)
}
}
private fun getMsgPendingIntent(cInfo: ChatInfo) : PendingIntent{
Log.d(TAG, "getMsgPendingIntent ${cInfo.id}")
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
}
)
ntfBuilder = ntfBuilder
.setContentTitle(invitation.contact.displayName)
.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()
val intent = Intent(context, MainActivity::class.java)
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)
.putExtra("chatId", cInfo.id)
.setAction(OpenChatAction)
.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
@@ -7,9 +7,20 @@ val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val Gray = Color(0x22222222)
val SimplexBlue = Color(0, 136, 255, 255)
val SimplexGreen = Color(98, 196, 103, 255)
val 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(134, 135, 139, 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, 20)
val ToolbarDark = Color(80, 80, 80, 20)
val SettingsBackgroundLight = Color(220, 216, 215, 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 FileLight = Color(183, 190, 199, 255)
val FileDark = Color(101, 101, 106, 255)
@@ -3,9 +3,10 @@ package chat.simplex.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorPalette = darkColors(
primary = SimplexBlue,
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
primaryVariant = SimplexGreen,
secondary = DarkGray,
// background = Color.Black,
@@ -13,14 +14,12 @@ private val DarkColorPalette = darkColors(
// background = Color(0xFF121212),
// surface = Color(0xFF121212),
// error = Color(0xFFCF6679),
// onPrimary = Color.Black,
// onSecondary = Color.Black,
// onBackground = Color.White,
// onSurface = Color.White,
onBackground = Color(0xFFFFFBFA),
onSurface = Color(0xFFFFFBFA),
// onError: Color = Color.Black,
)
private val LightColorPalette = lightColors(
primary = SimplexBlue,
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
primaryVariant = SimplexGreen,
secondary = LightGray,
// background = Color.White,
@@ -2,45 +2,54 @@ package chat.simplex.app.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.*
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
// https://github.com/rsms/inter
val Inter = FontFamily(
Font(R.font.inter_regular),
Font(R.font.inter_italic, style = FontStyle.Italic),
Font(R.font.inter_bold, weight = FontWeight.Bold),
Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold),
Font(R.font.inter_medium, weight = FontWeight.Medium),
)
// Set of Material typography styles to start with
val Typography = Typography(
h1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontFamily = Inter,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
),
h2 = TextStyle(
fontFamily = FontFamily.Default,
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 24.sp
),
h3 = TextStyle(
fontFamily = FontFamily.Default,
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 19.sp
fontSize = 18.5.sp
),
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 17.sp
),
body2 = TextStyle(
fontFamily = FontFamily.Default,
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
body2 = TextStyle(
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 14.sp
),
button = TextStyle(
fontFamily = FontFamily.Default,
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
),
caption = TextStyle(
fontFamily = FontFamily.Default,
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 20.sp
fontSize = 18.sp
)
)
)
@@ -3,13 +3,12 @@ package chat.simplex.app.views
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@@ -19,32 +18,53 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.SendMsgView
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.newchat.ModalManager
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@Composable
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
BackHandler(onBack = close)
TerminalLayout(chatModel.terminalItems, close) { cmd ->
withApi {
// show "in progress"
chatModel.controller.sendCmd(CC.Console(cmd))
// hide "in progress"
}
}
TerminalLayout(
chatModel.terminalItems,
composeState,
sendCommand = {
withApi {
// show "in progress"
chatModel.controller.sendCmd(CC.Console(composeState.value.message))
composeState.value = ComposeState(useLinkPreviews = false)
// hide "in progress"
}
},
close
)
}
@Composable
fun TerminalLayout(terminalItems: List<TerminalItem> , close: () -> Unit, sendCommand: (String) -> Unit) {
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 = { SendMsgView(sendCommand) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
}
},
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Surface(
@@ -62,6 +82,10 @@ fun TerminalLayout(terminalItems: List<TerminalItem> , close: () -> Unit, sendCo
@Composable
fun TerminalLog(terminalItems: List<TerminalItem>) {
val listState = rememberLazyListState()
val keyboardState by getKeyboardState()
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
mutableStateOf(CIListState(false, terminalItems.count(), keyboardState))
}
val scope = rememberCoroutineScope()
LazyColumn(state = listState) {
items(terminalItems) { item ->
@@ -81,8 +105,9 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
)
}
val len = terminalItems.count()
if (len > 1) {
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
scope.launch {
ciListState.value = CIListState(true, len, keyboardState)
listState.animateScrollToItem(len - 1)
}
}
@@ -100,8 +125,9 @@ fun PreviewTerminalLayout() {
SimpleXTheme {
TerminalLayout(
terminalItems = TerminalItem.sampleData,
close = {},
sendCommand = {}
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
sendCommand = {},
close = {}
)
}
}
@@ -1,159 +1,143 @@
package chat.simplex.app.views
import androidx.compose.foundation.*
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.res.painterResource
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 com.google.accompanist.insets.ProvideWindowInsets
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.onboarding.ReadableText
import com.google.accompanist.insets.navigationBarsWithImePadding
@Composable
fun WelcomeView(chatModel: ChatModel) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
.padding(12.dp)
) {
Image(
painter = painterResource(R.drawable.logo),
contentDescription = "Simplex Logo",
modifier = Modifier.padding(vertical = 15.dp)
)
Text(
"You control your chat!",
style = MaterialTheme.typography.h4,
color = MaterialTheme.colors.onBackground
)
Text(
"The messaging and application platform protecting your privacy and security.",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(8.dp))
Text(
"We don't store any of your contacts or messages (once delivered) on the servers.",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(24.dp))
CreateProfilePanel(chatModel)
}
}
}
}
fun isValidDisplayName(name: String) : Boolean {
return (name.firstOrNull { it.isWhitespace() }) == null
}
@Composable
fun CreateProfilePanel(chatModel: ChatModel) {
var displayName by remember { mutableStateOf("") }
var fullName by remember { mutableStateOf("") }
val displayName = remember { mutableStateOf("") }
val fullName = remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
Column(
modifier=Modifier.fillMaxSize()
) {
Text(
"Create profile",
style = MaterialTheme.typography.h4,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(vertical = 5.dp)
)
Text(
"Your profile is stored on your device and shared only with your contacts.",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(10.dp))
Text(
"Display Name",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 3.dp)
)
BasicTextField(
value = displayName,
onValueChange = { displayName = it },
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.secondary)
.height(40.dp)
.clip(RoundedCornerShape(5.dp))
.padding(8.dp)
.navigationBarsWithImePadding(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
val errorText = if(!isValidDisplayName(displayName)) "Display name cannot contain whitespace." else ""
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 }
Text(
errorText,
fontSize = 15.sp,
color = MaterialTheme.colors.error
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Spacer(Modifier.height(3.dp))
Text(
"Full Name (Optional)",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 5.dp)
)
BasicTextField(
value = fullName,
onValueChange = { fullName = it },
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.secondary)
.height(40.dp)
.clip(RoundedCornerShape(3.dp))
.padding(8.dp)
.navigationBarsWithImePadding(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
Spacer(Modifier.height(20.dp))
Button(onClick = {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName)
)
chatModel.controller.startChat(user)
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)
}
}
}
},
enabled = (displayName.isNotEmpty() && isValidDisplayName(displayName))
) { Text("Create") }
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()
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)
)
}
@@ -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()
}
}
}
@@ -0,0 +1,466 @@
package chat.simplex.app.views.call
import android.Manifest
import android.content.Context
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
@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)
}
}
@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)
}
}
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 = {}
)
}
}
@@ -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 = {},
)
}
}
}
@@ -0,0 +1,108 @@
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 (isSystemInDarkTheme()) IncomingCallDark else IncomingCallLight
Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
IncomingCallInfo(invitation)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
Spacer(Modifier.fillMaxWidth().weight(1f))
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
}
}
}
@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 = {}
)
}
}
@@ -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()
}
}
@@ -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
)
@@ -6,15 +6,17 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.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.TextAlign
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.helpers.*
@@ -24,46 +26,76 @@ fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
if (chat != null) {
ChatInfoLayout(chat,
ChatInfoLayout(
chat,
close = close,
deleteContact = {
AlertManager.shared.showAlertMsg(
title = "Delete contact?",
text = "Contact and all messages will be deleted - this cannot be undone!",
confirmText = "Delete",
onConfirm = {
val cInfo = chat.chatInfo
withApi {
val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
if (r) {
chatModel.removeChat(cInfo.id)
chatModel.chatId.value = null
close()
}
}
}
)
}
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, close: () -> Unit, deleteContact: () -> Unit) {
Column(Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
fun ChatInfoLayout(
chat: Chat,
close: () -> Unit,
deleteContact: () -> Unit,
clearChat: () -> Unit
) {
Column(
Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close)
Spacer(Modifier.size(48.dp))
ChatInfoImage(chat, size = 192.dp)
val cInfo = chat.chatInfo
ChatInfoImage(cInfo, size = 192.dp)
Text(
cInfo.displayName, style = MaterialTheme.typography.h1,
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(top = 32.dp).padding(bottom = 8.dp)
modifier = Modifier
.padding(top = 32.dp)
.padding(bottom = 8.dp)
)
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
@@ -87,35 +119,63 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp)
modifier = Modifier
.padding(top = 16.dp)
.padding(horizontal = 16.dp)
)
}
Spacer(Modifier.weight(1F))
Box(Modifier.padding(48.dp)) {
Box(Modifier.padding(4.dp)) {
SimpleButton(
"Delete contact", icon = Icons.Outlined.Delete,
stringResource(R.string.clear_chat_button),
icon = Icons.Outlined.Restore,
color = WarningOrange,
click = clearChat
)
}
Box(
Modifier
.padding(4.dp)
.padding(bottom = 32.dp)
) {
SimpleButton(
stringResource(R.string.button_delete_contact),
icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteContact
)
}
} else if (cInfo is ChatInfo.Group) {
Spacer(Modifier.weight(1F))
Box(
Modifier
.padding(4.dp)
.padding(bottom = 32.dp)
) {
SimpleButton(
stringResource(R.string.clear_chat_button),
icon = Icons.Outlined.Restore,
color = WarningOrange,
click = clearChat
)
}
}
}
}
@Composable
fun ServerImage(chat: Chat) {
val status = chat.serverInfo.networkStatus
when {
status is Chat.NetworkStatus.Connected ->
Icon(Icons.Filled.Circle, "Connected", tint = MaterialTheme.colors.primaryVariant)
status is Chat.NetworkStatus.Disconnected ->
Icon(Icons.Filled.Pending, "Disconnected", tint = HighOrLowlight)
status is Chat.NetworkStatus.Error ->
Icon(Icons.Filled.Error, "Error", tint = HighOrLowlight)
else ->
Icon(Icons.Outlined.Circle, "Pending", tint = HighOrLowlight)
when (chat.serverInfo.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)
}
}
@@ -129,7 +189,7 @@ fun PreviewChatInfoLayout() {
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
close = {}, deleteContact = {}
close = {}, deleteContact = {}, clearChat = {}
)
}
}
@@ -3,48 +3,64 @@ package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.annotation.StringRes
import androidx.compose.foundation.*
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.outlined.ArrowBack
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
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.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ModalManager
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
@Composable
fun ChatView(chatModel: ChatModel) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
if (chat == null) {
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) }
val attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
if (chat == null || user == null) {
chatModel.chatId.value = null
} else {
BackHandler { chatModel.chatId.value = null }
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
delay(1000L)
if (chat.chatItems.count() > 0) {
delay(750L)
if (chat.chatItems.isNotEmpty()) {
chatModel.markChatItemsRead(chat.chatInfo)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
@@ -54,20 +70,58 @@ fun ChatView(chatModel: ChatModel) {
}
}
}
ChatLayout(chat, chatModel.chatItems,
ChatLayout(
user,
chat,
composeState,
composeView = {
ComposeView(
chatModel, chat, composeState, attachmentOption,
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
)
},
attachmentOption,
scope,
attachmentBottomSheetState,
chatModel.chatItems,
useLinkPreviews = useLinkPreviews,
back = { chatModel.chatId.value = null },
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
sendMessage = { msg ->
openDirectChat = { contactId ->
val c = chatModel.chats.firstOrNull {
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
}
if (c != null) withApi { openChat(c.chatInfo, chatModel) }
},
deleteMessage = { itemId, mode ->
withApi {
// show "in progress"
val cInfo = chat.chatInfo
val newItem = chatModel.controller.apiSendMessage(
val toItem = chatModel.controller.apiDeleteChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
mc = MsgContent.MCText(msg)
itemId = itemId,
mode = mode
)
// hide "in progress"
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
}
},
receiveFile = { fileId ->
withApi { chatModel.controller.receiveFile(fileId) }
},
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)
}
}
)
@@ -76,23 +130,49 @@ fun ChatView(chatModel: ChatModel) {
@Composable
fun ChatLayout(
chat: Chat, chatItems: List<ChatItem>,
user: User,
chat: Chat,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
useLinkPreviews: Boolean,
back: () -> Unit,
info: () -> Unit,
sendMessage: (String) -> Unit
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit
) {
Surface(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background)) {
.background(MaterialTheme.colors.background)
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
bottomBar = { SendMsgView(sendMessage) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
ChatItemsList(chatItems)
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)
) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, acceptCall)
}
}
}
}
@@ -100,42 +180,62 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
Box(
Modifier
.height(60.dp)
.padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterStart
) {
IconButton(onClick = back) {
Icon(
Icons.Outlined.ArrowBack,
"Back",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
IconButton(onClick, modifier = modifier) {
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
}
Row(
}
Column {
Box(
Modifier
.padding(horizontal = 68.dp)
.fillMaxWidth()
.clickable(onClick = info),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
.height(52.dp)
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
val cInfo = chat.chatInfo
ChatInfoImage(chat, size = 40.dp)
Column(Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
if (cInfo is ChatInfo.Direct) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {
startCall(CallMediaType.Audio)
}
}
toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) {
startCall(CallMediaType.Video)
}
}
}
Row(
Modifier
.padding(horizontal = 80.dp)
.fillMaxWidth()
.clickable(onClick = info),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(cInfo.displayName, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
Text(cInfo.fullName,
maxLines = 1, overflow = TextOverflow.Ellipsis)
ChatInfoImage(cInfo, size = 40.dp)
Column(
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
Text(
cInfo.fullName,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
}
}
}
Divider()
}
}
@@ -152,20 +252,74 @@ val CIListStateSaver = run {
}
@Composable
fun ChatItemsList(chatItems: List<ChatItem>) {
val listState = rememberLazyListState()
fun ChatItemsList(
user: User,
chat: Chat,
composeState: MutableState<ComposeState>,
chatItems: List<ChatItem>,
useLinkPreviews: Boolean,
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
acceptCall: (Contact) -> Unit
) {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
val keyboardState by getKeyboardState()
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
mutableStateOf(CIListState(false, chatItems.count(), keyboardState))
}
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
LazyColumn(state = listState) {
items(chatItems) { cItem ->
ChatItemView(cItem, uriHandler)
itemsIndexed(chatItems) { i, cItem ->
if (i == 0) {
Spacer(Modifier.size(8.dp))
}
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i > 0) chatItems[i - 1] else null
val member = cItem.chatDir.groupMember
val showMember = showMemberImage(member, prevItem)
Row(Modifier.padding(start = 8.dp, end = 66.dp)) {
if (showMember) {
val contactId = member.memberContactId
if (contactId == null) {
MemberImage(member)
} else {
Box(
Modifier
.clip(CircleShape)
.clickable { openDirectChat(contactId) }
) {
MemberImage(member)
}
}
Spacer(Modifier.size(4.dp))
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, 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,
)
) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
}
}
}
val len = chatItems.count()
if (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount) {
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
scope.launch {
ciListState.value = CIListState(true, len, keyboardState)
listState.animateScrollToItem(len - 1)
@@ -174,6 +328,16 @@ fun ChatItemsList(chatItems: List<ChatItem>) {
}
}
fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd ||
(prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId)
}
@Composable
fun MemberImage(member: GroupMember) {
ProfileImage(38.dp, member.memberProfile.image)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -190,26 +354,85 @@ fun PreviewChatLayout() {
ChatItem.getSampleData(
2, CIDirection.DirectRcv(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
3, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
ChatItem.getDeletedContentSampleData(3),
ChatItem.getSampleData(
4, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
5, CIDirection.DirectRcv(), Clock.System.now(), "hello"
5, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
useLinkPreviews = true,
back = {},
info = {},
sendMessage = {}
openDirectChat = {},
deleteMessage = { _, _ -> },
receiveFile = {},
startCall = {},
acceptCall = { _ -> }
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewGroupChatLayout() {
SimpleXTheme {
val chatItems = listOf(
ChatItem.getSampleData(
1, CIDirection.GroupSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
2, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
),
ChatItem.getDeletedContentSampleData(3),
ChatItem.getSampleData(
4, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
5, CIDirection.GroupSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
)
)
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
useLinkPreviews = true,
back = {},
info = {},
openDirectChat = {},
deleteMessage = { _, _ -> },
receiveFile = {},
startCall = {},
acceptCall = { _ -> }
)
}
}
@@ -0,0 +1,61 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
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 (isSystemInDarkTheme()) 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
)
}
}
@@ -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)
)
}
}
}
}
@@ -0,0 +1,488 @@
package chat.simplex.app.views.chat
import ComposeFileView
import ComposeImageView
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.ImageDecoder
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.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 java.io.File
sealed class ComposePreview {
object NoPreview: ComposePreview()
class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
class ImagePreview(val image: String): ComposePreview()
class FilePreview(val fileName: String): ComposePreview()
}
sealed class ComposeContextItem {
object NoContextItem: ComposeContextItem()
class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
class EditingItem(val chatItem: ChatItem): ComposeContextItem()
}
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
}
}
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 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 galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
chosenImage.value = bitmap
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
}
val 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 -> {
galleryLauncher.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
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)
}
}
}
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
}
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
)
}
}
}
@@ -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 = {}
)
}
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -16,25 +17,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.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
@Composable
fun SendMsgView(sendMessage: (String) -> Unit) {
var cmd by remember { mutableStateOf("") }
fun SendMsgView(
composeState: MutableState<ComposeState>,
sendMessage: () -> Unit,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
val cs = composeState.value
BasicTextField(
value = cmd,
onValueChange = { cmd = it },
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
value = cs.message,
onValueChange = onMessageChange,
textStyle = textStyle.value,
maxLines = 16,
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
),
modifier = Modifier.padding(8.dp),
modifier = Modifier.padding(vertical = 8.dp),
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
@@ -54,23 +64,35 @@ fun SendMsgView(sendMessage: (String) -> Unit) {
) {
innerTextField()
}
val color = if (cmd.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray
Icon(
Icons.Outlined.ArrowUpward,
"Send Message",
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cmd.isNotEmpty()) {
sendMessage(cmd)
cmd = ""
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()
}
}
}
)
)
}
}
}
}
@@ -85,9 +107,56 @@ fun SendMsgView(sendMessage: (String) -> Unit) {
)
@Composable
fun PreviewSendMsgView() {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
SimpleXTheme {
SendMsgView(
sendMessage = { msg -> println(msg) }
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
)
}
}
@@ -0,0 +1,156 @@
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)
}
}
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)
// }
// }
//}
@@ -0,0 +1,211 @@
package chat.simplex.app.views.chat.item
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
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 (isSystemInDarkTheme()) 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 (isSystemInDarkTheme()) 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(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
}
}
@@ -0,0 +1,121 @@
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.CIFile
import chat.simplex.app.model.CIFileStatus
import chat.simplex.app.views.helpers.*
@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
)
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,
)
}
Box(contentAlignment = Alignment.TopEnd) {
val context = LocalContext.current
val imageBitmap: Bitmap? = getLoadedImage(context, file)
if (imageBitmap != null) {
imageView(imageBitmap, onClick = {
if (getLoadedFilePath(context, file) != null) {
ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) }
}
})
} else {
imageView(base64ToBitmap(image), onClick = {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
receiveFile(file.fileId)
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()
}
}
@@ -1,21 +1,65 @@
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.*
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.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimplexBlue
import kotlinx.datetime.Clock
@Composable
fun CIMetaView(chatItem: ChatItem) {
Text(
chatItem.timestampText,
color = HighOrLowlight,
fontSize = 14.sp
)
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 = SimplexBlue)
}
else -> {}
}
}
@Preview
@@ -27,3 +71,88 @@ fun PreviewCIMetaView() {
)
)
}
@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()
)
}
@@ -1,44 +1,233 @@
package chat.simplex.app.views.chat.item
import android.content.Context
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
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.LocalContext
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.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(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
fun ChatItemView(
user: User,
cInfo: ChatInfo,
cItem: ChatItem,
composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
showMember: Boolean = false,
useLinkPreviews: Boolean,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (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()
.padding(
start = if (sent) 60.dp else 16.dp,
end = if (sent) 16.dp else 60.dp,
),
.fillMaxWidth(),
contentAlignment = alignment,
) {
TextItemView(chatItem, uriHandler)
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 {
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
}
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)
}
}
}
}
@Composable
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
DropdownMenuItem(onClick) {
Row {
Text(
text,
modifier = Modifier
.fillMaxWidth()
.weight(1F),
color = color
)
Icon(icon, text, tint = color)
}
}
}
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
AlertManager.shared.showAlertDialogButtons(
title = 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,
) {
Button(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
AlertManager.shared.hideAlert()
}) { Text(stringResource(R.string.for_me_only)) }
if (chatItem.meta.editable) {
Spacer(Modifier.padding(horizontal = 4.dp))
Button(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
AlertManager.shared.hideAlert()
}) { Text(stringResource(R.string.for_everybody)) }
}
}
}
)
}
@Preview
@Composable
fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
chatItem = ChatItem.getSampleData(
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,
deleteMessage = { _, _ -> },
receiveFile = {},
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,
deleteMessage = { _, _ -> },
receiveFile = {},
acceptCall = { _ -> }
)
}
}
@@ -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()
)
}
}
@@ -0,0 +1,42 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.ChatItem
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
@Composable
fun EmojiItemView(chatItem: ChatItem) {
Column(
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(chatItem.content.text)
CIMetaView(chatItem)
}
}
@Composable
fun EmojiText(text: String) {
val s = text.trim()
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
}
private fun isSimpleEmoji(c: Int): Boolean = c > 0x238C
fun isEmoji(c: Int): Boolean = isSimpleEmoji(c) // || isCombinedIntoEmoji(c)
// TODO count perceived emojis, possibly using icu4j
fun isShortEmoji(str: String): Boolean {
val s = str.trim()
return s.codePoints().count() in 1..5 && s.codePoints().allMatch(::isEmoji)
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,55 @@
import android.graphics.Bitmap
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.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
@Composable
fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) {
BackHandler(onBack = close)
Column(
Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = close)
) {
var scale by remember { mutableStateOf(1f) }
var translationX by remember { mutableStateOf(0f) }
var translationY by remember { mutableStateOf(0f) }
Image(
imageBitmap.asImageBitmap(),
contentDescription = stringResource(R.string.image_descr),
contentScale = ContentScale.Fit,
modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = translationX,
translationY = translationY,
)
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, gestureZoom, _ ->
scale = (scale * gestureZoom).coerceIn(1f, 20f)
if (scale > 1) {
translationX += pan.x * scale
translationY += pan.y * scale
} else {
translationX = 0f
translationY = 0f
}
}
)
}
.fillMaxSize(),
)
}
}
@@ -0,0 +1,65 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
Surface(
Modifier.clickable(onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.alert_title_skipped_messages),
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
)
}),
shape = RoundedCornerShape(18.dp),
color = ReceivedColorLight,
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
buildAnnotatedString {
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun IntegrityErrorItemViewView() {
SimpleXTheme {
IntegrityErrorItemView(
ChatItem.getDeletedContentSampleData()
)
}
}
@@ -1,48 +1,17 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
// TODO move to theme
val SentColorLight = Color(0x1E45B8FF)
val ReceivedColorLight = Color(0x1EB1B0B5)
@Composable
fun TextItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
Surface(
shape = RoundedCornerShape(18.dp),
color = if (sent) SentColorLight else ReceivedColorLight
) {
Box(
modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp)
) {
Box(contentAlignment = Alignment.BottomEnd) {
MarkdownText(chatItem, uriHandler = uriHandler, groupMemberBold = true)
CIMetaView(chatItem)
}
}
}
}
import chat.simplex.app.model.*
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
@@ -56,33 +25,46 @@ fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMembe
}
}
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 (
chatItem: ChatItem,
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,
groupMemberBold: Boolean = false,
senderBold: Boolean = false,
modifier: Modifier = Modifier
) {
if (chatItem.formattedText == null) {
val reserve = if (edited) " " else " "
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
appendGroupMember(this, chatItem, groupMemberBold)
append(chatItem.content.text)
withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
}
SelectionContainer {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
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 {
appendGroupMember(this, chatItem, groupMemberBold)
for (ft in chatItem.formattedText) {
appendSender(this, sender, senderBold)
for (ft in formattedText) {
if (ft.format == null) append(ft.text)
else {
val link = ft.link
if (link != null) {
hasLinks = true
withAnnotation(tag = "URL", annotation = link) {
withStyle(ft.format.style) { append(ft.text) }
}
@@ -91,60 +73,17 @@ fun MarkdownText (
}
}
}
withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
if (uriHandler != null) {
SelectionContainer {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
}
)
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
}
)
} else {
SelectionContainer {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
}
}
@Preview
@Composable
fun PreviewTextItemViewSnd() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
}
@Preview
@Composable
fun PreviewTextItemViewRcv() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
}
}
@Preview
@Composable
fun PreviewTextItemViewLong() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.now(),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
)
)
}
}
@@ -1,4 +1,4 @@
package chat.simplex.app.views.chat
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
@@ -10,11 +10,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.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)
@@ -27,18 +31,13 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
) {
val uriHandler = LocalUriHandler.current
Text("Thank you for installing SimpleX Chat!")
Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp)
Text(
buildAnnotatedString {
append("You can ")
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
append("connect to SimpleX team")
}
append(".")
},
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
modifier = Modifier.clickable(onClick = {
uriHandler.openUri(simplexTeamUri)
})
}),
lineHeight = 22.sp
)
Column(
@@ -47,33 +46,24 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
"To start a new chat",
style = MaterialTheme.typography.h2
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("Tap button")
Text(stringResource(R.string.chat_help_tap_button))
Icon(
Icons.Outlined.PersonAdd,
"Add Contact",
stringResource(R.string.add_contact),
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
)
Text("above, then:")
Text(stringResource(R.string.above_then_preposition_continuation))
}
Text(
buildAnnotatedString {
withStyle(bold) { append("Add new contact") }
append(": to create your one-time QR Code for your contact.")
}
)
Text(
buildAnnotatedString {
withStyle(bold) { append("Scan QR code") }
append(": to connect to your contact who shows QR code to you.")
}
)
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(
@@ -81,24 +71,10 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text("To connect via link", style = MaterialTheme.typography.h2)
Text("If you received SimpleX Chat invitation link you can open it in your browser:")
Text(
buildAnnotatedString {
append("\uD83D\uDCBB desktop: scan displayed QR code from the app, via ")
withStyle(bold) { append("Scan QR code") }
append(".")
}
)
Text(
buildAnnotatedString {
append("\uD83D\uDCF1 mobile: tap ")
withStyle(bold) { append("Open in mobile app") }
append(", then tap ")
withStyle(bold) { append("Connect") }
append(" in the app.")
}
)
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)
}
}
}
@@ -112,6 +88,6 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
@Composable
fun PreviewChatHelpLayout() {
SimpleXTheme {
ChatHelpView({})
ChatHelpView {}
}
}
@@ -2,76 +2,302 @@ package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.Divider
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.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.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.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.ui.theme.WarningOrange
import chat.simplex.app.views.chat.clearChatDialog
import chat.simplex.app.views.chat.deleteContactDialog
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) {
ChatListNavLinkLayout(
chat = chat,
click = {
if (chat.chatInfo is ChatInfo.ContactRequest) {
contactRequestAlertDialog(chat.chatInfo, chatModel)
} else {
withApi { openChat(chatModel, chat.chatInfo) }
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, stopped) },
click = { openOrPendingChat(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
stopped
)
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, stopped) },
click = { openOrPendingChat(chat.chatInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
stopped
)
is ChatInfo.ContactRequest ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactRequestView(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 openOrPendingChat(chatInfo: ChatInfo, chatModel: ChatModel) {
if (chatInfo.ready) {
withApi { openChat(chatInfo, chatModel) }
} else {
pendingContactAlertDialog(chatInfo, 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
}
}
@Composable
fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
ItemAction(
stringResource(R.string.mark_read),
Icons.Outlined.Check,
onClick = {
markChatRead(chat, chatModel)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
showMenu.value = false
}
}
)
}
ItemAction(
stringResource(R.string.clear_verb),
Icons.Outlined.Restore,
onClick = {
clearChatDialog(chat.chatInfo, chatModel)
showMenu.value = false
},
color = WarningOrange
)
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteContactDialog(chat.chatInfo as ChatInfo.Direct, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) {
val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId)
if (chat != null) {
chatModel.chatItems = chat.chatItems.toMutableStateList()
chatModel.chatId.value = cInfo.id
@Composable
fun GroupMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
ItemAction(
stringResource(R.string.mark_read),
Icons.Outlined.Check,
onClick = {
markChatRead(chat, chatModel)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
showMenu.value = false
}
)
}
ItemAction(
stringResource(R.string.clear_verb),
Icons.Outlined.Restore,
onClick = {
clearChatDialog(chat.chatInfo, chatModel)
showMenu.value = false
},
color = WarningOrange
)
}
@Composable
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.accept_contact_button),
Icons.Outlined.Check,
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) {
chatModel.markChatItemsRead(chat.chatInfo)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
)
}
}
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
title = "Accept connection request?",
text = "If you choose to reject sender will NOT be notified",
confirmText = "Accept",
onConfirm = {
withApi {
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
if (contact != null) {
val chat = Chat(ChatInfo.Direct(contact), listOf())
chatModel.replaceChat(contactRequest.id, chat)
title = generalGetString(R.string.accept_connection_request__question),
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
confirmText = generalGetString(R.string.accept_contact_button),
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,
) {
Button(onClick = {
AlertManager.shared.hideAlert()
deleteContactConnectionAlert(connection, chatModel)
}) {
Text(stringResource(R.string.delete_verb))
}
Spacer(Modifier.padding(horizontal = 4.dp))
Button(onClick = { AlertManager.shared.hideAlert() }) {
Text(stringResource(R.string.ok))
}
}
},
dismissText = "Reject",
onDismiss = {
withApi {
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
chatModel.removeChat(contactRequest.id)
}
}
)
}
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),
)
}
@Composable
fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = click)
.height(88.dp)
) {
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()
@@ -80,10 +306,17 @@ fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) {
.padding(end = 12.dp),
verticalAlignment = Alignment.Top
) {
if (chat.chatInfo is ChatInfo.ContactRequest) {
ContactRequestView(chat)
} else {
ChatPreviewView(chat)
chatLinkPreview()
}
if (dropdownMenuItems != null) {
Box(Modifier.padding(horizontal = 16.dp)) {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
dropdownMenuItems()
}
}
}
}
@@ -100,19 +333,27 @@ fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) {
fun PreviewChatListNavLinkDirect() {
SimpleXTheme {
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = listOf(
ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.now(),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
)
),
chatStats = Chat.ChatStats()
),
click = {}
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()
),
stopped = false
)
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) },
stopped = false
)
}
}
@@ -127,19 +368,27 @@ fun PreviewChatListNavLinkDirect() {
fun PreviewChatListNavLinkGroup() {
SimpleXTheme {
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = listOf(
ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.now(),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
)
),
chatStats = Chat.ChatStats()
),
click = {}
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()
),
stopped = false
)
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) },
stopped = false
)
}
}
@@ -154,12 +403,13 @@ fun PreviewChatListNavLinkGroup() {
fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.ContactRequest.sampleData,
chatItems = listOf(),
chatStats = Chat.ChatStats()
),
click = {}
chatLinkPreview = {
ContactRequestView(ChatInfo.ContactRequest.sampleData)
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) },
stopped = false
)
}
}
@@ -1,23 +1,30 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.PersonAdd
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.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.chat.ChatHelpView
import chat.simplex.app.ui.theme.ToolbarDark
import chat.simplex.app.ui.theme.ToolbarLight
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
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
@@ -60,11 +67,14 @@ fun scaffoldController(): ScaffoldController {
}
@Composable
fun ChatListView(chatModel: ChatModel) {
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()
}
BottomSheetScaffold(
scaffoldState = scaffoldCtrl.state,
drawerContent = { SettingsView(chatModel) },
drawerContent = { SettingsView(chatModel, setPerformLA) },
sheetPeekHeight = 0.dp,
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
@@ -75,12 +85,12 @@ fun ChatListView(chatModel: ChatModel) {
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
ChatListToolbar(scaffoldCtrl)
ChatListToolbar(scaffoldCtrl, stopped)
Divider()
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel)
} else {
val user = chatModel.currentUser.value
Help(scaffoldCtrl, displayName = user?.profile?.displayName)
MakeConnection(chatModel)
}
}
if (scaffoldCtrl.expanded.value) {
@@ -96,76 +106,54 @@ fun ChatListView(chatModel: ChatModel) {
}
@Composable
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
Column(
Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(
text = if (displayName != null) "Welcome ${displayName}!" else "Welcome!",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
ChatHelpView({ scaffoldCtrl.toggleSheet() })
Row(
Modifier.padding(top = 30.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"This text is available in settings",
color = MaterialTheme.colors.onBackground
)
Icon(
Icons.Outlined.Settings,
"Settings",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
)
}
}
}
@Composable
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
.padding(horizontal = 8.dp)
.height(60.dp)
) {
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
Icon(
Icons.Outlined.Menu,
"Settings",
stringResource(R.string.icon_descr_settings),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
Text(
"Your chats",
stringResource(R.string.your_chats),
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.Bold,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(5.dp)
)
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.PersonAdd,
"Add Contact",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
if (!stopped) {
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.PersonAdd,
stringResource(R.string.add_contact),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
} else {
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
Icon(
Icons.Filled.Report,
generalGetString(R.string.chat_is_stopped_indication),
tint = Color.Red,
modifier = Modifier.padding(10.dp)
)
}
}
}
}
@Composable
fun ChatList(chatModel: ChatModel) {
Divider(Modifier.padding(horizontal = 8.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
@@ -2,55 +2,66 @@ package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ErrorOutline
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.model.Chat
import chat.simplex.app.model.getTimestampText
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.badgeLayout
@Composable
fun ChatPreviewView(chat: Chat) {
fun ChatPreviewView(chat: Chat, stopped: Boolean) {
Row {
ChatInfoImage(chat, size = 72.dp)
val cInfo = chat.chatInfo
ChatInfoImage(cInfo, size = 72.dp)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
chat.chatInfo.chatViewName,
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
color = if (cInfo.ready) Color.Unspecified else HighOrLowlight
)
if (chat.chatItems.count() > 0) {
MarkdownText(
chat.chatItems.last(),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (cInfo.ready) {
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 (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
)
}
} else {
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
}
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
Box(
contentAlignment = Alignment.TopEnd
) {
Text(
ts,
@@ -60,22 +71,57 @@ fun ChatPreviewView(chat: Chat) {
)
val n = chat.chatStats.unreadCount
if (n > 0) {
Text(
if (n < 1000) "$n" else "${n / 1000}k",
color = MaterialTheme.colors.onPrimary,
fontSize = 14.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.align(Alignment.End)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
)
Box(
Modifier.padding(top = 24.dp),
contentAlignment = Alignment.Center
) {
Text(
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
color = MaterialTheme.colors.onPrimary,
fontSize = 11.sp,
modifier = Modifier
.background(if (stopped) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
)
}
}
if (cInfo is ChatInfo.Direct) {
Box(
Modifier.padding(top = 52.dp),
contentAlignment = Alignment.Center
) {
ChatStatusImage(chat)
}
}
}
}
}
@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,
@@ -85,6 +131,6 @@ fun ChatPreviewView(chat: Chat) {
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData)
ChatPreviewView(Chat.sampleData, stopped = false)
}
}
@@ -0,0 +1,55 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddLink
import androidx.compose.material.icons.outlined.Link
import androidx.compose.runtime.Composable
import androidx.compose.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 (isSystemInDarkTheme()) 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)
)
}
}
}
@@ -1,42 +1,41 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.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.model.Chat
import chat.simplex.app.R
import chat.simplex.app.model.ChatInfo
import chat.simplex.app.model.getTimestampText
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ChatInfoImage
@Composable
fun ContactRequestView(chat: Chat) {
fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
Row {
ChatInfoImage(chat, size = 72.dp)
ChatInfoImage(contactRequest, size = 72.dp)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
chat.chatInfo.chatViewName,
contactRequest.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary
)
Text(
"wants to connect to you!",
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
}
val ts = getTimestampText(chat.chatInfo.createdAt)
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top
@@ -0,0 +1,145 @@
package chat.simplex.app.views.database
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.SettingsActionItem
import chat.simplex.app.views.usersettings.SettingsSectionView
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,
) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
Text(
title,
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SettingsSectionView(stringResource(R.string.chat_database_section)) {
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.save_archive),
saveArchive,
textColor = MaterialTheme.colors.primary
)
divider()
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()))
SettingsSectionFooter(
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 = {}
)
}
}
@@ -0,0 +1,471 @@
package chat.simplex.app.views.database
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.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.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.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
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.*
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(false) }
val prefs = m.controller.appPrefs
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)
}
}
LaunchedEffect(m.chatRunning) {
runChat.value = m.chatRunning.value ?: true
}
Box(
Modifier.fillMaxSize(),
) {
DatabaseLayout(
progressIndicator.value,
runChat.value,
m.chatDbChanged.value,
importArchiveLauncher,
chatArchiveName,
chatArchiveTime,
chatLastStart,
startChat = { startChat(m, runChat) },
stopChatAlert = { stopChatAlert(m, runChat) },
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
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,
chatDbChanged: Boolean,
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
startChat: () -> Unit,
stopChatAlert: () -> Unit,
exportArchive: () -> Unit,
deleteChatAlert: () -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val stopped = !runChat
val operationsDisabled = !stopped || progressIndicator || chatDbChanged
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
Text(
stringResource(R.string.your_chat_database),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SettingsSectionView(stringResource(R.string.run_chat_section)) {
RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert)
}
Spacer(Modifier.height(30.dp))
SettingsSectionView(stringResource(R.string.chat_database_section)) {
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.export_database),
exportArchive,
textColor = MaterialTheme.colors.primary,
disabled = operationsDisabled
)
divider()
SettingsActionItem(
Icons.Outlined.FileDownload,
stringResource(R.string.import_database),
{ importArchiveLauncher.launch("application/zip") },
textColor = Color.Red,
disabled = operationsDisabled
)
divider()
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
)
divider()
}
SettingsActionItem(
Icons.Outlined.DeleteForever,
stringResource(R.string.delete_database),
deleteChatAlert,
textColor = Color.Red,
disabled = operationsDisabled
)
}
SettingsSectionFooter(
if (chatDbChanged) {
stringResource(R.string.restart_the_app_to_use_new_chat_database)
} else {
if (stopped) {
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
} else {
stringResource(R.string.stop_chat_to_enable_database_actions)
}
}
)
}
}
@Composable
fun RunChatSetting(
runChat: Boolean,
stopped: Boolean,
chatDbChanged: Boolean,
startChat: () -> Unit,
stopChatAlert: () -> Unit
) {
SettingsItemView() {
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 (chatDbChanged) HighOrLowlight else if (stopped) Color.Red else MaterialTheme.colors.primary
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
chatRunningText,
Modifier.padding(end = 24.dp),
color = if (chatDbChanged) HighOrLowlight else Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = runChat,
onCheckedChange = { runChatSwitch ->
if (runChatSwitch) {
startChat()
} else {
stopChatAlert()
}
},
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
enabled = !chatDbChanged
)
}
}
}
@Composable
fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
}
@Composable
fun SettingsSectionFooter(text: String) {
Text(text, color = HighOrLowlight, modifier = Modifier.padding(start = 16.dp, top = 5.dp).fillMaxWidth(0.9F), fontSize = 12.sp)
}
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>) {
withApi {
try {
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
m.controller.appPrefs.chatLastStart.set(Clock.System.now())
} 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>) {
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 = { stopChat(m, runChat) },
onDismiss = { runChat.value = true }
)
}
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>) {
withApi {
try {
m.controller.apiStopChat()
runChat.value = false
m.chatRunning.value = false
} 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)
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()
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 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,
chatDbChanged = false,
importArchiveLauncher = rememberGetContentLauncher {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
startChat = {},
stopChatAlert = {},
exportArchive = {},
deleteChatAlert = {},
showSettingsModal = { {} }
)
}
}
@@ -4,6 +4,7 @@ import android.util.Log
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.R
import chat.simplex.app.TAG
class AlertManager {
@@ -21,12 +22,28 @@ class AlertManager {
alertView.value = null
}
fun showAlertDialogButtons(
title: String,
text: String? = null,
buttons: @Composable () -> Unit,
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = this::hideAlert,
title = { Text(title) },
text = alertText,
buttons = buttons
)
}
}
fun showAlertDialog(
title: String,
text: String? = null,
confirmText: String = "Ok",
confirmText: String = generalGetString(R.string.ok),
onConfirm: (() -> Unit)? = null,
dismissText: String = "Cancel",
dismissText: String = generalGetString(R.string.cancel_verb),
onDismiss: (() -> Unit)? = null
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
@@ -53,7 +70,7 @@ class AlertManager {
fun showAlertMsg(
title: String, text: String? = null,
confirmText: String = "Ok", onConfirm: (() -> Unit)? = null
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
@@ -1,6 +1,8 @@
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
@@ -8,24 +10,51 @@ import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.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.model.Chat
import chat.simplex.app.R
import chat.simplex.app.model.ChatInfo
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun ChatInfoImage(chat: Chat, size: Dp) {
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) {
val icon =
if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
else Icons.Filled.AccountCircle
ProfileImage(size, chatInfo.image, icon)
}
@Composable
fun ProfileImage(
size: Dp,
image: String? = null,
icon: ImageVector = Icons.Filled.AccountCircle,
color: Color = MaterialTheme.colors.secondary
) {
Box(Modifier.size(size)) {
Icon(icon,
contentDescription = "Avatar Placeholder",
tint = MaterialTheme.colors.secondary,
modifier = Modifier.fillMaxSize()
)
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)
)
}
}
}
@@ -34,7 +63,7 @@ fun ChatInfoImage(chat: Chat, size: Dp) {
fun PreviewChatInfoImage() {
SimpleXTheme {
ChatInfoImage(
chat = Chat(chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf()),
chatInfo = ChatInfo.Direct.sampleData,
size = 55.dp
)
}
@@ -0,0 +1,54 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.views.newchat.ActionButton
sealed class AttachmentOption {
object TakePhoto: AttachmentOption()
object PickImage: AttachmentOption()
object PickFile: AttachmentOption()
}
@Composable
fun ChooseAttachmentView(
attachmentOption: MutableState<AttachmentOption?>,
hide: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.onFocusChanged { focusState ->
if (!focusState.hasFocus) hide()
}
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 30.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
attachmentOption.value = AttachmentOption.TakePhoto
hide()
}
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
attachmentOption.value = AttachmentOption.PickImage
hide()
}
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
attachmentOption.value = AttachmentOption.PickFile
hide()
}
}
}
}
@@ -8,8 +8,10 @@ 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
@@ -24,7 +26,7 @@ fun CloseSheetBar(close: () -> Unit) {
IconButton(onClick = close) {
Icon(
Icons.Outlined.Close,
"Close button",
stringResource(R.string.icon_descr_close_button),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
@@ -0,0 +1,213 @@
package chat.simplex.app.views.helpers
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.*
import android.net.Uri
import android.provider.MediaStore
import android.util.Base64
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.CallSuper
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
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.views.newchat.ActionButton
import java.io.ByteArrayOutputStream
import java.io.File
import kotlin.math.min
import kotlin.math.sqrt
// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
fun cropToSquare(image: Bitmap): Bitmap {
var xOffset = 0
var yOffset = 0
val side = min(image.height, image.width)
if (image.height < image.width) {
xOffset = (image.width - side) / 2
} else {
yOffset = (image.height - side) / 2
}
return Bitmap.createBitmap(image, xOffset, yOffset, side, side)
}
fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String {
var img = image
var str = compressImageStr(img)
while (str.length > maxDataSize) {
val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble())
val clippedRatio = min(ratio, 2.0)
val width = (img.width.toDouble() / clippedRatio).toInt()
val height = img.height * width / img.width
img = Bitmap.createScaledBitmap(img, width, height, true)
str = compressImageStr(img)
}
return str
}
private fun compressImageStr(bitmap: Bitmap): String {
return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP)
}
fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream {
var img = image
var stream = compressImageData(img)
while (stream.size() > maxDataSize) {
val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble())
val clippedRatio = min(ratio, 2.0)
val width = (img.width.toDouble() / clippedRatio).toInt()
val height = img.height * width / img.width
img = Bitmap.createScaledBitmap(img, width, height, true)
stream = compressImageData(img)
}
return stream
}
private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream)
return stream
}
fun base64ToBitmap(base64ImageString: String): Bitmap {
val imageString = base64ImageString
.removePrefix("data:image/png;base64,")
.removePrefix("data:image/jpg;base64,")
val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
}
class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
private var uri: Uri? = null
private var tmpFile: File? = null
lateinit var externalContext: Context
@CallSuper
override fun createIntent(context: Context, input: Void?): Intent {
externalContext = context
tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
}
override fun getSynchronousResult(
context: Context,
input: Void?
): SynchronousResult<Bitmap?>? = null
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
return if (resultCode == Activity.RESULT_OK && uri != null) {
val source = ImageDecoder.createSource(externalContext.contentResolver, uri!!)
val bitmap = ImageDecoder.decodeBitmap(source)
tmpFile?.delete()
bitmap
} else {
Log.e(TAG, "Getting image from camera cancelled or failed.")
tmpFile?.delete()
null
}
}
}
//class GetGalleryContent: ActivityResultContracts.GetContent() {
// override fun createIntent(context: Context, input: String): Intent {
// super.createIntent(context, input)
// return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
// }
//}
//@Composable
//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
@Composable
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher<Void?, Bitmap?> =
rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
@Composable
fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher<String, Boolean> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb)
@Composable
fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
@Composable
fun GetImageBottomSheet(
imageBitmap: MutableState<Bitmap?>,
onImageChange: (Bitmap) -> Unit,
hideBottomSheet: () -> Unit
) {
val context = LocalContext.current
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
imageBitmap.value = bitmap
onImageChange(bitmap)
}
}
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
if (bitmap != null) {
imageBitmap.value = bitmap
onImageChange(bitmap)
}
}
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launch(null)
hideBottomSheet()
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.onFocusChanged { focusState ->
if (!focusState.hasFocus) hideBottomSheet()
}
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 30.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launch(null)
hideBottomSheet()
}
else -> {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
galleryLauncher.launch("image/*")
hideBottomSheet()
}
}
}
}
@@ -0,0 +1,163 @@
package chat.simplex.app.views.helpers
import android.content.res.Configuration
import android.graphics.BitmapFactory
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.layout.ContentScale
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.LinkPreview
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.SentColorLight
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
private const val OG_SELECT_QUERY = "meta[property^=og:]"
suspend fun getLinkPreview(url: String): LinkPreview? {
return withContext(Dispatchers.IO) {
try {
val response = Jsoup.connect(url)
.ignoreContentType(true)
.timeout(10000)
.followRedirects(true)
.execute()
val doc = response.parse()
val ogTags = doc.select(OG_SELECT_QUERY)
val imageUri = ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content")
if (imageUri != null) {
try {
val stream = java.net.URL(imageUri).openStream()
val image = resizeImageToStrSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000)
// TODO add once supported in iOS
// val description = ogTags.firstOrNull {
// it.attr("property") == "og:description"
// }?.attr("content") ?: ""
val title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content")
if (title != null) {
return@withContext LinkPreview(url, title, description = "", image)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return@withContext null
}
}
@Composable
fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
) {
if (linkPreview == null) {
Box(
Modifier.fillMaxWidth().weight(1f).height(60.dp).padding(start = 16.dp),
contentAlignment = Alignment.CenterStart
) {
CircularProgressIndicator(
Modifier.size(16.dp),
color = HighOrLowlight,
strokeWidth = 2.dp
)
}
} else {
val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap()
Image(
imageBitmap,
stringResource(R.string.image_descr_link_preview),
modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp)
)
Column(Modifier.fillMaxWidth().weight(1F)) {
Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body2
)
}
}
IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.icon_descr_cancel_link_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
@Composable
fun ChatItemLinkView(linkPreview: LinkPreview) {
Column {
Image(
base64ToBitmap(linkPreview.image).asImageBitmap(),
stringResource(R.string.image_descr_link_preview),
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.FillWidth,
)
Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
Text(linkPreview.title, maxLines = 3, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp, modifier = Modifier.padding(bottom = 4.dp))
if (linkPreview.description != "") {
Text(linkPreview.description, maxLines = 12, overflow = TextOverflow.Ellipsis, fontSize = 14.sp, lineHeight = 20.sp)
}
Text(linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, color = HighOrLowlight)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "ChatItemLinkView (Dark Mode)"
)
@Composable
fun PreviewChatItemLinkView() {
SimpleXTheme {
ChatItemLinkView(LinkPreview.sampleData)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "ComposeLinkView (Dark Mode)"
)
@Composable
fun PreviewComposeLinkView() {
SimpleXTheme {
ComposeLinkView(LinkPreview.sampleData) { -> }
}
}
@Preview(showBackground = true)
@Composable
fun PreviewComposeLinkViewLoading() {
SimpleXTheme {
ComposeLinkView(null) { -> }
}
}
@@ -0,0 +1,112 @@
package chat.simplex.app.views.helpers
import android.content.Context
import android.os.Build.VERSION.SDK_INT
import android.widget.Toast
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.*
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.R
sealed class LAResult {
object Success: LAResult()
class Error(val errString: CharSequence): LAResult()
object Failed: LAResult()
object Unavailable: LAResult()
}
fun authenticate(
promptTitle: String,
promptSubtitle: String,
activity: FragmentActivity,
completed: (LAResult) -> Unit
) {
when {
SDK_INT in 28..29 ->
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
SDK_INT > 29 ->
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
else ->
completed(LAResult.Unavailable)
}
}
private fun authenticateWithBiometricManager(
promptTitle: String,
promptSubtitle: String,
activity: FragmentActivity,
completed: (LAResult) -> Unit,
authenticators: Int
) {
val biometricManager = BiometricManager.from(activity)
when (biometricManager.canAuthenticate(authenticators)) {
BiometricManager.BIOMETRIC_SUCCESS -> {
val executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt(
activity,
executor,
object: BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString)
completed(LAResult.Error(errString))
}
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(result)
completed(LAResult.Success)
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
completed(LAResult.Failed)
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(promptTitle)
.setSubtitle(promptSubtitle)
.setAllowedAuthenticators(authenticators)
.setConfirmationRequired(false)
.build()
biometricPrompt.authenticate(promptInfo)
}
else -> {
completed(LAResult.Unavailable)
}
}
}
fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
generalGetString(R.string.auth_simplex_lock_turned_on),
generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
)
fun laErrorToast(context: Context, errString: CharSequence) = Toast.makeText(
context,
if (errString.isNotEmpty()) String.format(generalGetString(R.string.auth_error_w_desc), errString) else generalGetString(R.string.auth_error),
Toast.LENGTH_SHORT
).show()
fun laFailedToast(context: Context) = Toast.makeText(
context,
generalGetString(R.string.auth_failed),
Toast.LENGTH_SHORT
).show()
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
generalGetString(R.string.auth_unavailable),
generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
)
fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg(
generalGetString(R.string.auth_unavailable),
generalGetString(R.string.auth_device_authentication_is_disabled_turning_off)
)
@@ -1,4 +1,4 @@
package chat.simplex.app.views.newchat
package chat.simplex.app.views.helpers
import android.util.Log
import androidx.activity.compose.BackHandler
@@ -9,21 +9,22 @@ import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import chat.simplex.app.TAG
import chat.simplex.app.views.helpers.CloseSheetBar
@Composable
fun ModalView(close: () -> Unit, content: @Composable () -> Unit) {
fun ModalView(
close: () -> Unit,
background: Color = MaterialTheme.colors.background,
modifier: Modifier = Modifier.padding(horizontal = 16.dp),
content: @Composable () -> Unit,
) {
BackHandler(onBack = close)
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
Column {
Surface(Modifier.fillMaxSize()) {
Column(Modifier.background(background)) {
CloseSheetBar(close)
Box(Modifier.padding(horizontal = 16.dp)) { content() }
Box(modifier) { content() }
}
}
}
@@ -33,7 +34,7 @@ class ModalManager {
private val modalCount = mutableStateOf(0)
fun showModal(content: @Composable () -> Unit) {
showCustomModal { close -> ModalView(close, content) }
showCustomModal { close -> ModalView(close, content = content) }
}
fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
@@ -43,12 +44,16 @@ class ModalManager {
}
fun closeModal() {
if (!modalViews.isEmpty()) {
if (modalViews.isNotEmpty()) {
modalViews.removeAt(modalViews.count() - 1)
}
modalCount.value = modalViews.count()
}
fun closeModals() {
while (modalViews.isNotEmpty()) closeModal()
}
@Composable
fun showInView() {
if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal)
@@ -1,7 +1,18 @@
package chat.simplex.app.views.helpers
import android.content.Context
import android.content.Intent
import android.content.*
import android.net.Uri
import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat
import chat.simplex.app.R
import chat.simplex.app.model.CIFile
import java.io.BufferedOutputStream
import java.io.File
fun shareText(cxt: Context, text: String) {
val sendIntent: Intent = Intent().apply {
@@ -12,3 +23,55 @@ fun shareText(cxt: Context, text: String) {
val shareIntent = Intent.createChooser(sendIntent, null)
cxt.startActivity(shareIntent)
}
fun copyText(cxt: Context, text: String) {
val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java)
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
}
@Composable
fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
destination?.let {
val filePath = getLoadedFilePath(cxt, ciFile)
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()
}
}
}
)
fun saveImage(cxt: Context, ciFile: CIFile?) {
val filePath = getLoadedFilePath(cxt, ciFile)
val fileName = ciFile?.fileName
if (filePath != null && fileName != null) {
val values = ContentValues()
values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
values.put(MediaStore.MediaColumns.TITLE, fileName)
val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
uri?.let {
cxt.contentResolver.openOutputStream(uri)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
val file = File(filePath)
outputStream.write(file.readBytes())
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.image_saved), Toast.LENGTH_SHORT).show()
}
}
} else {
Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
}
}
@@ -3,6 +3,7 @@ package chat.simplex.app.ui.theme
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
@@ -18,14 +19,24 @@ import androidx.compose.ui.unit.dp
fun SimpleButton(text: String, icon: ImageVector,
color: Color = MaterialTheme.colors.primary,
click: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { click() }
) {
Icon(icon, text, tint = color,
modifier = Modifier.padding(horizontal = 10.dp)
SimpleButtonFrame(click) {
Icon(
icon, text, tint = color,
modifier = Modifier.padding(end = 8.dp)
)
Text(text, style = MaterialTheme.typography.caption, color = color)
Text(text, style = MaterialTheme.typography.caption, color = color)
}
}
@Composable
fun SimpleButtonFrame(click: () -> Unit, content: @Composable () -> Unit) {
Surface(shape = RoundedCornerShape(20.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable { click() }
.padding(8.dp)
) { content() }
}
}
@@ -0,0 +1,58 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.MaterialTheme
import androidx.compose.material.Surface
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.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.ui.theme.HighOrLowlight
@Composable
fun TextEditor(modifier: Modifier, text: MutableState<String>) {
BasicTextField(
value = text.value,
onValueChange = { text.value = it },
textStyle = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 14.sp,
color = MaterialTheme.colors.onBackground
),
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
modifier = modifier,
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
Row(
Modifier.background(MaterialTheme.colors.background),
verticalAlignment = Alignment.Top
) {
Box(
Modifier
.weight(1f)
.padding(vertical = 5.dp, horizontal = 7.dp)
) {
innerTextField()
}
}
}
}
)
}
@@ -1,13 +1,43 @@
package chat.simplex.app.views.helpers
import android.graphics.Rect
import android.content.Context
import android.content.res.Resources
import android.graphics.*
import android.graphics.Typeface
import android.net.Uri
import android.os.FileUtils
import android.provider.OpenableColumns
import android.text.Spanned
import android.text.SpannedString
import android.text.style.*
import android.util.Log
import android.view.ViewTreeObserver
import androidx.annotation.StringRes
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.*
import androidx.core.content.FileProvider
import androidx.core.text.HtmlCompat
import chat.simplex.app.BuildConfig
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.CIFile
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.log2
import kotlin.math.pow
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch { withContext(Dispatchers.Main, action) }
fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalScope, action)
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
scope.launch { withContext(Dispatchers.Main, action) }
enum class KeyboardState {
Opened, Closed
@@ -38,3 +68,280 @@ fun getKeyboardState(): State<KeyboardState> {
return keyboardState
}
// Resource to annotated string from
// https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources
fun generalGetString(id: Int): String {
// prefer stringResource in Composable items to retain preview abilities
return SimplexApp.context.getString(id)
}
@Composable
@ReadOnlyComposable
private fun resources(): Resources {
LocalConfiguration.current
return LocalContext.current.resources
}
fun Spanned.toHtmlWithoutParagraphs(): String {
return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE)
.substringAfter("<p dir=\"ltr\">").substringBeforeLast("</p>")
}
fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence {
val escapedArgs = args.map {
if (it is Spanned) it.toHtmlWithoutParagraphs() else it
}.toTypedArray()
val resource = SpannedString(getText(id))
val htmlResource = resource.toHtmlWithoutParagraphs()
val formattedHtml = String.format(htmlResource, *escapedArgs)
return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
@Composable
fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
val resources = resources()
val density = LocalDensity.current
return remember(id) {
val text = resources.getText(id)
spannableStringToAnnotatedString(text, density)
}
}
private fun spannableStringToAnnotatedString(
text: CharSequence,
density: Density,
): AnnotatedString {
return if (text is Spanned) {
with(density) {
buildAnnotatedString {
append((text.toString()))
text.getSpans(0, text.length, Any::class.java).forEach {
val start = text.getSpanStart(it)
val end = text.getSpanEnd(it)
when (it) {
is StyleSpan -> when (it.style) {
Typeface.NORMAL -> addStyle(
SpanStyle(
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal,
),
start,
end
)
Typeface.BOLD -> addStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Normal
),
start,
end
)
Typeface.ITALIC -> addStyle(
SpanStyle(
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Italic
),
start,
end
)
Typeface.BOLD_ITALIC -> addStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic
),
start,
end
)
}
is TypefaceSpan -> addStyle(
SpanStyle(
fontFamily = when (it.family) {
FontFamily.SansSerif.name -> FontFamily.SansSerif
FontFamily.Serif.name -> FontFamily.Serif
FontFamily.Monospace.name -> FontFamily.Monospace
FontFamily.Cursive.name -> FontFamily.Cursive
else -> FontFamily.Default
}
),
start,
end
)
is AbsoluteSizeSpan -> addStyle(
SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()),
start,
end
)
is RelativeSizeSpan -> addStyle(
SpanStyle(fontSize = it.sizeChange.em),
start,
end
)
is StrikethroughSpan -> addStyle(
SpanStyle(textDecoration = TextDecoration.LineThrough),
start,
end
)
is UnderlineSpan -> addStyle(
SpanStyle(textDecoration = TextDecoration.Underline),
start,
end
)
is SuperscriptSpan -> addStyle(
SpanStyle(baselineShift = BaselineShift.Superscript),
start,
end
)
is SubscriptSpan -> addStyle(
SpanStyle(baselineShift = BaselineShift.Subscript),
start,
end
)
is ForegroundColorSpan -> addStyle(
SpanStyle(color = Color(it.foregroundColor)),
start,
end
)
else -> addStyle(SpanStyle(color = Color.White), start, end)
}
}
}
}
} else {
AnnotatedString(text.toString())
}
}
// maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE: Long = 236700
const val MAX_FILE_SIZE: Long = 8000000
fun getFilesDirectory(context: Context): String {
return context.filesDir.toString()
}
fun getAppFilesDirectory(context: Context): String {
return "${getFilesDirectory(context)}/app_files"
}
fun getAppFilePath(context: Context, fileName: String): String {
return "${getAppFilesDirectory(context)}/$fileName"
}
fun getLoadedFilePath(context: Context, file: CIFile?): String? {
return if (file?.filePath != null && file.loaded) {
val filePath = getAppFilePath(context, file.filePath)
if (File(filePath).exists()) filePath else null
} else {
null
}
}
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
fun getLoadedImage(context: Context, file: CIFile?): Bitmap? {
val filePath = getLoadedFilePath(context, file)
return if (filePath != null) {
try {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r")
val fileDescriptor = parcelFileDescriptor?.fileDescriptor
val image = BitmapFactory.decodeFileDescriptor(fileDescriptor)
parcelFileDescriptor?.close()
image
} catch (e: Exception) {
null
}
} else {
null
}
}
fun getFileName(context: Context, uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
cursor.getString(nameIndex)
}
}
fun getFileSize(context: Context, uri: Uri): Long? {
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
cursor.getLong(sizeIndex)
}
}
fun saveImage(context: Context, image: Bitmap): String? {
return try {
val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.jpg")
val file = File(getAppFilePath(context, fileToSave))
val output = FileOutputStream(file)
dataResized.writeTo(output)
output.flush()
output.close()
fileToSave
} catch (e: Exception) {
Log.e(chat.simplex.app.TAG, "Util.kt saveImage error: ${e.message}")
null
}
}
fun saveFileFromUri(context: Context, uri: Uri): String? {
return try {
val inputStream = context.contentResolver.openInputStream(uri)
val fileToSave = getFileName(context, uri)
if (inputStream != null && fileToSave != null) {
val destFileName = uniqueCombine(context, fileToSave)
val destFile = File(getAppFilePath(context, destFileName))
FileUtils.copy(inputStream, FileOutputStream(destFile))
destFileName
} else {
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
null
}
} catch (e: Exception) {
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri error: ${e.message}")
null
}
}
fun uniqueCombine(context: Context, fileName: String): String {
fun tryCombine(fileName: String, n: Int): String {
val name = File(fileName).nameWithoutExtension
val ext = File(fileName).extension
val suffix = if (n == 0) "" else "_$n"
val f = "$name$suffix.$ext"
return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f
}
return tryCombine(fileName, 0)
}
fun formatBytes(bytes: Long): String {
if (bytes == 0.toLong()) {
return "0 bytes"
}
val bytesDouble = bytes.toDouble()
val k = 1000.toDouble()
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
val size = bytesDouble / k.pow(i)
val unit = units[i.toInt()]
return if (i <= 1) {
String.format("%.0f %s", size, unit)
} else {
String.format("%.2f %s", size, unit)
}
}
fun removeFile(context: Context, fileName: String): Boolean {
val file = File(getAppFilePath(context, fileName))
val fileDeleted = file.delete()
if (!fileDeleted) {
Log.e(chat.simplex.app.TAG, "Util.kt removeFile error")
}
return fileDeleted
}
@@ -10,12 +10,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
@@ -42,12 +43,12 @@ fun AddContactLayout(connReq: String, share: () -> Unit) {
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
"Add contact",
style = MaterialTheme.typography.h1,
stringResource(R.string.add_contact),
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
)
Text(
"Show QR code to your contact\nto scan from the app",
style = MaterialTheme.typography.h2.copy(fontSize = if(screenHeight > 600.dp) 26.sp else 20.sp),
stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
style = MaterialTheme.typography.h3,
textAlign = TextAlign.Center,
)
QRCode(
@@ -57,20 +58,14 @@ fun AddContactLayout(connReq: String, share: () -> Unit) {
.padding(vertical = 3.dp)
)
Text(
buildAnnotatedString {
append("If you cannot meet in person, you can ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
append(", or you can share the invitation link via any other channel.")
},
stringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption.copy(fontSize=if(screenHeight > 600.dp) 20.sp else 16.sp),
lineHeight = 22.sp,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = if(screenHeight > 600.dp) 16.dp else 8.dp)
.padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp)
)
SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share)
Spacer(Modifier.height(10.dp))
}
}
@@ -3,6 +3,7 @@ package chat.simplex.app.views.newchat
import android.Manifest
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.outlined.*
@@ -10,14 +11,17 @@ 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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chatlist.ScaffoldController
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.rememberPermissionState
@@ -38,75 +42,102 @@ fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) {
},
scanCode = {
newChatCtrl.collapse()
ModalManager.shared.showCustomModal { close -> ConnectContactView(chatModel, close) }
ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) }
cameraPermissionState.launchPermissionRequest()
},
pasteLink = {
newChatCtrl.collapse()
ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
}
)
}
@Composable
fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 48.dp),
horizontalArrangement = Arrangement.SpaceEvenly
fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
Text(
stringResource(R.string.add_contact_to_start_new_chat),
modifier = Modifier.padding(horizontal = 8.dp).padding(top = 32.dp)
)
Row(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
"Add contact", "(create QR code\nor link)",
Icons.Outlined.PersonAdd, click = addContact
)
}
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
"Scan QR code", "(in person or in video call)",
Icons.Outlined.QrCode, click = scanCode
)
}
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
"Create Group", "(coming soon!)",
Icons.Outlined.GroupAdd, disabled = true
)
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(top = 24.dp, bottom = 40.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
stringResource(R.string.create_one_time_link),
stringResource(R.string.to_share_with_your_contact),
Icons.Outlined.AddLink,
click = addContact
)
}
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
stringResource(R.string.paste_received_link),
stringResource(R.string.paste_received_link_from_clipboard),
Icons.Outlined.Article,
click = pasteLink
)
}
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
stringResource(R.string.scan_QR_code),
stringResource(R.string.in_person_or_in_video_call__bracketed),
Icons.Outlined.QrCode,
click = scanCode
)
}
}
}
}
@Composable
fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boolean = false,
fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}) {
Column(
Modifier
.clickable(onClick = click)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Icon(icon, text,
tint = tint,
modifier = Modifier
.size(40.dp)
.padding(bottom = 8.dp))
Text(text,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
color = tint,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(comment,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2
)
Surface(shape = RoundedCornerShape(18.dp)) {
Column(
Modifier
.clickable(onClick = click)
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Icon(icon, text,
tint = tint,
modifier = Modifier
.size(40.dp)
.padding(bottom = 8.dp))
if (text != null) {
Text(
text,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
color = tint,
modifier = Modifier.padding(bottom = 4.dp)
)
}
if (comment != null) {
Text(
comment,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2
)
}
}
}
}
@@ -116,7 +147,8 @@ fun PreviewNewChatSheet() {
SimpleXTheme {
NewChatSheetLayout(
addContact = {},
scanCode = {}
scanCode = {},
pasteLink = {}
)
}
}
@@ -0,0 +1,127 @@
package chat.simplex.app.views.newchat
import android.content.ClipboardManager
import android.content.res.Configuration
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.getSystemService
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
@Composable
fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
val connectionLink = remember { mutableStateOf("")}
val context = LocalContext.current
val clipboard = getSystemService(context, ClipboardManager::class.java)
BackHandler(onBack = close)
PasteToConnectLayout(
connectionLink = connectionLink,
pasteFromClipboard = {
connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as String
},
connectViaLink = { connReqUri ->
try {
val uri = Uri.parse(connReqUri)
withUriAction(uri) { action ->
connectViaUri(chatModel, action, uri)
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.invalid_connection_link),
text = generalGetString(R.string.this_string_is_not_a_connection_link)
)
}
close()
},
close = close
)
}
@Composable
fun PasteToConnectLayout(
connectionLink: MutableState<String>,
pasteFromClipboard: () -> Unit,
connectViaLink: (String) -> Unit,
close: () -> Unit
) {
ModalView(close) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.connect_via_link),
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
modifier = Modifier.padding(bottom = 16.dp)
)
Text(stringResource(R.string.paste_connection_link_below_to_connect))
Text(stringResource(R.string.profile_will_be_sent_to_contact_sending_link))
Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) {
TextEditor(Modifier.height(180.dp), text = connectionLink)
}
Row(
Modifier.fillMaxWidth().padding(bottom = 6.dp),
horizontalArrangement = Arrangement.Start,
) {
if (connectionLink.value == "") {
SimpleButton(text = "Paste", icon = Icons.Outlined.ContentPaste) {
pasteFromClipboard()
}
} else {
SimpleButton(text = "Clear", icon = Icons.Outlined.Clear) {
connectionLink.value = ""
}
}
Spacer(Modifier.weight(1f).fillMaxWidth())
SimpleButton(text = "Connect", icon = Icons.Outlined.Link) {
connectViaLink(connectionLink.value)
}
}
Text(annotatedStringResource(R.string.you_can_also_connect_by_clicking_the_link))
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun PreviewPasteToConnectTextbox() {
SimpleXTheme {
PasteToConnectLayout(
connectionLink = remember { mutableStateOf("") },
pasteFromClipboard = {},
connectViaLink = { link ->
try {
println(link)
// withApi { chatModel.controller.apiConnect(link) }
} catch (e: Exception) {
e.printStackTrace()
}
},
close = {}
)
}
}
@@ -1,36 +1,32 @@
package chat.simplex.app.views.newchat
import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import boofcv.alg.fiducial.qrcode.QrCodeEncoder
import boofcv.alg.fiducial.qrcode.QrCodeGeneratorImage
import boofcv.android.ConvertBitmap
import chat.simplex.app.R
import chat.simplex.app.ui.theme.SimpleXTheme
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
@Composable
fun QRCode(connReq: String, modifier: Modifier = Modifier) {
Image(
bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(),
contentDescription = "QR Code",
contentDescription = stringResource(R.string.image_descr_qr_code),
modifier = modifier
)
}
fun qrCodeBitmap(content: String, size: Int): Bitmap {
val hints = hashMapOf<EncodeHintType, Int>().also { it[EncodeHintType.MARGIN] = 1 }
val bits = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
return Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).also {
for (x in 0 until size) {
for (y in 0 until size) {
it.setPixel(x, y, if (bits[x, y]) Color.BLACK else Color.WHITE)
}
}
}
val qrCode = QrCodeEncoder().addAutomatic(content).fixate()
val renderer = QrCodeGeneratorImage(5)
renderer.render(qrCode)
return ConvertBitmap.grayToBitmap(renderer.gray, Bitmap.Config.RGB_565)
}
@Preview
@@ -1,5 +1,6 @@
package chat.simplex.app.views.newchat
import android.annotation.SuppressLint
import android.util.Log
import android.view.ViewGroup
import androidx.camera.core.*
@@ -10,21 +11,24 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import boofcv.abst.fiducial.QrCodeDetector
import boofcv.alg.color.ColorFormat
import boofcv.android.ConvertCameraImage
import boofcv.factory.fiducial.FactoryFiducial
import boofcv.struct.image.GrayU8
import chat.simplex.app.TAG
import com.google.common.util.concurrent.ListenableFuture
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.*
// Bar code scanner adapted from https://github.com/MakeItEasyDev/Jetpack-Compose-BarCode-Scanner
// Adapted from learntodroid - https://gist.github.com/learntodroid/8f839be0b29d0378f843af70607bd7f5
@Composable
fun QRCodeScanner(onBarcode: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var preview by remember { mutableStateOf<Preview?>(null) }
var lastAnalyzedTimeStamp = 0L
var contactLink = ""
AndroidView(
factory = { AndroidViewContext ->
@@ -36,74 +40,60 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) {
)
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
},
// modifier = Modifier.fillMaxSize(),
update = { previewView ->
val cameraSelector: CameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> =
ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
val barcodeAnalyser = BarCodeAnalyser { barcodes ->
barcodes.firstOrNull()?.rawValue?.let(onBarcode)
}
val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(cameraExecutor, barcodeAnalyser) }
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
} catch (e: Exception) {
Log.d(TAG, "CameraPreview: ${e.localizedMessage}")
}
}, ContextCompat.getMainExecutor(context))
}
)
}
) { previewView ->
val cameraSelector: CameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> =
ProcessCameraProvider.getInstance(context)
class BarCodeAnalyser(
private val onBarcodeDetected: (barcodes: List<Barcode>) -> Unit,
): ImageAnalysis.Analyzer {
private var lastAnalyzedTimeStamp = 0L
@ExperimentalGetImage
override fun analyze(image: ImageProxy) {
val currentTimestamp = System.currentTimeMillis()
if (currentTimestamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) {
image.image?.let { imageToAnalyze ->
val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.build()
val barcodeScanner = BarcodeScanning.getClient(options)
val imageToProcess = InputImage.fromMediaImage(imageToAnalyze, image.imageInfo.rotationDegrees)
barcodeScanner.process(imageToProcess)
.addOnSuccessListener { barcodes ->
if (barcodes.isNotEmpty()) {
onBarcodeDetected(barcodes)
} else {
Log.d(TAG, "BarcodeAnalyser: No barcode Scanned")
cameraProviderFuture.addListener({
preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
val detector: QrCodeDetector<GrayU8> = FactoryFiducial.qrcode(null, GrayU8::class.java)
fun getQR(imageProxy: ImageProxy) {
val currentTimeStamp = System.currentTimeMillis()
if (currentTimeStamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) {
detector.process(imageProxyToGrayU8(imageProxy))
val found = detector.detections
val qr = found.firstOrNull()
if (qr != null) {
if (qr.message != contactLink) {
// Make sure link is new and not a repeat
contactLink = qr.message
onBarcode(contactLink)
}
}
.addOnFailureListener { exception ->
Log.e(TAG, "BarcodeAnalyser: Something went wrong $exception")
}
.addOnCompleteListener {
image.close()
}
}
imageProxy.close()
}
lastAnalyzedTimeStamp = currentTimestamp
} else {
image.close()
}
val imageAnalyzer = ImageAnalysis.Analyzer { proxy -> getQR(proxy) }
val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setImageQueueDepth(1)
.build()
.also { it.setAnalyzer(cameraExecutor, imageAnalyzer) }
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
} catch (e: Exception) {
Log.d(TAG, "CameraPreview: ${e.localizedMessage}")
}
}, ContextCompat.getMainExecutor(context))
}
}
@SuppressLint("UnsafeOptInUsageError")
private fun imageProxyToGrayU8(img: ImageProxy) : GrayU8? {
val image = img.image
if (image != null) {
val outImg = GrayU8()
ConvertCameraImage.imageToBoof(image, ColorFormat.GRAY, outImg, null)
return outImg
}
return null
}
@@ -8,18 +8,18 @@ import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.helpers.*
@Composable
fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) {
fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
ConnectContactLayout(
qrCodeScanner = {
@@ -31,8 +31,8 @@ fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) {
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
title = "Invalid QR code",
text = "This QR code is not a link!"
title = generalGetString(R.string.invalid_QR_code),
text = generalGetString(R.string.this_QR_code_is_not_a_link)
)
}
close()
@@ -48,8 +48,8 @@ fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
withApi { run(action) }
} else {
AlertManager.shared.showAlertMsg(
title = "Invalid link!",
text = "This link is not a valid connection link!"
title = generalGetString(R.string.invalid_contact_link),
text = generalGetString(R.string.this_link_is_not_a_valid_connection_link)
)
}
}
@@ -57,12 +57,11 @@ fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
val r = chatModel.controller.apiConnect(uri.toString())
if (r) {
val whenConnected =
if (action == "contact") "your connection request is accepted"
else "your contact's device is online"
AlertManager.shared.showAlertMsg(
title = "Connection request sent!",
text = "You will be connected when $whenConnected, please wait or check later!"
title = generalGetString(R.string.connection_request_sent),
text =
if (action == "contact") generalGetString(R.string.you_will_be_connected_when_your_connection_request_is_accepted)
else generalGetString(R.string.you_will_be_connected_when_your_contacts_device_is_online)
)
}
}
@@ -75,12 +74,12 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Scan QR code",
style = MaterialTheme.typography.h1,
generalGetString(R.string.scan_QR_code),
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
)
Text(
"Your chat profile will be sent\nto your contact",
style = MaterialTheme.typography.h2,
generalGetString(R.string.your_chat_profile_will_be_sent_to_your_contact),
style = MaterialTheme.typography.h3,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 4.dp)
)
@@ -90,18 +89,8 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni
.aspectRatio(ratio = 1F)
) { qrCodeScanner() }
Text(
buildAnnotatedString {
append("If you cannot meet in person, you can ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
append(", or you can create the invitation link.")
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp)
annotatedStringResource(R.string.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link),
lineHeight = 22.sp
)
}
}
@@ -0,0 +1,70 @@
package chat.simplex.app.views.onboarding
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.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.User
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.annotatedStringResource
@Composable
fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = null) {
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
Text(stringResource(R.string.how_simplex_works), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = 8.dp))
ReadableText(R.string.many_people_asked_how_can_it_deliver)
ReadableText(R.string.to_protect_privacy_simplex_has_ids_for_queues)
ReadableText(R.string.you_control_servers_to_receive_your_contacts_to_send)
ReadableText(R.string.only_client_devices_store_contacts_groups_e2e_encrypted_messages)
if (onboardingStage == null) {
val uriHandler = LocalUriHandler.current
Text(
annotatedStringResource(R.string.read_more_in_github_with_link),
modifier = Modifier.padding(bottom = 12.dp).clickable { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat#readme") },
lineHeight = 22.sp
)
} else {
ReadableText(R.string.read_more_in_github)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
if (onboardingStage != null) {
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.shared.closeModal() })
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}
}
}
@Composable
fun ReadableText(@StringRes stringResId: Int) {
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(bottom = 12.dp), lineHeight = 22.sp)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewHowItWorks() {
SimpleXTheme {
HowItWorks(user = null)
}
}
@@ -0,0 +1,172 @@
package chat.simplex.app.views.onboarding
import android.Manifest
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.usersettings.simplexTeamUri
import com.google.accompanist.permissions.rememberPermissionState
@Composable
fun MakeConnection(chatModel: ChatModel) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
MakeConnectionLayout(
chatModel.currentUser.value,
createLink = {
withApi {
// show spinner
chatModel.connReqInvitation = chatModel.controller.apiAddContact()
// hide spinner
if (chatModel.connReqInvitation != null) {
ModalManager.shared.showModal { AddContactView(chatModel) }
}
}
},
pasteLink = {
ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
},
scanCode = {
ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) }
cameraPermissionState.launchPermissionRequest()
},
about = {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
}
)
}
@Composable
fun MakeConnectionLayout(
user: User?,
createLink: () -> Unit,
pasteLink: () -> Unit,
scanCode: () -> Unit,
about: () -> Unit
) {
Surface {
Column(
Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
.padding(20.dp)
) {
Text(
if (user == null) stringResource(R.string.make_private_connection)
else String.format(stringResource(R.string.personal_welcome), user.profile.displayName),
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
annotatedStringResource(R.string.to_make_your_first_private_connection_choose),
modifier = Modifier.padding(bottom = 16.dp)
)
ActionRow(
Icons.Outlined.AddLink,
R.string.create_1_time_link_qr_code,
R.string.it_s_secure_to_share__only_one_contact_can_use_it,
createLink
)
ActionRow(
Icons.Outlined.Article,
R.string.paste_the_link_you_received,
R.string.or_open_the_link_in_the_browser_and_tap_open_in_mobile,
pasteLink
)
ActionRow(
Icons.Outlined.QrCode,
R.string.scan_contact_s_qr_code,
R.string.in_person_or_via_a_video_call__the_most_secure_way_to_connect,
scanCode
)
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.or))
}
val uriHandler = LocalUriHandler.current
ActionRow(
Icons.Outlined.Tag,
R.string.connect_with_the_developers,
R.string.to_ask_any_questions_and_to_receive_simplex_chat_updates,
{ uriHandler.openUri(simplexTeamUri) }
)
Spacer(Modifier.fillMaxHeight().weight(1f))
SimpleButton(
text = stringResource(R.string.about_simplex),
icon = Icons.Outlined.ArrowBackIosNew,
click = about
)
}
}
}
@Composable
private fun ActionRow(icon: ImageVector, @StringRes titleId: Int, @StringRes textId: Int, action: () -> Unit) {
Row(
Modifier
.clickable { action() }
.padding(bottom = 16.dp)
) {
Icon(icon, stringResource(titleId), tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(end = 10.dp).size(40.dp))
Column {
Text(stringResource(titleId), color = MaterialTheme.colors.primary)
Text(annotatedStringResource(textId))
}
}
// Button(action: action, label: {
// HStack(alignment: .top, spacing: 20) {
// Image(systemName: icon)
// .resizable()
// .scaledToFit()
// .frame(width: 30, height: 30)
// .padding(.leading, 4)
// .padding(.top, 6)
// VStack(alignment: .leading) {
// Group {
// Text(title).font(.headline)
// Text(text).foregroundColor(.primary)
// }
// .multilineTextAlignment(.leading)
// }
// }
// })
// .padding(.bottom)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewMakeConnection() {
SimpleXTheme {
MakeConnectionLayout(
user = User.sampleData,
createLink = {},
pasteLink = {},
scanCode = {},
about = {}
)
}
}
@@ -0,0 +1,47 @@
package chat.simplex.app.views.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.CreateProfilePanel
import chat.simplex.app.views.helpers.getKeyboardState
import com.google.accompanist.insets.ProvideWindowInsets
import kotlinx.coroutines.launch
enum class OnboardingStage {
Step1_SimpleXInfo,
Step2_CreateProfile,
OnboardingComplete
}
@Composable
fun CreateProfile(chatModel: ChatModel) {
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
.padding(20.dp)
) {
CreateProfilePanel(chatModel)
if (savedKeyboardState != keyboardState) {
LaunchedEffect(keyboardState) {
scope.launch {
savedKeyboardState = keyboardState
scrollState.animateScrollTo(scrollState.maxValue)
}
}
}
}
}
}
@@ -0,0 +1,144 @@
package chat.simplex.app.views.onboarding
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Info
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.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.model.ChatModel
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ModalManager
@Composable
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
SimpleXInfoLayout(
user = chatModel.currentUser.value,
onboardingStage = if (onboarding) chatModel.onboardingStage else null,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
)
}
@Composable
fun SimpleXInfoLayout(
user: User?,
onboardingStage: MutableState<OnboardingStage?>?,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
) {
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
SimpleXLogo()
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp))
InfoRow("🎭", R.string.privacy_redefined, R.string.first_platform_without_user_ids)
InfoRow("📭", R.string.immune_to_spam_and_abuse, R.string.people_can_connect_only_via_links_you_share)
InfoRow("🤝", R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers)
Spacer(
Modifier
.fillMaxHeight()
.weight(1f))
if (onboardingStage != null) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
OnboardingActionButton(user, onboardingStage)
}
Spacer(
Modifier
.fillMaxHeight()
.weight(1f))
}
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
SimpleButton(text = stringResource(R.string.how_it_works), icon = Icons.Outlined.Info,
click = showModal { HowItWorks(user, onboardingStage) })
}
}
}
@Composable
fun SimpleXLogo() {
Image(
painter = painterResource(R.drawable.logo),
contentDescription = stringResource(R.string.image_descr_simplex_logo),
modifier = Modifier
.padding(vertical = 20.dp)
.fillMaxWidth(0.80f)
)
}
@Composable
private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) {
Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) {
Text(emoji, fontSize = 36.sp, modifier = Modifier
.width(60.dp)
.padding(end = 16.dp))
Column(horizontalAlignment = Alignment.Start) {
Text(stringResource(titleId), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h3, lineHeight = 24.sp)
Text(stringResource(textId), lineHeight = 24.sp, style = MaterialTheme.typography.caption)
}
}
}
@Composable
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
if (user == null) {
ActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
} else {
ActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
}
}
@Composable
private fun ActionButton(
@StringRes labelId: Int,
onboarding: OnboardingStage?,
onboardingStage: MutableState<OnboardingStage?>,
onclick: (() -> Unit)?
) {
SimpleButtonFrame(click = {
onclick?.invoke()
onboardingStage.value = onboarding
}) {
Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary)
Icon(
Icons.Outlined.ArrowForwardIos, "next stage", tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(end = 8.dp)
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewSimpleXInfo() {
SimpleXTheme {
SimpleXInfoLayout(
user = null,
onboardingStage = null,
showModal = {{}}
)
}
}
@@ -0,0 +1,94 @@
package chat.simplex.app.views.usersettings
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
@Composable
fun CallSettingsView(m: ChatModel) {
CallSettingsLayout(
webrtcPolicyRelay = m.controller.appPrefs.webrtcPolicyRelay,
callOnLockScreen = m.controller.appPrefs.callOnLockScreen
)
}
@Composable
fun CallSettingsLayout(
webrtcPolicyRelay: Preference<Boolean>,
callOnLockScreen: Preference<CallOnLockScreen>,
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
Text(
stringResource(R.string.your_calls),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
Box(Modifier.padding(start = 10.dp)) {
SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay)
}
divider()
Column(Modifier.padding(start = 10.dp, top = 12.dp)) {
Text(stringResource(R.string.call_on_lock_screen))
Row {
SharedPreferenceRadioButton(stringResource(R.string.no_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.DISABLE)
Spacer(Modifier.fillMaxWidth().weight(1f))
SharedPreferenceRadioButton(stringResource(R.string.show_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.SHOW)
Spacer(Modifier.fillMaxWidth().weight(1f))
SharedPreferenceRadioButton(stringResource(R.string.accept_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.ACCEPT)
}
}
}
}
}
@Composable
fun SharedPreferenceToggle(
text: String,
preference: Preference<Boolean>,
preferenceState: MutableState<Boolean>? = null
) {
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(text, Modifier.padding(end = 24.dp))
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = prefState.value,
onCheckedChange = {
preference.set(it)
prefState.value = it
},
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
modifier = Modifier.padding(end = 6.dp)
)
}
}
@Composable
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text)
val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary)
RadioButton(selected = prefState.value == value, colors = colors, onClick = {
preference.set(value)
prefState.value = value
})
}
}
@@ -0,0 +1,32 @@
package chat.simplex.app.views.usersettings
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.Videocam
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
@Composable
fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boolean>) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Text(
stringResource(R.string.settings_experimental_features),
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
)
SettingsSectionView("") {
SettingsPreferenceItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), chatModel.controller.appPrefs.experimentalCalls, enableCalls)
}
}
}
@@ -3,16 +3,20 @@ package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ChatHelpView
import chat.simplex.app.views.chatlist.ChatHelpView
@Composable
fun HelpView(chatModel: ChatModel) {
@@ -24,9 +28,14 @@ fun HelpView(chatModel: ChatModel) {
@Composable
fun HelpLayout(displayName: String) {
Column(horizontalAlignment = Alignment.Start) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.Start
){
Text(
"Welcome $displayName!",
String.format(stringResource(R.string.personal_welcome), displayName),
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
)
@@ -7,32 +7,41 @@ 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.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.Format
import chat.simplex.app.model.FormatColor
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun MarkdownHelpView() {
Column(Modifier.padding(horizontal = 16.dp)) {
Column {
Text(
"How to use markdown",
stringResource(R.string.how_to_use_markdown),
style = MaterialTheme.typography.h1,
)
Text(
"You can use markdown to format messages:",
stringResource(R.string.you_can_use_markdown_to_format_messages__prompt),
Modifier.padding(vertical = 16.dp)
)
MdFormat("*bold*", "bold", Format.Bold())
MdFormat("_italic_", "italic", Format.Italic())
MdFormat("~strike~", "strike", Format.StrikeThrough())
MdFormat("`a + b`", "a + b", Format.Snippet())
val bold = stringResource(R.string.bold)
val italic = stringResource(R.string.italic)
val strikethrough = stringResource(R.string.strikethrough)
val equation = stringResource(R.string.a_plus_b)
val colored = stringResource(R.string.colored)
val secret = stringResource(R.string.secret)
MdFormat("*$bold*", bold, Format.Bold())
MdFormat("_${italic}_", italic, Format.Italic())
MdFormat("~$strikethrough~", strikethrough, Format.StrikeThrough())
MdFormat("`$equation`", equation, Format.Snippet())
Row {
MdSyntax("!1 colored!")
MdSyntax("!1 $colored!")
Text(buildAnnotatedString {
withStyle(Format.Colored(FormatColor.red).style) { append("colored") }
withStyle(Format.Colored(FormatColor.red).style) { append(colored) }
append(" (")
appendColor(this, "1", FormatColor.red, ", ")
appendColor(this, "2", FormatColor.green, ", ")
@@ -43,10 +52,10 @@ fun MarkdownHelpView() {
})
}
Row {
MdSyntax("#secret#")
MdSyntax("#$secret#")
SelectionContainer {
Text(buildAnnotatedString {
withStyle(Format.Secret().style) { append("secret") }
withStyle(Format.Secret().style) { append(secret) }
})
}
}
@@ -56,7 +65,7 @@ fun MarkdownHelpView() {
@Composable
fun MdSyntax(markdown: String) {
Text(markdown, Modifier
.width(100.dp)
.width(120.dp)
.padding(bottom = 4.dp))
}
@@ -0,0 +1,39 @@
package chat.simplex.app.views.usersettings
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.TravelExplore
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.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
@Composable
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Text(
stringResource(R.string.your_privacy),
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
)
SettingsSectionView(stringResource(R.string.settings_section_title_device)) {
ChatLockItem(chatModel.performLA, setPerformLA)
}
Spacer(Modifier.height(30.dp))
SettingsSectionView(stringResource(R.string.settings_section_title_chats)) {
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
divider()
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
}
}
}
@@ -3,8 +3,6 @@ package chat.simplex.app.views.usersettings
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -12,18 +10,18 @@ import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
@Composable
fun SMPServersView(chatModel: ChatModel) {
@@ -31,7 +29,7 @@ fun SMPServersView(chatModel: ChatModel) {
if (userSMPServers != null) {
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
var userSMPServersStr by remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
fun saveSMPServers(smpServers: List<String>) {
withApi {
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
@@ -59,29 +57,28 @@ fun SMPServersView(chatModel: ChatModel) {
if (userSMPServers != null) {
if (userSMPServers.isNotEmpty()) {
AlertManager.shared.showAlertMsg(
title = "Use SimpleX Chat servers?",
text = "Saved SMP servers will be removed",
confirmText = "Confirm",
title = generalGetString(R.string.use_simplex_chat_servers__question),
text = generalGetString(R.string.saved_SMP_servers_will_br_removed),
confirmText = generalGetString(R.string.confirm_verb),
onConfirm = {
saveSMPServers(listOf())
isUserSMPServers = false
userSMPServersStr = ""
userSMPServersStr.value = ""
}
)
} else {
isUserSMPServers = false
userSMPServersStr = ""
userSMPServersStr.value = ""
}
}
}
},
editUserSMPServersStr = { userSMPServersStr = it },
cancelEdit = {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
isUserSMPServers = userSMPServers.isNotEmpty()
editSMPServers = !isUserSMPServers
userSMPServersStr = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
}
},
saveSMPServers = { saveSMPServers(it) },
@@ -94,9 +91,8 @@ fun SMPServersView(chatModel: ChatModel) {
fun SMPServersLayout(
isUserSMPServers: Boolean,
editSMPServers: Boolean,
userSMPServersStr: String,
userSMPServersStr: MutableState<String>,
isUserSMPServersOnOff: (Boolean) -> Unit,
editUserSMPServersStr: (String) -> Unit,
cancelEdit: () -> Unit,
saveSMPServers: (List<String>) -> Unit,
editOn: () -> Unit,
@@ -107,14 +103,14 @@ fun SMPServersLayout(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Your SMP servers",
stringResource(R.string.your_SMP_servers),
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text("Configure SMP servers", Modifier.padding(end = 24.dp))
Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
Switch(
checked = isUserSMPServers,
onCheckedChange = isUserSMPServersOnOff,
@@ -126,43 +122,11 @@ fun SMPServersLayout(
}
if (!isUserSMPServers) {
Text("Using SimpleX Chat servers.")
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
} else {
Text("Enter one SMP server per line:")
Text(stringResource(R.string.enter_one_SMP_server_per_line))
if (editSMPServers) {
BasicTextField(
value = userSMPServersStr,
onValueChange = editUserSMPServersStr,
textStyle = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 14.sp,
color = MaterialTheme.colors.onBackground
),
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
modifier = Modifier.height(160.dp),
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
Row(
Modifier.background(MaterialTheme.colors.background),
verticalAlignment = Alignment.Top
) {
Box(
Modifier
.weight(1f)
.padding(vertical = 5.dp, horizontal = 7.dp)
) {
innerTextField()
}
}
}
}
)
TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
Row(
Modifier.fillMaxWidth(),
@@ -172,17 +136,17 @@ fun SMPServersLayout(
Column(horizontalAlignment = Alignment.Start) {
Row {
Text(
"Cancel",
stringResource(R.string.cancel_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = cancelEdit)
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
"Save",
stringResource(R.string.save_servers_button),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable(onClick = {
val servers = userSMPServersStr.split("\n")
val servers = userSMPServersStr.value.split("\n")
saveSMPServers(servers)
})
)
@@ -204,7 +168,7 @@ fun SMPServersLayout(
Modifier.verticalScroll(rememberScrollState())
) {
Text(
userSMPServersStr,
userSMPServersStr.value,
Modifier
.padding(vertical = 5.dp, horizontal = 7.dp),
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
@@ -218,7 +182,7 @@ fun SMPServersLayout(
) {
Column(horizontalAlignment = Alignment.Start) {
Text(
"Edit",
stringResource(R.string.edit_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editOn)
@@ -240,9 +204,9 @@ fun howToButton() {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
) {
Text("How to", color = MaterialTheme.colors.primary)
Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary)
Icon(
Icons.Outlined.OpenInNew, "How to", tint = MaterialTheme.colors.primary,
Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(horizontal = 5.dp)
)
}
@@ -255,9 +219,8 @@ fun PreviewSMPServersLayoutDefaultServers() {
SMPServersLayout(
isUserSMPServers = false,
editSMPServers = true,
userSMPServersStr = "",
userSMPServersStr = remember { mutableStateOf("") },
isUserSMPServersOnOff = {},
editUserSMPServersStr = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
@@ -272,9 +235,8 @@ fun PreviewSMPServersLayoutUserServersEditOn() {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = true,
userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
isUserSMPServersOnOff = {},
editUserSMPServersStr = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
@@ -289,9 +251,8 @@ fun PreviewSMPServersLayoutUserServersEditOff() {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = false,
userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
isUserSMPServersOnOff = {},
editUserSMPServersStr = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
@@ -1,37 +1,64 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import chat.simplex.app.BuildConfig
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.TerminalView
import chat.simplex.app.views.newchat.ModalManager
import chat.simplex.app.views.database.DatabaseView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.SimpleXInfo
@Composable
fun SettingsView(chatModel: ChatModel) {
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
val user = chatModel.currentUser.value
val stopped = chatModel.chatRunning.value == false
fun setRunServiceInBackground(on: Boolean) {
chatModel.controller.appPrefs.runServiceInBackground.set(on)
if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
chatModel.runServiceInBackground.value = on
}
if (user != null) {
SettingsLayout(
profile = user.profile,
stopped,
runServiceInBackground = chatModel.runServiceInBackground,
setRunServiceInBackground = ::setRunServiceInBackground,
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close ->
ModalView(close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
modalView(chatModel)
}
} } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
)
}
}
@@ -42,146 +69,264 @@ val simplexTeamUri =
@Composable
fun SettingsLayout(
profile: Profile,
stopped: Boolean,
runServiceInBackground: MutableState<Boolean>,
setRunServiceInBackground: (Boolean) -> Unit,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit,
// showVideoChatPrototype: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
Surface(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
Column(
Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(8.dp)
.background(if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
.padding(top = 16.dp)
) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
@Composable fun spacer() = Spacer(Modifier.height(30.dp))
Text(
"Your Settings",
stringResource(R.string.your_settings),
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 8.dp)
modifier = Modifier.padding(start = 16.dp)
)
Spacer(Modifier.height(30.dp))
SettingsSectionView(showModal { UserProfileView(it) }, 60.dp) {
Icon(
Icons.Outlined.AccountCircle,
contentDescription = "Avatar Placeholder",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
Text(
profile.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
)
Text(profile.fullName)
SettingsSectionView(stringResource(R.string.settings_section_title_you)) {
SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
divider()
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped)
divider()
DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showModal { UserAddressView(it) }) {
Icon(
Icons.Outlined.QrCode,
contentDescription = "Address",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("Your SimpleX contact address")
}
Spacer(Modifier.height(24.dp))
spacer()
SettingsSectionView(showModal { HelpView(it) }) {
Icon(
Icons.Outlined.HelpOutline,
contentDescription = "Chat help",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("How to use SimpleX Chat")
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped)
divider()
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
divider()
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped)
divider()
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }, disabled = stopped)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showModal { MarkdownHelpView() }) {
Icon(
Icons.Outlined.TextFormat,
contentDescription = "Markdown help",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("Markdown in messages")
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) {
Icon(
Icons.Outlined.Tag,
contentDescription = "SimpleX Team",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Get help & advice via chat",
color = MaterialTheme.colors.primary
)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) {
Icon(
Icons.Outlined.Email,
contentDescription = "Email",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Ask questions via email",
color = MaterialTheme.colors.primary
)
}
Spacer(Modifier.height(24.dp))
spacer()
SettingsSectionView(showModal { SMPServersView(it) }) {
Icon(
Icons.Outlined.Dns,
contentDescription = "SMP servers",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("SMP servers")
SettingsSectionView(stringResource(R.string.settings_section_title_help)) {
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }, disabled = stopped)
divider()
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
divider()
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
divider()
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
divider()
SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showTerminal) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
contentDescription = "Chat console",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("Chat console")
spacer()
SettingsSectionView(stringResource(R.string.settings_section_title_develop)) {
ChatConsoleItem(showTerminal, stopped)
divider()
InstallTerminalAppItem(uriHandler)
divider()
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
// divider()
AppVersionItem()
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
}
}
}
@Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) {
Column {
Text(
title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
)
Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
Column(Modifier.padding(horizontal = 6.dp)) { content() }
}
}
}
@Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) {
SettingsItemView(openDatabaseView) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Row {
Icon(
painter = painterResource(id = R.drawable.ic_github),
contentDescription = "GitHub",
Icons.Outlined.Archive,
contentDescription = stringResource(R.string.database_export_and_import),
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
buildAnnotatedString {
append("Install ")
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
append("SimpleX Chat for terminal")
}
}
Text(stringResource(R.string.database_export_and_import))
}
if (stopped) {
Icon(
Icons.Filled.Report,
contentDescription = stringResource(R.string.chat_is_stopped),
tint = Color.Red,
modifier = Modifier.padding(end = 6.dp)
)
}
}
}
}
@Composable private fun PrivateNotificationsItem(
runServiceInBackground: MutableState<Boolean>,
setRunServiceInBackground: (Boolean) -> Unit,
stopped: Boolean
) {
SettingsItemView(disabled = stopped) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Outlined.Bolt,
contentDescription = stringResource(R.string.private_notifications),
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(R.string.private_notifications),
Modifier
.padding(end = 24.dp)
.fillMaxWidth()
.weight(1f),
color = if (stopped) HighOrLowlight else Color.Unspecified
)
Switch(
checked = runServiceInBackground.value,
onCheckedChange = { setRunServiceInBackground(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
modifier = Modifier.padding(end = 6.dp),
enabled = !stopped
)
}
}
}
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
SettingsItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Outlined.Lock,
contentDescription = stringResource(R.string.chat_lock),
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(R.string.chat_lock), Modifier
.padding(end = 24.dp)
.fillMaxWidth()
.weight(1F)
)
Switch(
checked = performLA.value,
onCheckedChange = { setPerformLA(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
modifier = Modifier.padding(end = 6.dp)
)
}
}
}
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) {
SettingsItemView(showTerminal, disabled = stopped) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
contentDescription = stringResource(R.string.chat_console),
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(R.string.chat_console),
color = if (stopped) HighOrLowlight else Color.Unspecified
)
}
}
@Composable private fun InstallTerminalAppItem(uriHandler: UriHandler) {
SettingsItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
Icon(
painter = painterResource(id = R.drawable.ic_github),
contentDescription = "GitHub",
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
}
}
@Composable private fun AppVersionItem() {
SettingsItemView() {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
}
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary, stopped: Boolean = false) {
ProfileImage(size = size, image = profileOf.image, color = color)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
Text(
profileOf.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = if (stopped) HighOrLowlight else Color.Unspecified
)
Text(
profileOf.fullName,
color = if (stopped) HighOrLowlight else Color.Unspecified
)
}
}
@Composable
fun SettingsSectionView(click: () -> Unit, height: Dp = 48.dp, content: (@Composable () -> Unit)) {
fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) {
val modifier = Modifier
.padding(start = 8.dp)
.fillMaxWidth()
.height(height)
Row(
Modifier
.padding(start = 8.dp)
.fillMaxWidth()
.clickable(onClick = click)
.height(height),
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
verticalAlignment = Alignment.CenterVertically
) {
content.invoke()
content()
}
}
@Composable
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) {
SettingsItemView(click, disabled = disabled) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text, color = if (disabled) HighOrLowlight else textColor)
}
}
@Composable
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
SettingsItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
SharedPreferenceToggle(text, pref, prefState)
}
}
}
@@ -196,8 +341,15 @@ fun PreviewSettingsLayout() {
SimpleXTheme {
SettingsLayout(
profile = Profile.sampleData,
showModal = {{}},
showTerminal = {}
stopped = false,
runServiceInBackground = remember { mutableStateOf(true) },
setRunServiceInBackground = {},
setPerformLA = {},
showModal = { {} },
showSettingsModal = { {} },
showCustomModal = { {} },
showTerminal = {},
// showVideoChatPrototype = {}
)
}
}
@@ -11,8 +11,11 @@ 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.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
@@ -32,9 +35,9 @@ fun UserAddressView(chatModel: ChatModel) {
share = { userAddress: String -> shareText(cxt, userAddress) },
deleteAddress = {
AlertManager.shared.showAlertMsg(
title = "Delete address?",
text = "All your contacts will remain connected",
confirmText = "Delete",
title = generalGetString(R.string.delete_address__question),
text = generalGetString(R.string.all_your_contacts_will_remain_connected),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
chatModel.controller.apiDeleteUserAddress()
@@ -58,14 +61,14 @@ fun UserAddressLayout(
verticalArrangement = Arrangement.Top
) {
Text(
"Your chat address",
stringResource(R.string.your_chat_address),
Modifier.padding(bottom = 16.dp),
style = MaterialTheme.typography.h1,
)
Text(
"You can share your address as a link or as a QR code - anybody will be able to connect to you, " +
"and if you later delete it - you won't lose your contacts.",
stringResource(R.string.you_can_share_your_address_anybody_will_be_able_to_connect),
Modifier.padding(bottom = 12.dp),
lineHeight = 22.sp
)
Column(
Modifier.fillMaxWidth(),
@@ -73,7 +76,12 @@ fun UserAddressLayout(
verticalArrangement = Arrangement.SpaceEvenly
) {
if (userAddress == null) {
SimpleButton("Create address", icon = Icons.Outlined.QrCode, click = createAddress)
Text(
stringResource(R.string.if_you_delete_address_you_wont_lose_contacts),
Modifier.padding(bottom = 12.dp),
lineHeight = 22.sp
)
SimpleButton(stringResource(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress)
} else {
QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f))
Row(
@@ -82,11 +90,11 @@ fun UserAddressLayout(
modifier = Modifier.padding(vertical = 10.dp)
) {
SimpleButton(
"Share link",
stringResource(R.string.share_link),
icon = Icons.Outlined.Share,
click = { share(userAddress) })
SimpleButton(
"Delete address",
stringResource(R.string.delete_address),
icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteAddress
@@ -1,45 +1,56 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import android.graphics.Bitmap
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.MaterialTheme
import androidx.compose.material.Text
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.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.isValidDisplayName
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@Composable
fun UserProfileView(chatModel: ChatModel) {
fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
val user = chatModel.currentUser.value
if (user != null) {
var editProfile by remember { mutableStateOf(false) }
val editProfile = remember { mutableStateOf(false) }
var profile by remember { mutableStateOf(user.profile) }
UserProfileLayout(
close = close,
editProfile = editProfile,
profile = profile,
editProfileOff = { editProfile = false },
editProfileOn = { editProfile = true },
saveProfile = { displayName: String, fullName: String ->
saveProfile = { displayName, fullName, image ->
withApi {
val newProfile = chatModel.controller.apiUpdateProfile(
profile = Profile(displayName, fullName)
)
val p = Profile(displayName, fullName, image)
val newProfile = chatModel.controller.apiUpdateProfile(p)
if (newProfile != null) {
chatModel.updateUserProfile(newProfile)
profile = newProfile
}
editProfile = false
editProfile.value = false
}
}
)
@@ -48,119 +59,215 @@ fun UserProfileView(chatModel: ChatModel) {
@Composable
fun UserProfileLayout(
editProfile: Boolean,
close: () -> Unit,
editProfile: MutableState<Boolean>,
profile: Profile,
editProfileOff: () -> Unit,
editProfileOn: () -> Unit,
saveProfile: (String, String) -> Unit,
saveProfile: (String, String, String?) -> Unit,
) {
Column(horizontalAlignment = Alignment.Start) {
Text(
"Your chat profile",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"Your profile is stored on your device and shared only with your contacts.\n" +
"SimpleX servers cannot see your profile.",
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground
)
if (editProfile) {
var displayName by remember { mutableStateOf(profile.displayName) }
var fullName by remember { mutableStateOf(profile.fullName) }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
// TODO hints
BasicTextField(
value = displayName,
onValueChange = { displayName = it },
modifier = Modifier
.padding(bottom = 24.dp)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
BasicTextField(
value = fullName,
onValueChange = { fullName = it },
modifier = Modifier
.padding(bottom = 24.dp)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
Row {
Text(
"Cancel",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editProfileOff),
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
"Save (and notify contacts)",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = { saveProfile(displayName, fullName) })
)
}
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Row(
Modifier.padding(bottom = 24.dp)
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val displayName = remember { mutableStateOf(profile.displayName) }
val fullName = remember { mutableStateOf(profile.fullName) }
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val profileImage = remember { mutableStateOf(profile.image) }
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetContent = {
GetImageBottomSheet(
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(
"Display name:",
stringResource(R.string.your_chat_profile),
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
profile.displayName,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
stringResource(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it),
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground,
lineHeight = 22.sp
)
if (editProfile.value) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
contentAlignment = Alignment.Center
) {
Box(contentAlignment = Alignment.TopEnd) {
Box(contentAlignment = Alignment.Center) {
ProfileImage(192.dp, profileImage.value)
EditImageButton { scope.launch { bottomSheetModalState.show() } }
}
if (profileImage.value != null) {
DeleteImageButton { profileImage.value = null }
}
}
}
Box {
if (!isValidDisplayName(displayName.value)) {
Icon(Icons.Outlined.Info, tint = Color.Red, contentDescription = stringResource(R.string.display_name_cannot_contain_whitespace))
}
ProfileNameTextField(displayName)
}
ProfileNameTextField(fullName)
Row {
TextButton(stringResource(R.string.cancel_verb)) {
displayName.value = profile.displayName
fullName.value = profile.fullName
profileImage.value = profile.image
editProfile.value = false
}
Spacer(Modifier.padding(horizontal = 8.dp))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
val saveModifier: Modifier
val saveColor: Color
if (enabled) {
saveModifier = Modifier
.clickable { saveProfile(displayName.value, fullName.value, profileImage.value) }
saveColor = MaterialTheme.colors.primary
} else {
saveModifier = Modifier
saveColor = HighOrLowlight
}
Text(
stringResource(R.string.save_and_notify_contacts),
modifier = saveModifier,
color = saveColor
)
}
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 24.dp), contentAlignment = Alignment.Center
) {
ProfileImage(192.dp, profile.image)
if (profile.image == null) {
EditImageButton {
editProfile.value = true
scope.launch { bottomSheetModalState.show() }
}
}
}
ProfileNameRow(stringResource(R.string.display_name__field), profile.displayName)
ProfileNameRow(stringResource(R.string.full_name__field), profile.fullName)
TextButton(stringResource(R.string.edit_verb)) { editProfile.value = true }
}
}
if (savedKeyboardState != keyboardState) {
LaunchedEffect(keyboardState) {
scope.launch {
savedKeyboardState = keyboardState
scrollState.animateScrollTo(scrollState.maxValue)
}
}
}
}
Row(
Modifier.padding(bottom = 24.dp)
) {
Text(
"Full name:",
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
profile.fullName,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
)
}
Text(
"Edit",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editProfileOn)
)
}
}
}
}
@Composable
private fun ProfileNameTextField(name: MutableState<String>) {
BasicTextField(
value = name.value,
onValueChange = { name.value = it },
modifier = Modifier
.padding(bottom = 24.dp)
.padding(start = 28.dp)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
}
@Composable
private fun ProfileNameRow(label: String, text: String) {
Row(Modifier.padding(bottom = 24.dp)) {
Text(
label,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
text,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
)
}
}
@Composable
private fun TextButton(text: String, click: () -> Unit) {
Text(
text,
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable(onClick = click),
)
}
@Composable
fun EditImageButton(click: () -> Unit) {
IconButton(
onClick = click,
modifier = Modifier.background(Color(1f, 1f, 1f, 0.2f), shape = CircleShape)
) {
Icon(
Icons.Outlined.PhotoCamera,
contentDescription = stringResource(R.string.edit_image),
tint = MaterialTheme.colors.primary,
modifier = Modifier.size(36.dp)
)
}
}
@Composable
fun DeleteImageButton(click: () -> Unit) {
IconButton(onClick = click) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.delete_image),
tint = MaterialTheme.colors.primary,
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -171,11 +278,10 @@ fun UserProfileLayout(
fun PreviewUserProfileLayoutEditOff() {
SimpleXTheme {
UserProfileLayout(
close = {},
profile = Profile.sampleData,
editProfile = false,
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
editProfile = remember { mutableStateOf(false) },
saveProfile = { _, _, _ -> }
)
}
}
@@ -190,11 +296,10 @@ fun PreviewUserProfileLayoutEditOff() {
fun PreviewUserProfileLayoutEditOn() {
SimpleXTheme {
UserProfileLayout(
close = {},
profile = Profile.sampleData,
editProfile = true,
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
editProfile = remember { mutableStateOf(true) },
saveProfile = { _, _, _ -> }
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

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