Compare commits

..

446 Commits

Author SHA1 Message Date
Evgeny Poberezkin 589f560dd6 3.1.0 2022-07-23 14:49:45 +01:00
Evgeny Poberezkin 4fd13c637c core: access messaging servers via SOCKS5 proxy (#835)
* core: access messaging servers via SOCKS5 proxy

* update option info

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

* group item layout

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

* update readme

* corrections

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

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

* always update

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

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

* contact and member information

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

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

This reverts commit 976b1c919f.

* nix: update hackage index

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

* fix

* memberActive (crashes)

* adjustments

* android ui

* android - memberActive

* update group invitation item layout

* update texts

* typo

* update layout

* do not add contacts added via groups

* filter contacts by conn_level

* turn off address sanitizer

* fix layout

* android: filter on update chat

* android adjustments

* divider fix

* android chat previews

* ios previews

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

* update simplexmq

* remove comments

* clean up

* refactor

* remove todo

* revert change

* revert change

* remove comment

* add delay to the async group test

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

* remove unnecessary TODOs

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

* update blog

* images, corrections

* update images

* update images

* update

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

* fix import

* import, delete

* disabled during in progress

* footer

* ChatArchiveView

* refactor

* disable settings

* more chat running interactions

* more chat running interactions

* fixes

* rename

* fixes

* fix

* change ts format

* chatWasStopped model variable

* remove logs

* reset chatWasStopped

* chat was stopped preference

* fixes

* unconditional chatRunning

* remove intermediary view

* refactor

* mkInstantPreference

* refactor

* refactor

* refactor

* DatabaseItem

* remove todos

* refactor

* refactor

* refactor

* translations

* translations

* refactor import

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

* update version v3.0 (38)

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

* swift

* brackets

* Update src/Simplex/Chat/Archive.hs

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

* logs

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

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

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

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

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

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

* enable tests

* delete calls, encoding

* load calls on start

* remove line

* remove table alias

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

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

* fix refreshing chats when exiting background

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

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

* refactor

* register token on start

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

* Apply suggestions from code review

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

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

* terminal: show auto accept status and message

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

* move files

* ui for database migration

* migration screen layout

* ios: export archive and delete chat database

* import archive

* refactor, update texts

* database migration (almost works)

* fix missing import

* delete legacy database

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

This reverts commit 7df8c23c81.

* Revert "update flake.nix"

This reverts commit 4b263510ee.

* Revert "update tls"

This reverts commit 2e34ae3b1c.

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

* update simplexmq

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

* update chat

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

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

* remove local ref from cabal.project

* update simplexmq

* log test

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

* enable test log

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

* version test matrix

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

* update simplexmq

* remove unnecessary condition

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

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

* export chat archive

* import archive, support starting chat after it is stopped

* test for maintenance mode

* test/fix archive with files

* prevent starting chat after chat database was deleted or imported

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

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

* update readme

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

* avoid showing chats

* avoid flicker

* fix exit

* bg task

* rename function

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

* Update apps/ios/Shared/ContentView.swift

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

* remove doAuthenticate = false

* remove lock button

* moare fixos

* whitespace

* and more

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

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

* remove bridging headers from pp/NSE

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

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

* update translation

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

* translation

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

* translation

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

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

* ios: skipped messages alert and translations

* android: skipped messages alert

* android translation keys

* more keys

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

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

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

* remove type signature

* remove migration

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

* refactor

* advertisement state machine

* simplify

* ios: retry

* remove log

* android: retry

* Update apps/ios/Shared/ContentView.swift

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

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

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

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

* close overlays when call is accepted via notification

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

* fix clickable area of create profile button

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

* refactor preferences

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

* add ringtone to incoming call

* incoming call on full screen

* enable notification ringtone

* remove text

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

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

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

* fix remote video, support logging from ios

* use callkit depending on CallController setting

* call sound

* update incoming call view

* fixing audio encryption

* refactor encryption webrtc fix

* log ontrack success/error

* accept / ignore call via notification

* remove unused imports

* remove unused file

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

* update images

* add image

* correction

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

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

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

* terminal: forward file commands

* terminal: update help

* terminal: update placeholder image

* Update src/Simplex/Chat/Help.hs

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

* help

* allow forwarding sent file

* typo

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

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

* move to right

* remove unused imports

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

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

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

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

* use updated_at returned from chat response

* refactor

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

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

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

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

* android: clear chat

* fix chat stats

* fixes

* check if deleted

* delete from files for groups

* android - fixes

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

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

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

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

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

* npm package

* update webrtc npm package

* remove console.log

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

* update webrtc settings

* WebRTCView to use command/response types

* compress WebRTC session descriptions, simple web UI for calls

* update webrtc ui

* use webworked in desktop browser

* use RTCRtpScriptTransform in safari

* update android type

* refactor

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

* update service notice conditions

* android: update notification service logic

* update translations

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

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

* remove do

* fix logic

* rename

* refactor

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

* typescript types for chat commands and command serialization

* typescript ChatResponse type

* more types

* more types

* websocket chat client

* aligb ts/haskell types

* chat server & TS client via websockets - it works

* TS chat client test

* TS chat client test

* update test

* more api functions

* more api methods, refactor, readme

* squaring chat bot example, fixes

* update readme

* remove console.log

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

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

* android: save enabled

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

* update post

* update

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

* update

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

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

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

* create profile

* creating profile works

* make connection view

* onboarding layout

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

* remove geometryreader

* update translations

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

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

* ios onboarding: create profile and make connection

* how SimpleX works

* connect via link

* remove separate view for connecting via link, fix bugs

* remove unused files

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

* layout

* add About to settings, tidy up

* rename function

* update layout

* translations

* translation corrections

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

* correction

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

* corrections

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

* fix translations/layout

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

* android: add v2 to "choose file" label

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

* change some android translations

* corrections

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

* correction

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

* correction

* picture

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

* import updated

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

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

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

* android: call api types

* android: call api methods

* ios: connect calls via chat UI (WIP)

* ios: webrtc call connects via UI

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

* improve call UX/UI

* audio calls

* different overlay for audio calls

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

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

* minor ios changes

* linting

* RPC calls with webview

* tidy up

* more types

* ios: webrtc types, call works

* remove comment

* prettier

* add prettier to build

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

* fix test

* add APICallStatus, tests

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

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

* create notifications in NSE (WIP)

* refactor notifications to use in NSE

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

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

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

* update sequence diagram

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

* typsecript compiles

* fix error messages

* TS works in chrome

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

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

* limited javascrtipt interaction

* run some js

* try to resolve permissions issues

* some initial RTC javascript

* approaching a workable js file

* js fixes

* tidy up js

* add some ui to web call

* fixes

* ready to test

* typo

* refactor for readability

* tidy up before adding encryption

* add transform to video streams

* tidy a little, audio encoding works, video fails

* minor changes

* use variables consistently

* e2ee video calls
git push

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

* add different prefix retention for differing frame types

* e2ee video calls with iv passed in band

* enforce use of VP8 encoding

* allow plaintext chunk only for video frames

* tidy up kotlin. Android <> browser tested

* minor ios changes

* capture js logs in xcode

* typo

* linting

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

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

* formatting typo

* update

* update rfc (WIP)

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

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

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

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

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

* update translations

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

* update translation

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

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

* Update tests/ChatTests.hs

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

* refined error localisation - todo moved

* tidy up to make picking back up easier

* better errors

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

* more sophisticated avoidance of repeat contact requests

* remove format specifier

* refinements

* update QR scanner to use boofcv

* remove dependence on zxing

* attribution

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

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

* ios: UI for pending contact connections complete

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

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

* update simplexmq

* update query to use temp table

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

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

* first pass at pastebox in iOS

* clean up iOS implementation

* put paste link page in for groups in android

* initial inclusion in iOS UI

* refactor naming

* lint kotlin

* fix typo

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

* android: update paste link UI

* add russian translations

* update translations

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

* update translations

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

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

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

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

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

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

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

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

* disable navigation to not ready chat

* update "pending contact" alert, Russian translations

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

* add missing import

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

* ios: receive messages when background notification is received

* add notifications API, update simplexmq

* chat API to register and verify notification token

* update AppDelegate to recognize different notification types, update simplexmq

* core: api to enable periodic background notifications

* update simplexmq

* chat API to delete device notification token

* use base64url encoding in verification code

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

* autoverify domain

* add path prefix to intent filter

* support contact links

* add android app readme

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

* change text

* android error description

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

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

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

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

* fix translations

* Apply suggestions from code review

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

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

* ios wip

* android wip

* ios files folder

* ios get address on start

* android app files folder

* ios more backend

* android more backend

* translation

* ios image without text, remove preview

* android image without text, remove preview

* fix translation

* file name in previews and w/t text

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

This reverts commit 0110570e55.

* ios filename in preview

* android filename in preview

* android wider images

* ios determine width on image for correct quote width

* ios images in previews wip

* ios square image in quote

* ios: update image layout

* android images in quotes

* android remove redundant modifier

* android clip to bounds

* android - image in right side of quote

* android refactor image view

* android - refactor, align quote text top

* android fix emoji view

* fix image layout

* full screen image view, fix quote layout

* android various size

* android fixed image width

* android meta on image

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

* android: make image-only meta white

* refactor file.stored

* android: meta icon color

* android: open chat scrolled to last unread item

* copy/share image messages

* android: full screen image

* check file is loaded

* terminal: refactor view for messages with files

* android: change to onClick, only show stored file

* android: remove close sheet bar

* android: close image view on click

* translation

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

* increase DropDown width

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

* commit localizations

* update Russian translations

* fix notifications and layouts after localizations

* localization docs

* update translations

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

* fix typo

* update translations

* fix translations for different link types

* update translations

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

* update translation

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

* update translations

* update translations

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

* rearrange strings

* typo

* minor id & xliff changes

* response to comments

* colour comments and verb suffixes

* add russian language file

* fix interpolation error

* final strings

* russian translations

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

* fix layouts, improve font spacing

* split sentence about User address, font line height

* typo

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

* update Russian translations

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

* remove an

* update Russian translations

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

* commas

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

* prettier spacing

* tighten status icons

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

* more changes

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

* update "why"

* typo

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

* dot

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

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

* clear overlays on open from ntf

* cancel notifications alongside unmarked markers

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

* update comparison

* update link

* move message_views.sql to scripts

* move section

* move news section

* typos

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

* update readme

* update readme

* update readme

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

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

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

* add getLinkPreview (synchonous for now)

* api wiring fix

* get network requests off main thread

* copy over state machine logic from iOS

* filter api parsing calls from logs

* refactor of image processing

* remove image deepcopy

* minor change to log filtering

* mobile: link previews

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

* core: quoteData in ChatMonad (#518)

* core: quoteData in ChatMonad

* use throwChatError

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

* fix tests

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

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

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

* initial very basic preview outline, remove icon loading

* connect preview view to compose view

* v0.1 barely working

* minor refactor

* refactor

* collect images effectively

* link up to api for send/receive

* rework async get metadata logic

* show previews in chat

* refactor resizing logic

* checkpoint before view editing

* ui changes

* housekeeping

* ui tweaks

* typo

* improve link preview design/logic

* resize image to target data size

* fix link preview state machine

* tidy up

* fix typo

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

* update nix file, simplexmq sha

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

* add back camera selection option

* remove force unwrap of optional

* response to comments

* rerun tests

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

* fix blog diagram

* update blog post

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

* extract repl bot

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

* update notifications popup wording

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

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-04-01 17:13:54 +01:00
Evgeny Poberezkin c7d7c6c608 mobile: build number ios 33, android 21 2022-04-01 16:54:55 +01:00
JRoberts 6c4c097150 core: update simplexmq hash (remove manual vacuum) (#493) 2022-04-01 17:23:12 +04:00
JRoberts 3eb4d5efdd core: update simplexmq hash (catch db error) (#490) 2022-04-01 13:00:35 +04:00
Evgeny Poberezkin ea95912bd5 mobile: update lib and versions 1.5 (ios - 32, android - 20) 2022-04-01 09:58:00 +01:00
JRoberts d080a3a87b mobile: hide broadcast delete button (#488) 2022-03-31 21:38:53 +04:00
JRoberts 54b501913c fix readme link (#485) 2022-03-31 16:41:08 +04:00
Evgeny Poberezkin 12ee82808e ios: update lib and build version 31 / 1.4 2022-03-30 09:02:20 +01:00
342 changed files with 40218 additions and 7584 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/
+173 -245
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)
@@ -19,281 +19,209 @@
[<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)
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
- [For developers](#for-developers)
- [Roadmap](#roadmap)
- [Help us pay for 3rd party security audit](#help-us-pay-for-3rd-party-security-audit)
- [Disclaimer, License](#disclaimer)
## Why privacy matters
Everyone should care about privacy and security of their communications - innocuous conversations can put you in danger even if there is nothing to hide.
One of the most shocking stories is the experience of [Mohamedou Ould Salahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi) that he wrote about in his memoir and that is shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the 10 years prior to the attacks.
It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks - who we are connected with.
## SimpleX approach to privacy and security
### Complete privacy of your identity, profile, contacts and metadata
**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - not even random numbers. This protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. [Read more](./docs/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata).
### The best protection against spam and abuse
As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. [Read more](./docs/SIMPLEX.md#the-best-protection-against-spam-and-abuse).
### Complete ownership, control and security of your data
SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received. [Read more](./docs/SIMPLEX.md#complete-ownership-control-and-security-of-your-data).
### Users own SimpleX network
You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. [Read more](./docs/SIMPLEX.md#users-own-simplex-network).
## Frequently asked questions
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release annoucement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
2. _Why should I not just use Signal?_ Signal is a centralised platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identites?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
## News and updates
Selected updates:
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md)
[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md)
[Jan 12, 2022. SimpleX v1 released: the only messaging and application platform without user identities](./20220112-simplex-chat-v1-released.md)
[All updates](./blog)
## Make a private connection
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.
## Privacy: technical details and limitations
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).
SimpleX Chat is a work in progress we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
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.
What is already implemented:
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.
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notificaitons on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
2. End-to-end encryption in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) end-to-end encryption in each bi-directional conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with forward secrecy (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
5. Several levels of content padding to the fixed size - server blocks inside TLS and message content inside each of 3 encrypted envelopes are padded to a constant size, to prevent any correlation by message size.
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
## Terminal chat features
We plan to add soon:
- 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.
1. Access to messaging servers via Tor. Currently it is supported only for [terminal CLI clients](./docs/CLI.md); mobile clients access servers via public Internet, and the servers can observe IP addresses of the clients. Depending which platform you use, you might be able to configure access via Tor independently. The servers provided by SimpleX Chat do not correlate users by or log IP addresses, and you can use your own servers, but in some scenarios that may be not a sufficient level of privacy.
2. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
3. Local database encryption. Currently the local chat database stored on your device is not encrypted.
4. Independent implementation audit.
Public keys involved in key exchange are not used as identity, they are randomly generated for each contact.
## For developers
See [Encryption Primitives Used](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#encryption-primitives-used) for technical details.
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.
<a name="🚀-installation"></a>
You already can:
## 🚀 Installation
- 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).
### 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 WebRTC audio and video calls via the mobile apps.
- ✅ Privacy preserving instant notifications for iOS using Apple Push Notification service.
- ✅ Chat database export and import
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
- 🏗 Connecting to messaging servers via Tor (in progress).
- 🏗 Chat groups in mobile apps (in progress).
- Chat database encryption.
- 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.
## Help us pay for 3rd party security audit
I will get straight to the point: I ask you to support SimpleX Chat with donations.
We are prioritizing users privacy and security - it would be impossible without your support we were lucky to have so far.
We are planning a 3rd party security audit for the app, and it would hugely help us if some part of this $20,000+ expense could be covered with donations.
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
If you are already using SimpleX Chat, or plan to use it in the future when it has more features, please consider making a donation - it will help us to raise more funds. Donating any amount, even the price of the cup of coffee, would make a huge difference for us.
It is possible to [donate via GitHub](https://github.com/sponsors/simplex-chat), which is commission-free for us, or [via OpenCollective](https://opencollective.com/simplex-chat), that also accepts donations in crypto-currencies, but charges a commission.
Thank you,
Evgeny
SimpleX Chat founder
## Disclaimer
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed and had many improvements in v1.0.0; we are currently arranging for the independent implementation audit.
You are likely to discover some bugs - we would really appreciate if you use it and let us know anything that needs to be fixed or improved.
## License
[AGPL v3](./LICENSE)
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
&nbsp;
[![Android app](https://github.com/simplex-chat/.github/blob/master/profile/images/google_play.svg)](https://play.google.com/store/apps/details?id=chat.simplex.app)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
+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>
+15 -4
View File
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 19
versionName "1.4"
versionCode 46
versionName "3.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -47,6 +47,7 @@ android {
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 {
@@ -75,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"
@@ -90,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'
+47 -22
View File
@@ -1,33 +1,42 @@
<?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.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.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">
android:name="SimplexApp"
android:allowBackup="true"
android:icon="@mipmap/icon"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<!-- Main activity -->
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.SimpleX">
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" />
@@ -36,12 +45,29 @@
<!-- open simplex:/ connection URI -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="simplex" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="simplex.chat" />
<data android:pathPrefix="/invitation" />
<data android:pathPrefix="/contact" />
</intent-filter>
</activity>
<activity android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true"/>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="chat.simplex.app.provider"
@@ -49,7 +75,7 @@
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
android:resource="@xml/file_paths" />
</provider>
<!-- SimplexService foreground service -->
@@ -57,8 +83,7 @@
android:name=".SimplexService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false">
</service>
android:stopWithTask="false"></service>
<!-- SimplexService restart on reboot -->
<receiver
@@ -66,7 +91,7 @@
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
@@ -74,6 +99,6 @@
<receiver
android:name=".SimplexService$AutoRestartReceiver"
android:enabled="true"
android:exported="false"/>
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,42 +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)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
// testJson()
val m = vm.chatModel
processNotificationIntent(intent, m)
setContent {
SimpleXTheme {
Surface(
@@ -47,21 +59,85 @@ 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.getAutoRestartWorkerVersion()
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.setAutoRestartWorkerVersion(SimplexService.SERVICE_START_WORKER_VERSION)
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
ExistingPeriodicWorkPolicy.REPLACE
}
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
@@ -71,6 +147,82 @@ class MainActivity: ComponentActivity() {
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()
}
}
}
)
}
}
class SimplexViewModel(application: Application): AndroidViewModel(application) {
@@ -79,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)
@@ -117,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")
@@ -131,7 +393,6 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
}
}
}
//fun testJson() {
// val str: String = """
// """.trimIndent()
@@ -5,7 +5,9 @@ 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.*
@@ -22,13 +24,15 @@ 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 {
val chatController: ChatController by lazy {
val ctrl = chatInit(applicationContext.filesDir.toString())
ChatController(ctrl, ntfManager, applicationContext)
val ctrl = chatInit(getFilesDirectory(applicationContext))
ChatController(ctrl, ntfManager, applicationContext, appPreferences)
}
val chatModel: ChatModel by lazy {
@@ -36,18 +40,25 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
private val ntfManager: NtfManager by lazy {
NtfManager(applicationContext)
NtfManager(applicationContext, appPreferences)
}
private val appPreferences: AppPreferences by lazy {
AppPreferences(applicationContext)
}
override fun onCreate() {
super.onCreate()
context = this
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
withApi {
val user = chatController.apiGetActiveUser()
if (user != null) {
if (user == null) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
chatController.startChat(user)
SimplexService.start(applicationContext)
chatController.showBackgroundServiceNotice()
chatController.showBackgroundServiceNoticeIfNeeded()
}
}
}
@@ -57,17 +68,23 @@ class SimplexApp: Application(), LifecycleEventObserver {
withApi {
when (event) {
Lifecycle.Event.ON_STOP ->
if (!chatController.getRunServiceInBackground()) SimplexService.stop(applicationContext)
if (!appPreferences.runServiceInBackground.get()) SimplexService.stop(applicationContext)
Lifecycle.Event.ON_START ->
SimplexService.start(applicationContext)
if (chatModel.chatRunning.value != false) SimplexService.start(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")
@@ -82,7 +99,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
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)
@@ -8,6 +8,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -46,7 +47,6 @@ class SimplexService: Service() {
val text = getString(R.string.simplex_service_notification_text)
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
}
@@ -65,10 +65,11 @@ class SimplexService: Service() {
withApi {
try {
val user = chatController.apiGetActiveUser()
if (user != null) {
if (user == null) {
chatController.chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
Log.w(TAG, "Starting foreground service")
chatController.startChat(user)
chatController.startReceiver()
isServiceStarted = true
saveServiceState(self, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
@@ -95,7 +96,6 @@ class SimplexService: Service() {
} catch (e: Exception) {
Log.d(TAG, "Service stopped without being started: ${e.message}")
}
isServiceStarted = false
saveServiceState(this, ServiceState.STOPPED)
}
@@ -118,12 +118,12 @@ class SimplexService: Service() {
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
}
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_icon)
.setSmallIcon(R.drawable.ntf_service_icon)
.setColor(0x88FFFF)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setSound(null)
.setSilent(true)
.setShowWhen(false) // no date/time
.build()
}
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(hideSecrets(cItem))
.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,19 +92,70 @@ 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 hideSecrets(cItem: ChatItem): String {
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) {
cItem.content.text
if (cItem.content.text != "") {
cItem.content.text
} else {
cItem.file?.fileName ?: ""
}
} else {
var res = ""
for (ft in md) {
@@ -75,13 +165,13 @@ class NtfManager(val context: Context) {
}
}
private fun getMsgPendingIntent(cInfo: ChatInfo) : PendingIntent{
Log.d(TAG, "getMsgPendingIntent ${cInfo.id}")
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,11 +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,
@@ -13,6 +13,7 @@ val Inter = FontFamily(
Font(R.font.inter_bold, weight = FontWeight.Bold),
Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold),
Font(R.font.inter_medium, weight = FontWeight.Medium),
Font(R.font.inter_light, weight = FontWeight.Light),
)
// Set of Material typography styles to start with
@@ -30,7 +31,7 @@ val Typography = Typography(
h3 = TextStyle(
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 19.sp
fontSize = 18.5.sp
),
body1 = TextStyle(
fontFamily = Inter,
@@ -3,12 +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.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@@ -18,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(msg = remember { mutableStateOf("") }, sendCommand) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
}
},
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Surface(
@@ -61,6 +82,10 @@ fun TerminalLayout(terminalItems: List<TerminalItem>, close: () -> Unit, sendCom
@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 ->
@@ -80,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)
}
}
@@ -99,8 +125,9 @@ fun PreviewTerminalLayout() {
SimpleXTheme {
TerminalLayout(
terminalItems = TerminalItem.sampleData,
close = {},
sendCommand = {}
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
sendCommand = {},
close = {}
)
}
}
@@ -1,15 +1,24 @@
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
@@ -17,146 +26,119 @@ 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, null)
)
chatModel.controller.startChat(user)
SimplexService.start(chatModel.controller.appContext)
chatModel.controller.showBackgroundServiceNotice()
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()
SimplexService.start(chatModel.controller.appContext)
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
}
}
@Composable
fun ProfileNameField(name: MutableState<String>, focusRequester: FocusRequester? = null) {
val modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.secondary)
.height(40.dp)
.clip(RoundedCornerShape(5.dp))
.padding(8.dp)
.navigationBarsWithImePadding()
BasicTextField(
value = name.value,
onValueChange = { name.value = it },
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true,
cursorBrush = SolidColor(HighOrLowlight)
)
}
@@ -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,16 +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.*
@@ -25,46 +26,88 @@ 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 = { deleteChatDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }
)
}
}
fun deleteChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_chat_question),
text = generalGetString(R.string.delete_chat_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()
}
}
}
)
}
// TODO move to GroupChatInfoView
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.leave_group_question),
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
confirmText = generalGetString(R.string.leave_group_button),
onConfirm = {
withApi { chatModel.controller.leaveGroup(groupInfo.groupId) }
}
)
}
@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.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,
@@ -88,35 +131,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)
}
}
@@ -130,7 +201,7 @@ fun PreviewChatInfoLayout() {
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
close = {}, deleteContact = {}
close = {}, deleteContact = {}, clearChat = {}
)
}
}
@@ -3,55 +3,64 @@ package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.util.Log
import androidx.activity.compose.BackHandler
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.ArrowBackIos
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.*
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 }
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 {
val quotedItem = remember { mutableStateOf<ChatItem?>(null) }
val editingItem = remember { mutableStateOf<ChatItem?>(null) }
var msg = remember { mutableStateOf("") }
BackHandler { chatModel.chatId.value = null }
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
delay(1000L)
if (chat.chatItems.count() > 0) {
delay(750L)
if (chat.chatItems.isNotEmpty()) {
chatModel.markChatItemsRead(chat.chatInfo)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
@@ -61,43 +70,31 @@ fun ChatView(chatModel: ChatModel) {
}
}
}
ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem,
ChatLayout(
user,
chat,
composeState,
composeView = {
if (chat.chatInfo.sendMsgEnabled) {
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) } },
openDirectChat = { contactId ->
val c = chatModel.chats.firstOrNull {
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
}
if (c != null) withApi { openChat(chatModel, c.chatInfo) }
if (c != null) withApi { openChat(c.chatInfo, chatModel) }
},
sendMessage = { msg ->
withApi {
// show "in progress"
val cInfo = chat.chatInfo
val ei = editingItem.value
if (ei != null) {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
mc = MsgContent.MCText(msg)
)
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
} else {
val newItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
quotedItemId = quotedItem.value?.meta?.itemId,
mc = MsgContent.MCText(msg)
)
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
}
// hide "in progress"
editingItem.value = null
quotedItem.value = null
}
},
resetMessage = { msg.value = "" },
deleteMessage = { itemId, mode ->
withApi {
val cInfo = chat.chatInfo
@@ -109,6 +106,28 @@ fun ChatView(chatModel: ChatModel) {
)
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
}
},
receiveFile = { fileId ->
withApi { chatModel.controller.receiveFile(fileId) }
},
joinGroup = { groupId ->
withApi { chatModel.controller.joinGroup(groupId) }
},
startCall = { media ->
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
chatModel.showCallView.value = true
chatModel.callCommand.value = WCallCommand.Capabilities
}
},
acceptCall = { contact ->
val invitation = chatModel.callInvitations.remove(contact.id)
if (invitation == null) {
AlertManager.shared.showAlertMsg("Call already ended!")
} else {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
}
)
}
@@ -118,16 +137,21 @@ fun ChatView(chatModel: ChatModel) {
fun ChatLayout(
user: User,
chat: Chat,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
msg: MutableState<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
useLinkPreviews: Boolean,
back: () -> Unit,
info: () -> Unit,
openDirectChat: (Long) -> Unit,
sendMessage: (String) -> Unit,
resetMessage: () -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit
) {
Surface(
Modifier
@@ -135,13 +159,26 @@ fun ChatLayout(
.background(MaterialTheme.colors.background)
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
bottomBar = { ComposeView(msg, quotedItem, editingItem, sendMessage, resetMessage) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage)
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, joinGroup, acceptCall)
}
}
}
}
@@ -149,34 +186,44 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
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)
}
}
Column {
Box(
Modifier
.fillMaxWidth()
.height(52.dp)
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
.padding(horizontal = 8.dp),
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
IconButton(onClick = back) {
Icon(
Icons.Outlined.ArrowBackIos,
"Back",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
val cInfo = chat.chatInfo
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 = 68.dp)
.padding(horizontal = 80.dp)
.fillMaxWidth()
.clickable(onClick = info),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val cInfo = chat.chatInfo
ChatInfoImage(chat, size = 40.dp)
ChatInfoImage(cInfo, size = 40.dp)
Column(
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
@@ -214,14 +261,16 @@ val CIListStateSaver = run {
fun ChatItemsList(
user: User,
chat: Chat,
composeState: MutableState<ComposeState>,
chatItems: List<ChatItem>,
msg: MutableState<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
useLinkPreviews: Boolean,
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit
) {
val listState = rememberLazyListState()
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))
@@ -257,11 +306,11 @@ fun ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
}
} else { // direct message
@@ -272,7 +321,7 @@ fun ChatItemsList(
end = if (sent) 12.dp else 76.dp,
)
) {
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
}
}
}
@@ -330,16 +379,21 @@ fun PreviewChatLayout() {
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,
msg = remember { mutableStateOf("") },
quotedItem = remember { mutableStateOf(null) },
editingItem = remember { mutableStateOf(null) },
useLinkPreviews = true,
back = {},
info = {},
openDirectChat = {},
sendMessage = {},
resetMessage = {},
deleteMessage = { _, _ -> }
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
startCall = {},
acceptCall = { _ -> }
)
}
}
@@ -373,16 +427,21 @@ fun PreviewGroupChatLayout() {
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,
msg = remember { mutableStateOf("") },
quotedItem = remember { mutableStateOf(null) },
editingItem = remember { mutableStateOf(null) },
useLinkPreviews = true,
back = {},
info = {},
openDirectChat = {},
sendMessage = {},
resetMessage = {},
deleteMessage = { _, _ -> }
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
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)
)
}
}
}
}
@@ -1,30 +1,488 @@
package chat.simplex.app.views.chat
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import chat.simplex.app.model.ChatItem
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
// TODO ComposeState
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(
msg: MutableState<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
sendMessage: (String) -> Unit,
resetMessage: () -> Unit
chatModel: ChatModel,
chat: Chat,
composeState: MutableState<ComposeState>,
attachmentOption: MutableState<AttachmentOption?>,
showChooseAttachment: () -> Unit
) {
Column {
when {
quotedItem.value != null -> {
ContextItemView(quotedItem)
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
}
editingItem.value != null -> {
ContextItemView(editingItem, editing = editingItem.value != null, resetMessage)
}
}
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 -> {}
}
SendMsgView(msg, sendMessage, editing = editingItem.value != null)
}
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
)
}
}
}
@@ -4,72 +4,65 @@ 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.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.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: MutableState<ChatItem?>,
editing: Boolean = false,
resetMessage: () -> Unit = {}
contextItem: ChatItem,
contextIcon: ImageVector,
cancelContextItem: () -> Unit
) {
val cxtItem = contextItem.value
if (cxtItem != null) {
val sent = cxtItem.chatDir.sent
val sent = contextItem.chatDir.sent
Row(
Modifier
.padding(top = 8.dp)
.background(if (sent) SentColorLight else ReceivedColorLight),
verticalAlignment = Alignment.CenterVertically
) {
Row(
Modifier
.padding(top = 8.dp)
.background(if (sent) SentColorLight else ReceivedColorLight),
.padding(vertical = 12.dp)
.fillMaxWidth()
.weight(1F),
verticalAlignment = Alignment.CenterVertically
) {
Box(
Modifier
.padding(start = 16.dp)
.padding(vertical = 12.dp)
.fillMaxWidth()
.weight(1F)
) {
ContextItemText(cxtItem)
}
IconButton(onClick = {
contextItem.value = null
if (editing) {
resetMessage()
}
}) {
Icon(
Icons.Outlined.Close,
contentDescription = "Cancel",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
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
)
}
}
}
@Composable
private fun ContextItemText(cxtItem: ChatItem) {
val member = cxtItem.memberDisplayName
if (member == null) {
Text(cxtItem.content.text, maxLines = 3)
} else {
val annotatedText = buildAnnotatedString {
withStyle(boldFont) { append(member) }
append(": ${cxtItem.content.text}")
IconButton(onClick = cancelContextItem) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.cancel_verb),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
Text(annotatedText, maxLines = 3)
}
}
@@ -78,13 +71,9 @@ private fun ContextItemText(cxtItem: ChatItem) {
fun PreviewContextItemView() {
SimpleXTheme {
ContextItemView(
contextItem = remember {
mutableStateOf(
ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
}
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
contextIcon = Icons.Filled.Edit,
cancelContextItem = {}
)
}
}
@@ -17,34 +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
import chat.simplex.app.views.chat.item.*
@Composable
fun SendMsgView(msg: MutableState<String>, sendMessage: (String) -> Unit, editing: Boolean = false) {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
var textStyle by remember { mutableStateOf(smallFont) }
fun SendMsgView(
composeState: MutableState<ComposeState>,
sendMessage: () -> Unit,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
val cs = composeState.value
BasicTextField(
value = msg.value,
onValueChange = {
msg.value = it
textStyle = if (isShortEmoji(it)) {
if (it.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
} else {
smallFont
}
},
textStyle = textStyle,
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(
@@ -64,24 +64,35 @@ fun SendMsgView(msg: MutableState<String>, sendMessage: (String) -> Unit, editin
) {
innerTextField()
}
val color = if (msg.value.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray
Icon(
if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward,
"Send Message",
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (msg.value.isNotEmpty()) {
sendMessage(msg.value)
msg.value = ""
textStyle = smallFont
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()
}
}
}
)
)
}
}
}
}
@@ -96,10 +107,14 @@ fun SendMsgView(msg: MutableState<String>, sendMessage: (String) -> Unit, editin
)
@Composable
fun PreviewSendMsgView() {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
SimpleXTheme {
SendMsgView(
msg = remember { mutableStateOf("") },
sendMessage = { msg -> println(msg) }
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
)
}
}
@@ -112,11 +127,36 @@ fun PreviewSendMsgView() {
)
@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(
msg = remember { mutableStateOf("") },
sendMessage = { msg -> println(msg) },
editing = true
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,59 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun CIGroupEventView(ci: ChatItem) {
fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
}
Surface {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
buildAnnotatedString {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
withGroupEventStyle(this, memberDisplayName)
append(" ")
}
withGroupEventStyle(this, ci.content.text)
append(" ")
withGroupEventStyle(this, ci.timestampText)
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun CIGroupEventViewPreview() {
SimpleXTheme {
CIGroupEventView(
ChatItem.getGroupEventSample()
)
}
}
@@ -0,0 +1,169 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun CIGroupInvitationView(
ci: ChatItem,
groupInvitation: CIGroupInvitation,
memberRole: GroupMemberRole,
joinGroup: (Long) -> Unit
) {
val sent = ci.chatDir.sent
val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending
@Composable
fun groupInfoView() {
val p = groupInvitation.groupProfile
val iconColor =
if (action) MaterialTheme.colors.primary
else if (isSystemInDarkTheme()) FileDark else FileLight
Row(
Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(vertical = 4.dp)
.padding(end = 2.dp)
) {
ProfileImage(size = 60.dp, icon = Icons.Filled.SupervisedUserCircle, color = iconColor)
Spacer(Modifier.padding(horizontal = 3.dp))
Column(
Modifier.defaultMinSize(minHeight = 60.dp),
verticalArrangement = Arrangement.Center
) {
Text(p.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (p.fullName != "" && p.displayName != p.fullName) {
Text(p.fullName, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
@Composable
fun groupInvitationText() {
when {
sent -> Text(stringResource(R.string.you_sent_group_invitation))
!sent && groupInvitation.status == CIGroupInvitationStatus.Pending -> Text(stringResource(R.string.you_are_invited_to_group))
!sent && groupInvitation.status == CIGroupInvitationStatus.Accepted -> Text(stringResource(R.string.you_joined_this_group))
!sent && groupInvitation.status == CIGroupInvitationStatus.Rejected -> Text(stringResource(R.string.you_rejected_group_invitation))
!sent && groupInvitation.status == CIGroupInvitationStatus.Expired -> Text(stringResource(R.string.group_invitation_expired))
}
}
fun acceptInvitation() {
Log.d(TAG, "CIGroupInvitationView acceptInvitation")
joinGroup(groupInvitation.groupId)
}
Surface(
modifier = if (action) Modifier.clickable(onClick = ::acceptInvitation) else Modifier,
shape = RoundedCornerShape(18.dp),
color = if (sent) SentColorLight else ReceivedColorLight,
) {
Box(
Modifier
.width(IntrinsicSize.Min)
.padding(vertical = 3.dp)
.padding(start = 8.dp, end = 12.dp),
contentAlignment = Alignment.BottomEnd
) {
Column(
Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 4.dp),
horizontalAlignment = Alignment.Start
) {
groupInfoView()
Column(Modifier.padding(top = 2.dp, start = 5.dp)) {
Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp))
if (action) {
groupInvitationText()
Text(stringResource(R.string.group_invitation_tap_to_join), color = MaterialTheme.colors.primary)
} else {
Box(Modifier.padding(end = 48.dp)) {
groupInvitationText()
}
}
}
}
Text(
ci.timestampText,
color = HighOrLowlight,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun PendingCIGroupInvitationViewPreview() {
SimpleXTheme {
CIGroupInvitationView(
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun CIGroupInvitationViewAcceptedPreview() {
SimpleXTheme {
CIGroupInvitationView(
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun CIGroupInvitationViewLongNamePreview() {
SimpleXTheme {
CIGroupInvitationView(
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(
groupProfile = GroupProfile("group_with_a_really_really_really_long_name", "Group With A Really Really Really Long Name"),
status = CIGroupInvitationStatus.Accepted
),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
)
}
}
@@ -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()
}
}
@@ -4,43 +4,64 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.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) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
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),
contentDescription = "Edited",
tint = HighOrLowlight,
modifier = Modifier.height(12.dp).padding(end = 1.dp),
contentDescription = stringResource(R.string.icon_descr_edited),
tint = metaColor,
)
}
// TODO status
CIStatusView(chatItem.meta.itemStatus, metaColor)
}
Text(
chatItem.timestampText,
color = HighOrLowlight,
fontSize = 14.sp
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
@Composable
fun PreviewCIMetaView() {
@@ -51,6 +72,48 @@ 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() {
@@ -62,6 +125,30 @@ fun PreviewCIMetaViewEdited() {
)
}
@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() {
@@ -3,6 +3,7 @@ package chat.simplex.app.views.chat.item
import android.content.Context
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
@@ -10,89 +11,153 @@ 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.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ComposeContextItem
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
@Composable
fun ChatItemView(
user: User,
cInfo: ChatInfo,
cItem: ChatItem,
msg: MutableState<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
showMember: Boolean = false,
deleteMessage: (Long, CIDeleteMode) -> Unit
useLinkPreviews: Boolean,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit
) {
val context = LocalContext.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
var showMenu by remember { mutableStateOf(false) }
val showMenu = remember { mutableStateOf(false) }
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
Box(
modifier = Modifier
.padding(bottom = 4.dp)
.fillMaxWidth(),
contentAlignment = alignment,
) {
Column(Modifier.combinedClickable(onLongClick = { showMenu = true }, onClick = {})) {
if (cItem.isMsgContent) {
if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
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)
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
}
} else if (cItem.isDeletedContent) {
DeletedItemView(cItem, showMember = showMember)
}
if (cItem.isMsgContent) {
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
ItemAction("Reply", Icons.Outlined.Reply, onClick = {
editingItem.value = null
quotedItem.value = cItem
showMenu = false
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("Share", Icons.Outlined.Share, onClick = {
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
shareText(cxt, cItem.content.text)
showMenu = false
showMenu.value = false
})
ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = {
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
copyText(cxt, cItem.content.text)
showMenu = false
showMenu.value = false
})
if (cItem.chatDir.sent && cItem.meta.editable) {
ItemAction("Edit", Icons.Filled.Edit, onClick = {
quotedItem.value = null
editingItem.value = cItem
msg.value = cItem.content.text
showMenu = false
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(
"Delete",
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu = false
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, showMember = showMember)
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
}
@Composable fun CallItem(status: CICallStatus, duration: Int) {
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
when (val c = cItem.content) {
is CIContent.SndMsgContent -> ContentItem()
is CIContent.RcvMsgContent -> ContentItem()
is CIContent.SndDeleted -> DeletedItem()
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup)
is CIContent.RcvGroupEventContent -> CIGroupEventView(cItem)
is CIContent.SndGroupEventContent -> CIGroupEventView(cItem)
}
}
}
}
@Composable
private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
DropdownMenuItem(onClick) {
Row {
Text(
@@ -109,8 +174,8 @@ private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, col
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
AlertManager.shared.showAlertDialogButtons(
title = "Delete message?",
text = "Message will be deleted - this cannot be undone!",
title = generalGetString(R.string.delete_message__question),
text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
buttons = {
Row(
Modifier
@@ -121,13 +186,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM
Button(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
AlertManager.shared.hideAlert()
}) { Text("For me only") }
}) { 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("For everyone") }
}) { Text(stringResource(R.string.for_everybody)) }
}
}
}
@@ -140,14 +205,17 @@ fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
msg = remember { mutableStateOf("") },
quotedItem = remember { mutableStateOf(null) },
editingItem = remember { mutableStateOf(null) },
useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
deleteMessage = { _, _ -> }
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
acceptCall = { _ -> }
)
}
}
@@ -158,12 +226,15 @@ fun PreviewChatItemViewDeletedContent() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(),
msg = remember { mutableStateOf("") },
quotedItem = remember { mutableStateOf(null) },
editingItem = remember { mutableStateOf(null) },
useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
deleteMessage = { _, _ -> }
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
acceptCall = { _ -> }
)
}
}
@@ -1,9 +1,8 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
@@ -11,10 +10,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
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 IntegrityErrorItemViewPreview() {
SimpleXTheme {
IntegrityErrorItemView(
ChatItem.getDeletedContentSampleData()
)
}
}
@@ -35,7 +35,7 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea
@Composable
fun MarkdownText (
content: ItemContent,
text: String,
formattedText: List<FormattedText>? = null,
sender: String? = null,
metaText: String? = null,
@@ -47,11 +47,11 @@ fun MarkdownText (
senderBold: Boolean = false,
modifier: Modifier = Modifier
) {
val reserve = if (edited) " " else " "
val reserve = if (edited) " " else " "
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
append(content.text)
append(text)
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
@@ -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 Chat founder")
}
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 {}
}
}
@@ -1,77 +1,368 @@
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.*
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 = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
stopped
)
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, stopped) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, 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 directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) {
if (chatInfo.ready) {
withApi { openChat(chatInfo, chatModel) }
} else {
pendingContactAlertDialog(chatInfo, chatModel)
}
}
fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel)
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert()
else -> withApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
}
}
suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
if (chat != null) {
chatModel.chatItems.clear()
chatModel.chatItems.addAll(chat.chatItems)
chatModel.chatId.value = chatInfo.id
}
}
@Composable
fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
}
ClearChatAction(chat, chatModel, showMenu)
DeleteChatAction(chat, chatModel, showMenu)
}
@Composable
fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> {
ItemAction(
stringResource(R.string.join_group_button),
Icons.Outlined.Login,
onClick = {
withApi { chatModel.controller.joinGroup(groupInfo.groupId) }
showMenu.value = false
}
)
if (groupInfo.canDelete) {
DeleteChatAction(chat, chatModel, showMenu)
}
}
else -> {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
}
ClearChatAction(chat, chatModel, showMenu)
if (groupInfo.membership.memberStatus != GroupMemberStatus.MemLeft) {
ItemAction(
stringResource(R.string.leave_group_button),
Icons.Outlined.Logout,
onClick = {
leaveGroupDialog(groupInfo, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
if (groupInfo.canDelete) {
DeleteChatAction(chat, chatModel, showMenu)
}
}
}
}
@Composable
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.mark_read),
Icons.Outlined.Check,
onClick = {
markChatRead(chat, chatModel)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
showMenu.value = false
}
)
}
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 ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.clear_verb),
Icons.Outlined.Restore,
onClick = {
clearChatDialog(chat.chatInfo, chatModel)
showMenu.value = false
},
color = WarningOrange
)
}
@Composable
fun DeleteChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteChatDialog(chat.chatInfo, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@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),
)
}
fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.join_group_question),
text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members),
confirmText = generalGetString(R.string.join_group_button),
onConfirm = { withApi { chatModel.controller.joinGroup(groupInfo.groupId) } },
dismissText = generalGetString(R.string.delete_verb),
onDismiss = { deleteGroup(groupInfo, chatModel) }
)
}
fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
withApi {
val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId)
if (r) {
chatModel.removeChat(groupInfo.id)
chatModel.chatId.value = null
chatModel.controller.ntfManager.cancelNotificationsForChat(groupInfo.id)
}
}
}
fun groupInvitationAcceptedAlert() {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.joining_group),
generalGetString(R.string.youve_accepted_group_invitation_connecting_to_inviting_group_member)
)
}
@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 +371,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 +398,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 +433,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 +468,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
)
}
}
@@ -7,18 +7,24 @@ 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.ui.theme.ToolbarDark
import chat.simplex.app.ui.theme.ToolbarLight
import chat.simplex.app.views.chat.ChatHelpView
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
@@ -61,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),
@@ -76,13 +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) {
@@ -98,40 +106,7 @@ fun ChatListView(chatModel: ChatModel) {
}
@Composable
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
Column(
Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = if (displayName != null) "Welcome ${displayName}!" else "Welcome!",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
ChatHelpView({ scaffoldCtrl.toggleSheet() })
Row(
Modifier.padding(top = 30.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"This text is available in settings",
color = MaterialTheme.colors.onBackground
)
Icon(
Icons.Outlined.Settings,
"Settings",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
)
}
}
}
@Composable
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -144,24 +119,35 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
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.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)
)
}
}
}
}
@@ -2,57 +2,114 @@ 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) {
val cInfo = chat.chatInfo
@Composable
fun chatPreviewTitleText(color: Color = Color.Unspecified) {
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = color
)
}
@Composable
fun chatPreviewTitle() {
when (cInfo) {
is ChatInfo.Direct ->
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> chatPreviewTitleText(MaterialTheme.colors.primary)
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight)
GroupMemberStatus.MemLeft ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.group_left_description),
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = HighOrLowlight
)
chatPreviewTitleText()
}
else -> chatPreviewTitleText()
}
else -> chatPreviewTitleText()
}
}
@Composable
fun chatPreviewText() {
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 {
when (cInfo) {
is ChatInfo.Direct ->
if (!cInfo.ready) {
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
}
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> Text(stringResource(R.string.you_are_invited_to_group))
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight)
else -> {}
}
else -> {}
}
}
}
Row {
ChatInfoImage(chat, size = 72.dp)
ChatInfoImage(cInfo, size = 72.dp)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
chat.chatInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold
)
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
MarkdownText(
ci.content, ci.formattedText, ci.memberDisplayName,
metaText = ci.timestampText,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
chatPreviewTitle()
chatPreviewText()
}
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,
@@ -62,22 +119,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,
@@ -87,6 +179,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_archive_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,475 @@
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.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.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(m.chatRunning.value ?: true) }
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, chatLastStart, context) },
stopChatAlert = { stopChatAlert(m, runChat, context) },
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>, chatLastStart: MutableState<Instant?>, context: Context) {
withApi {
try {
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
val ts = Clock.System.now()
m.controller.appPrefs.chatLastStart.set(ts)
chatLastStart.value = ts
SimplexService.start(context)
} catch (e: Error) {
runChat.value = false
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
}
}
}
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.stop_chat_question),
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
confirmText = generalGetString(R.string.stop_chat_confirmation),
onConfirm = { stopChat(m, runChat, context) },
onDismiss = { runChat.value = true }
)
}
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
withApi {
try {
m.controller.apiStopChat()
runChat.value = false
m.chatRunning.value = false
SimplexService.stop(context)
} 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 {
@@ -40,9 +41,9 @@ class AlertManager {
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) }
@@ -69,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 {
@@ -11,43 +11,46 @@ 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, chat.chatInfo.image, icon)
ProfileImage(size, chatInfo.image, icon)
}
@Composable
fun ProfileImage(
size: Dp,
image: String? = null,
icon: ImageVector = Icons.Filled.AccountCircle
icon: ImageVector = Icons.Filled.AccountCircle,
color: Color = MaterialTheme.colors.secondary
) {
Box(Modifier.size(size)) {
if (image == null) {
Icon(
icon,
contentDescription = "profile image placeholder",
tint = MaterialTheme.colors.secondary,
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
tint = color,
modifier = Modifier.fillMaxSize()
)
} else {
val imageBitmap = base64ToBitmap(image).asImageBitmap()
Image(
imageBitmap,
"profile image",
stringResource(R.string.image_descr_profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier.size(size).padding(size / 12).clip(CircleShape)
)
@@ -60,7 +63,7 @@ fun ProfileImage(
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)
)
@@ -20,48 +20,75 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.runtime.*
import androidx.compose.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.BuildConfig
import chat.simplex.app.TAG
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 bitmapToBase64(bitmap: Bitmap, squareCrop: Boolean = true): String {
val size = 104
var height = size
var width = size
fun cropToSquare(image: Bitmap): Bitmap {
var xOffset = 0
var yOffset = 0
if (bitmap.height < bitmap.width) {
width = height * bitmap.width / bitmap.height
xOffset = (width - height) / 2
val side = min(image.height, image.width)
if (image.height < image.width) {
xOffset = (image.width - side) / 2
} else {
height = width * bitmap.height / bitmap.width
yOffset = (height - width) / 2
yOffset = (image.height - side) / 2
}
var image = bitmap
while (image.width / 2 > width) {
image = Bitmap.createScaledBitmap(image, image.width / 2, image.height / 2, true)
}
image = Bitmap.createScaledBitmap(image, width, height, true)
if (squareCrop) {
image = Bitmap.createBitmap(image, xOffset, yOffset, size, size)
}
val stream = ByteArrayOutputStream()
image.compress(Bitmap.CompressFormat.JPEG, 85, stream)
return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
return Bitmap.createBitmap(image, xOffset, yOffset, side, side)
}
fun base64ToBitmap(base64ImageString: String) : Bitmap {
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,")
@@ -69,7 +96,7 @@ fun base64ToBitmap(base64ImageString: String) : Bitmap {
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
}
class CustomTakePicturePreview : ActivityResultContract<Void?, Bitmap?>() {
class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
private var uri: Uri? = null
private var tmpFile: File? = null
lateinit var externalContext: Context
@@ -95,17 +122,21 @@ class CustomTakePicturePreview : ActivityResultContract<Void?, Bitmap?>() {
tmpFile?.delete()
bitmap
} else {
Log.e( TAG, "Getting image from camera cancelled or failed.")
Log.e(TAG, "Getting image from camera cancelled or failed.")
tmpFile?.delete()
null
}
}
}
@Composable
fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
//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)
@@ -114,33 +145,37 @@ fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher
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(
profileImageStr: MutableState<String?>,
imageBitmap: MutableState<Bitmap?>,
onImageChange: (Bitmap) -> Unit,
hideBottomSheet: () -> Unit
) {
val context = LocalContext.current
val isCameraSelected = remember { mutableStateOf (false) }
val galleryLauncher = rememberGalleryLauncher { uri: Uri? ->
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
profileImageStr.value = bitmapToBase64(bitmap)
imageBitmap.value = bitmap
onImageChange(bitmap)
}
}
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
if (bitmap != null) profileImageStr.value = bitmapToBase64(bitmap)
if (bitmap != null) {
imageBitmap.value = bitmap
onImageChange(bitmap)
}
}
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
if (isCameraSelected.value) cameraLauncher.launch(null)
else galleryLauncher.launch("image/*")
cameraLauncher.launch(null)
hideBottomSheet()
} else {
Toast.makeText(context, "Permission Denied!", Toast.LENGTH_SHORT).show()
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
@@ -158,29 +193,20 @@ fun GetImageBottomSheet(
.padding(horizontal = 8.dp, vertical = 30.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ActionButton(null, "Use Camera", icon = Icons.Outlined.PhotoCamera) {
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 -> {
isCameraSelected.value = true
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
ActionButton(null, "From Gallery", icon = Icons.Outlined.Collections) {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> {
galleryLauncher.launch("image/*")
hideBottomSheet()
}
else -> {
isCameraSelected.value = false
permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
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.*
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 {
@@ -17,3 +28,50 @@ 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,41 @@
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.*
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.*
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 +66,313 @@ 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 = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000)
parcelFileDescriptor?.close()
image
} catch (e: Exception) {
null
}
} else {
null
}
}
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap {
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
inJustDecodeBounds = false
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
}
}
private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
// Raw height and width of image
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
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,11 +43,11 @@ fun AddContactLayout(connReq: String, share: () -> Unit) {
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
"Add contact",
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",
stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
style = MaterialTheme.typography.h3,
textAlign = TextAlign.Center,
)
@@ -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,46 +42,65 @@ 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
)
}
}
}
}
@@ -85,33 +108,35 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) {
@Composable
fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}) {
Column(
Modifier
.clickable(onClick = click)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Icon(icon, text,
tint = tint,
modifier = Modifier
.size(40.dp)
.padding(bottom = 8.dp))
if (text != null) {
Text(
text,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
color = tint,
modifier = Modifier.padding(bottom = 4.dp)
)
}
if (comment != null) {
Text(
comment,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2
)
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
)
}
}
}
}
@@ -122,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,11 +74,11 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Scan QR code",
generalGetString(R.string.scan_QR_code),
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
)
Text(
"Your chat profile will be sent\nto your contact",
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,9 +7,11 @@ 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
@@ -18,21 +20,28 @@ import chat.simplex.app.ui.theme.SimpleXTheme
fun MarkdownHelpView() {
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,19 +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.HighOrLowlight
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 SMPServersView(chatModel: ChatModel) {
@@ -32,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)
@@ -60,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) },
@@ -95,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,
@@ -108,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,
@@ -127,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(),
@@ -173,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)
})
)
@@ -205,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),
@@ -219,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)
@@ -241,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)
)
}
@@ -256,9 +219,8 @@ fun PreviewSMPServersLayoutDefaultServers() {
SMPServersLayout(
isUserSMPServers = false,
editSMPServers = true,
userSMPServersStr = "",
userSMPServersStr = remember { mutableStateOf("") },
isUserSMPServersOnOff = {},
editUserSMPServersStr = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
@@ -273,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 = {},
@@ -290,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,46 +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.*
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.HighOrLowlight
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.helpers.ProfileImage
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 = { on ->
chatModel.controller.setRunServiceInBackground(on)
chatModel.runServiceInBackground.value = on
},
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) } },
)
}
}
@@ -51,174 +69,267 @@ val simplexTeamUri =
@Composable
fun SettingsLayout(
profile: Profile,
stopped: Boolean,
runServiceInBackground: MutableState<Boolean>,
setRunServiceInBackground: (Boolean) -> Unit,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> 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(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
ProfileImage(size = 60.dp, profile.image)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
Text(
profile.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
)
Text(profile.fullName)
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(
"Chat with the founder",
color = MaterialTheme.colors.primary
)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) {
Icon(
Icons.Outlined.Email,
contentDescription = "Email",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Send us email",
color = MaterialTheme.colors.primary
)
}
Spacer(Modifier.height(24.dp))
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() {
Icon(
Icons.Outlined.Bolt,
contentDescription = "Private notifications",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("Private notifications", Modifier
.padding(end = 24.dp)
.fillMaxWidth()
.weight(1F))
Switch(
checked = runServiceInBackground.value,
onCheckedChange = { setRunServiceInBackground(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
modifier = Modifier.padding(end = 8.dp)
)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showTerminal) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
contentDescription = "Chat console",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("Chat console")
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
Icon(
painter = painterResource(id = R.drawable.ic_github),
contentDescription = "GitHub",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
buildAnnotatedString {
append("Install ")
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
append("SimpleX Chat for terminal")
}
}
)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView() {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
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()
}
}
}
}
@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(
Icons.Outlined.Archive,
contentDescription = stringResource(R.string.database_export_and_import),
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
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)? = null, height: Dp = 46.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(
if (click == null) modifier else modifier.clickable(onClick = click),
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
verticalAlignment = Alignment.CenterVertically
) {
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)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -230,11 +341,15 @@ fun PreviewSettingsLayout() {
SimpleXTheme {
SettingsLayout(
profile = Profile.sampleData,
stopped = false,
runServiceInBackground = remember { mutableStateOf(true) },
setRunServiceInBackground = {},
showModal = {{}},
showCustomModal = {{}},
showTerminal = {}
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,6 +1,7 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@@ -9,21 +10,24 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.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.*
import chat.simplex.app.views.newchat.ModalView
import chat.simplex.app.views.isValidDisplayName
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@@ -32,7 +36,7 @@ import kotlinx.coroutines.launch
fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
val user = chatModel.currentUser.value
if (user != null) {
var editProfile = remember { mutableStateOf(false) }
val editProfile = remember { mutableStateOf(false) }
var profile by remember { mutableStateOf(user.profile) }
UserProfileLayout(
close = close,
@@ -63,6 +67,7 @@ fun UserProfileLayout(
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()
@@ -74,9 +79,12 @@ fun UserProfileLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetContent = {
GetImageBottomSheet(profileImage, hideBottomSheet = {
scope.launch { bottomSheetModalState.hide() }
})
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)
@@ -89,16 +97,16 @@ fun UserProfileLayout(
horizontalAlignment = Alignment.Start
) {
Text(
"Your chat profile",
stringResource(R.string.your_chat_profile),
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"Your profile is stored on your device and shared only with your contacts.\n\n" +
"SimpleX servers cannot see your profile.",
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
color = MaterialTheme.colors.onBackground,
lineHeight = 22.sp
)
if (editProfile.value) {
Column(
@@ -121,19 +129,37 @@ fun UserProfileLayout(
}
}
}
ProfileNameTextField(displayName)
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("Cancel") {
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))
TextButton("Save (and notify contacts)") {
saveProfile(displayName.value, fullName.value, profileImage.value)
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 {
@@ -154,9 +180,9 @@ fun UserProfileLayout(
}
}
}
ProfileNameRow("Display name:", profile.displayName)
ProfileNameRow("Full name:", profile.fullName)
TextButton("Edit") { editProfile.value = true }
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) {
@@ -180,6 +206,7 @@ private fun ProfileNameTextField(name: MutableState<String>) {
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(
@@ -223,7 +250,7 @@ fun EditImageButton(click: () -> Unit) {
) {
Icon(
Icons.Outlined.PhotoCamera,
contentDescription = "Edit image",
contentDescription = stringResource(R.string.edit_image),
tint = MaterialTheme.colors.primary,
modifier = Modifier.size(36.dp)
)
@@ -235,7 +262,7 @@ fun DeleteImageButton(click: () -> Unit) {
IconButton(onClick = click) {
Icon(
Icons.Outlined.Close,
contentDescription = "Delete image",
contentDescription = stringResource(R.string.delete_image),
tint = MaterialTheme.colors.primary,
)
}
@@ -272,7 +299,7 @@ fun PreviewUserProfileLayoutEditOn() {
close = {},
profile = Profile.sampleData,
editProfile = remember { mutableStateOf(true) },
saveProfile = {_, _, _ ->}
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.
@@ -0,0 +1,518 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
<string name="thousand_abbreviation">т</string>
<!-- Connect via Link - MainActivity.kt -->
<string name="connect_via_contact_link">Соединиться через ссылку-контакт?</string>
<string name="connect_via_invitation_link">Соединиться через ссылку-приглашение?</string>
<string name="profile_will_be_sent_to_contact_sending_link">Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку.</string>
<string name="connect_via_link_verb">Соединиться</string>
<!-- Server info - ChatModel.kt -->
<string name="server_connected">Соединение установлено</string>
<string name="server_connecting">Соединение устанавливается…</string>
<string name="connected_to_server_to_receive_messages_from_contact">Установлено соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="trying_to_connect_to_server_to_receive_messages">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">удалено</string>
<string name="sending_files_not_yet_supported">отправка файлов не поддерживается</string>
<string name="receiving_files_not_yet_supported">получение файлов не поддерживается</string>
<string name="sender_you_pronoun">вы</string>
<string name="unknown_message_format">неизвестный формат сообщения</string>
<string name="invalid_message_format">неверный формат сообщения</string>
<!-- PendingContactConnection - ChatModel.kt -->
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
<string name="display_name_connection_established">соединение установлено</string>
<string name="display_name_invited_to_connect">приглашение соединиться</string>
<string name="display_name_connecting">соединяется…</string>
<string name="description_you_shared_one_time_link">вы создали одноразовую ссылку</string>
<string name="description_via_contact_address_link">через ссылку-контакт</string>
<string name="description_via_one_time_link">через одноразовую ссылку</string>
<!-- SMP Server Information - SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Ошибка при сохранении SMP серверов</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется.</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="contact_already_exists">Существующий контакт</string>
<string name="you_are_already_connected_to_vName_via_this_link">Вы уже соединены с <xliff:g id="contactName" example="Alice">%1$s!</xliff:g> через эту ссылку.</string>
<string name="invalid_connection_link">Ошибка в ссылке контакта</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Пожалуйста, проверьте, что вы использовали правильную ссылку, или попросите ваш контакт отправить вам новую.</string>
<string name="connection_error_auth">Ошибка соединения (AUTH)</string>
<string name="connection_error_auth_desc">Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.</string>
<string name="cannot_delete_contact">Невозможно удалить контакт!</string>
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Контакт <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> не может быть удален, так как является членом групп(ы) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
<string name="error_deleting_contact">Ошибка удаления контакта</string>
<string name="error_deleting_group">Ошибка удаления группы</string>
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
<string name="error_deleting_pending_contact_connection">Ошибка удаления ожидаемого соединения</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
<string name="private_instant_notifications">Приватные мгновенные уведомления!</string>
<string name="private_instant_notifications_disabled">Приватные уведомления выключены!</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает <b>фоновый сервис <xliff:g id="appName">SimpleX</xliff:g></b>, который потребляет несколько процентов батареи в день.</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Он может быть выключен через Настройки</b> – вы продолжите получать уведомления о сообщениях пока приложение запущено.</string>
<string name="turn_off_battery_optimization">Для использования приватных уведомлений, пожалуйста отключите оптимизацию батареи для <xliff:g id="appName">SimpleX</xliff:g> в слеующем диалоге. Иначе уведомления будут выключены.</string>
<string name="turning_off_background_service">Оптимизация батареи включена, поэтому приватные уведомления выключены. Вы можете снова включить их через Настройки.</string>
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
<string name="simplex_service_notification_text">Приём сообщений…</string>
<!-- local authentication notice - SimpleXAPI.kt -->
<string name="la_notice_title_simplex_lock">Блокировка SimpleX</string>
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Чтобы защитить вашу информацию, включите блокировку <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.\nВам будет нужно пройти аутентификацию для включения блокировки.</string>
<string name="la_notice_turn_on">Включить</string>
<!-- LocalAuthentication.kt -->
<string name="auth_simplex_lock_turned_on">Блокировка SimpleX включена</string>
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме.</string>
<string name="auth_unlock">Разблокировать</string>
<string name="auth_log_in_using_credential">Пройдите аутентификацию</string>
<string name="auth_enable_simplex_lock">Включить блокировку SimpleX</string>
<string name="auth_disable_simplex_lock">Отключить блокировку SimpleX</string>
<string name="auth_confirm_credential">Пройдите аутентификацию</string>
<string name="auth_error">Ошибка аутентификации</string>
<string name="auth_error_w_desc">Ошибка аутентификации: <xliff:g id="desc">%1$s</xliff:g></string>
<string name="auth_failed">Ошибка аутентификации</string>
<string name="auth_unavailable">Аутентификация недоступна</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации.</string>
<string name="auth_device_authentication_is_disabled_turning_off">Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</string>
<string name="auth_retry">Повторить</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Ответить</string>
<string name="share_verb">Поделиться</string>
<string name="copy_verb">Копировать</string>
<string name="save_verb">Сохранить</string>
<string name="edit_verb">Редактировать</string>
<string name="delete_verb">Удалить</string>
<string name="delete_message__question">Удалить сообщение?</string>
<string name="delete_message_cannot_be_undone_warning">Сообщение будет удалено – это действие нельзя отменить!</string>
<string name="for_me_only">Только для меня</string>
<string name="for_everybody">Для всех</string>
<!-- CIMetaView.kt -->
<string name="icon_descr_edited">отредактировано</string>
<string name="icon_descr_sent_msg_status_sent">отправлено</string>
<string name="icon_descr_sent_msg_status_unauthorized_send">ошибка авторизации при отправке</string>
<string name="icon_descr_sent_msg_status_send_failed">ошибка при отправке</string>
<string name="icon_descr_received_msg_status_unread">не прочитано</string>
<!-- ChatListView.kt -->
<string name="personal_welcome">Здравствуйте <xliff:g>%1$s</xliff:g>!</string>
<string name="welcome">Здравствуйте!</string>
<string name="this_text_is_available_in_settings">Этот текст можно найти в Настройках</string>
<string name="your_chats">Ваши чаты</string>
<string name="contact_connection_pending">соединяется…</string>
<string name="group_connection_pending">соединяется…</string>
<!-- ComposeView.kt, helpers -->
<string name="attach">Прикрепить</string>
<string name="icon_descr_context">Значок контекста</string>
<string name="icon_descr_cancel_image_preview">Удалить превью изображения</string>
<string name="icon_descr_cancel_file_preview">Удалить превью файла</string>
<!-- Images - CIImageView.kt -->
<string name="image_descr">Изображение</string>
<string name="icon_descr_waiting_for_image">Ожидается прием изображения</string>
<string name="icon_descr_image_snd_complete">Изображение отправлено</string>
<string name="waiting_for_image">Ожидается прием изображения</string>
<string name="image_will_be_received_when_contact_is_online">Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже!</string>
<string name="image_saved">Изображение сохранено в Галерею</string>
<!-- Files - CIFileView.kt -->
<string name="icon_descr_file">Файл</string>
<string name="large_file">Большой файл!</string>
<string name="contact_sent_large_file">Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
<string name="maximum_supported_file_size">В настоящее время максимальный поддерживаемый размер файла составляет <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
<string name="waiting_for_file">Ожидается прием файла</string>
<string name="file_will_be_received_when_contact_is_online">Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже!</string>
<string name="file_saved">Файл сохранен</string>
<string name="file_not_found">Файл не найден</string>
<string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_chat_question">Удалить чат?</string>
<string name="delete_chat_all_messages_deleted_cannot_undo_warning">Чат и все сообщения будут удалены - это действие нельзя отменить!</string>
<string name="button_delete_contact">Удалить контакт</string>
<string name="icon_descr_server_status_connected">Соединение с сервером установлено</string>
<string name="icon_descr_server_status_disconnected">Соединение с сервером не установлено</string>
<string name="icon_descr_server_status_error">Ошибка соединения с сервером</string>
<string name="icon_descr_server_status_pending">Ожидается соединение с сервером</string>
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Отправить сообщение</string>
<!-- General Actions / Responses -->
<string name="back">Назад</string>
<string name="cancel_verb">Отменить</string>
<string name="confirm_verb">Подтвердить</string>
<string name="ok"></string>
<string name="no_details">нет описания</string>
<string name="add_contact">Добавить контакт</string>
<!-- NewChatSheet -->
<string name="create_one_time_link">Создать одноразовую ссылку</string>
<string name="paste_received_link">Вставить полученную ссылку</string>
<string name="scan_QR_code">Сканировать QR код</string>
<string name="to_share_with_your_contact">(чтобы отправить вашему контакту)</string>
<string name="in_person_or_in_video_call__bracketed">(при встрече или через видеозвонок)</string>
<string name="paste_received_link_from_clipboard">(вставить ссылку из буфера обмена)</string>
<!-- GetImageView -->
<string name="toast_permission_denied">Разрешение не получено!</string>
<string name="use_camera_button">Камера</string>
<string name="from_gallery_button">Галерея</string>
<string name="choose_file">Файлы</string>
<!-- help - ChatHelpView.kt -->
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
<string name="you_can_connect_to_simplex_chat_founder">Вы можете <font color="#0088ff">соединиться с разработчиками</font>, чтобы задать любые вопросы или получать уведомления о новых версиях.</string>
<string name="to_start_a_new_chat_help_header">Чтобы начать новый чат</string>
<string name="chat_help_tap_button">Нажмите кнопку</string>
<string name="above_then_preposition_continuation">сверху, затем:</string>
<string name="add_new_contact_to_create_one_time_QR_code"><b>Добавить новый контакт</b>: чтобы создать одноразовый QR код/ссылку для вашего контакта.</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Сканировать QR код</b>: чтобы соединиться с контактом, который показывает вам QR код.</string>
<string name="to_connect_via_link_title">Чтобы соединиться через ссылку</string>
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Если вы получили ссылку с приглашением из <xliff:g id="appName">SimpleX Chat</xliff:g>, вы можете открыть ее в браузере:</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 на компьютере: сосканируйте показанный QR код из приложения через <b>Сканировать QR код</b>.</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 на мобильном: намжите кнопку <b>Open in mobile app</b> на веб странице, затем нажмите <b>Соединиться</b> в приложении.</string>
<!-- Contact Request Alert Dialogue - CharListNavLinkView.kt -->
<string name="accept_connection_request__question">Принять запрос на соединение?</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Отправителю НЕ будет послано уведомление, если вы отклоните запрос на соединение.</string>
<string name="accept_contact_button">Принять</string>
<string name="reject_contact_button">Отклонить</string>
<!-- Clear Chat - ChatListNavLinkView.kt -->
<string name="clear_chat_question">Очистить чат?</string>
<string name="clear_chat_warning">Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для вас.</string>
<string name="clear_verb">Очистить</string>
<string name="clear_chat_button">Очистить чат</string>
<string name="mark_read">Прочитано</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">Вы пригласили ваш контакт</string>
<string name="you_accepted_connection">Вы приняли приглашение соединиться</string>
<string name="delete_pending_connection__question">Удалить ожидаемое соединение?</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Контакт, которому вы отправили эту ссылку, не сможет соединиться!</string>
<string name="connection_you_accepted_will_be_cancelled">Подтвержденное соединение будет отменено!</string>
<!-- Connection Pending Alert Dialogue - ChatListNavLinkView.kt -->
<string name="alert_title_contact_connection_pending">Соединение еще не установлено!</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой).</string>
<!-- Contact Request Information - ContactRequestView.kt -->
<string name="contact_wants_to_connect_with_you">хочет соединиться с вами!</string>
<!-- Image Placeholder - ChatInfoImage.kt -->
<string name="icon_descr_profile_image_placeholder">аватар не установлен</string>
<string name="image_descr_profile_image">аватар</string>
<!-- Content Descriptions -->
<string name="icon_descr_close_button">закрыть</string>
<string name="image_descr_link_preview">изображение превью ссылки</string>
<string name="icon_descr_cancel_link_preview">удалить превью ссылки</string>
<string name="icon_descr_settings">Настройки</string>
<string name="image_descr_qr_code">QR код</string>
<string name="icon_descr_address"><xliff:g id="appName">SimpleX</xliff:g> адрес</string>
<string name="icon_descr_help">Помощь</string>
<string name="icon_descr_simplex_team"><xliff:g id="appName">SimpleX</xliff:g> команда</string>
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> логотип</string>
<string name="icon_descr_email">Email</string>
<!-- Add Contact - AddContactView.kt -->
<string name="invalid_QR_code">Неверный QR код</string>
<string name="this_QR_code_is_not_a_link">Этот QR код не является ссылкой!</string>
<string name="invalid_contact_link">Неверная ссылка!</string>
<string name="this_link_is_not_a_valid_connection_link">Эта ссылка не является ссылкой-приглашением!</string>
<string name="connection_request_sent">Запрос на соединение послан!</string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Соединение будет установлено когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">Соединение будет установлено когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже!</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Покажите QR код вашему контакту, чтобы сосканировать его из приложения</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Если вы не можете встретиться лично, вы можете <b>показать QR код во время видеозвонка</b> или отправить ссылку через любой другой канал связи.</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Ваш профиль будет отправлен\nвашему контакту</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Если вы не можете встретиться лично, вы можете <b>сосканировать QR код во время видеозвонка</b>, или ваш контакт может отправить вам ссылку.</string>
<string name="share_invitation_link">Поделиться ссылкой</string>
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
<!-- settings - SettingsView.kt -->
<string name="your_settings">Настройки</string>
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
<string name="database_export_and_import">Экспорт и импорт архива чата</string>
<string name="about_simplex_chat">Информация о <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="how_to_use_simplex_chat">Как использовать</string>
<string name="markdown_help">Форматирование сообщений</string>
<string name="markdown_in_messages">Форматирование сообщений</string>
<string name="chat_with_the_founder">Соединиться с разработчиками</string>
<string name="send_us_an_email">Отправить email</string>
<string name="private_notifications">Приватные уведомления</string>
<string name="chat_lock">Блокировка SimpleX</string>
<string name="chat_console">Консоль</string>
<string name="smp_servers">SMP серверы</string>
<string name="install_simplex_chat_for_terminal"><font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</font></string>
<string name="use_simplex_chat_servers__question">Использовать серверы предосталенные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>?</string>
<string name="saved_SMP_servers_will_br_removed">Сохраненные SMP серверы будут удалены.</string>
<string name="your_SMP_servers">Ваши SMP серверы</string>
<string name="configure_SMP_servers">Настройка SMP серверов</string>
<string name="using_simplex_chat_servers">Используются серверы предоставленные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<string name="enter_one_SMP_server_per_line">Введите SMP серверы, каждый сервер в отдельной строке:</string>
<string name="how_to">Инфо</string>
<string name="save_servers_button">Сохранить</string>
<!-- Address Items - UserAddressView.kt -->
<string name="create_address">Создать адрес</string>
<string name="delete_address__question">Удалить адрес?</string>
<string name="all_your_contacts_will_remain_connected">Все контакты, которые соединились через этот адрес, сохранятся.</string>
<string name="your_chat_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Вы можете использовать адрес как ссылку или как QR код - через него можно с вами соединиться.</string>
<string name="if_you_delete_address_you_wont_lose_contacts">Вы сможете удалить адрес, сохранив контакты, которые через него соединились.</string>
<string name="share_link">Поделиться\nссылкой</string>
<string name="delete_address">Удалить\nадрес</string>
<!-- User profile details - UserProfileView.kt -->
<string name="display_name__field">Имя профиля:</string>
<string name="full_name__field">"Полное имя:</string>
<string name="your_chat_profile">Ваш профиль</string>
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\n<xliff:g id="appName">SimpleX</xliff:g> серверы не могут получить доступ к вашему профилю.</string>
<string name="edit_image">Поменять аватар</string>
<string name="delete_image">Удалить аватар</string>
<string name="save_and_notify_contacts">Сохранить (и послать обновление контактам)</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность.</string>
<string name="we_do_not_store_contacts_or_messages_on_servers">Мы не храним ваши контакты и сообщения (после доставки) на серверах.</string>
<string name="create_profile">Создать профиль</string>
<string name="your_profile_is_stored_on_your_device">Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве.</string>
<string name="profile_is_only_shared_with_your_contacts">Профиль отправляется только вашим контактам.</string>
<string name="display_name_cannot_contain_whitespace">Имя профиля не может содержать пробелы.</string>
<string name="display_name">Имя профиля</string>
<string name="full_name_optional__prompt">Полное имя (не обязательно)</string>
<string name="create_profile_button">Создать</string>
<string name="about_simplex">О SimpleX</string>
<!-- markdown demo - MarkdownHelpView.kt -->
<string name="how_to_use_markdown">Как форматировать</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Вы можете форматировать сообщения:</string>
<string name="bold">жирный</string>
<string name="italic">курсив</string>
<string name="strikethrough">зачеркнуть</string>
<string name="a_plus_b">a + b</string>
<string name="colored">цвет</string>
<string name="secret">секрет</string>
<string name="connect_via_link">Соединиться через ссылку</string>
<string name="this_string_is_not_a_connection_link">Эта строка не является ссылкой-приглашением!</string>
<string name="you_can_also_connect_by_clicking_the_link">Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку <b>Open in mobile app</b>.</string>
<string name="add_contact_to_start_new_chat">Добавьте контакт, чтобы начать разговор:</string>
<!-- CICallStatus -->
<string name="callstatus_calling">входящий звонок…</string>
<string name="callstatus_missed">пропущенный звонок</string>
<string name="callstatus_rejected">отклоненный звонок</string>
<string name="callstatus_accepted">принятый звонок</string>
<string name="callstatus_connecting">звонок соединяется…</string>
<string name="callstatus_in_progress">активный звонок</string>
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstatus_error">ошибка звонка</string>
<!-- CallState -->
<string name="callstate_starting">инициализация…</string>
<string name="callstate_waiting_for_answer">ожидается ответ…</string>
<string name="callstate_waiting_for_confirmation">ожидается подтверждение…</string>
<string name="callstate_received_answer">получен ответ…</string>
<string name="callstate_received_confirmation">получено подтверждение…</string>
<string name="callstate_connecting">соединяется…</string>
<string name="callstate_connected">соединено</string>
<string name="callstate_ended">завершен</string>
<!-- SimpleXInfo -->
<string name="next_generation_of_private_messaging">Новое поколение приватных сообщений</string>
<string name="privacy_redefined">Более конфиденциальный</string>
<string name="first_platform_without_user_ids">Первая в мире платформа без идентификаторов пользователей.</string>
<string name="immune_to_spam_and_abuse">Защищен от спама</string>
<string name="people_can_connect_only_via_links_you_share">С вами можно соединиться только через созданные вами ссылки.</string>
<string name="decentralized">Децентрализованный</string>
<string name="opensource_protocol_and_code_anybody_can_run_servers">Открытый протокол и код - кто угодно может запустить сервер.</string>
<string name="create_your_profile">Создать профиль</string>
<string name="make_private_connection">Добавьте контакт</string>
<string name="how_it_works">Как это работает</string>
<!-- How SimpleX Works -->
<string name="how_simplex_works">Как <xliff:g id="appName">SimpleX</xliff:g> работает</string>
<string name="many_people_asked_how_can_it_deliver">Много пользователей спросили: <i>как <xliff:g id="appName">SimpleX</xliff:g> доставляет сообщения без идентификаторов пользователей?</i></string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, <xliff:g id="appName">SimpleX</xliff:g> использует ID для очередей сообщений, разные для каждого контакта.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Вы определяете через какие серверы вы <b>получаете сообщения</b>, ваши контакты - серверы, которые вы используете для отправки.</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются <b>с двухуровневым end-to-end шифрованием</b>.</string>
<string name="read_more_in_github">Узнайте больше из нашего GitHub репозитория.</string>
<string name="read_more_in_github_with_link">Узнайте больше из нашего <font color="#0088ff">GitHub репозитория</font>.</string>
<!-- MakeConnection -->
<string name="to_make_your_first_private_connection_choose">Чтобы добавить ваш первый контакт, выберите <b>одно из</b>:</string>
<string name="create_1_time_link_qr_code">Создать ссылку / QR код</string>
<string name="it_s_secure_to_share__only_one_contact_can_use_it">Ей безопасно поделиться - только один контакт может использовать её.</string>
<string name="paste_the_link_you_received">Вставьте полученную ссылку</string>
<string name="or_open_the_link_in_the_browser_and_tap_open_in_mobile">Или откройте ссылку в браузере и нажмите <b>Open in mobile</b>.</string>
<string name="scan_contact_s_qr_code">Сосканировать QR код контакта</string>
<string name="in_person_or_via_a_video_call__the_most_secure_way_to_connect">При встрече или в видеозвонке – самый безопасный способ установить соединение</string>
<string name="or">или</string>
<string name="connect_with_the_developers">Соединиться с разработчиками</string>
<string name="to_ask_any_questions_and_to_receive_simplex_chat_updates">Чтобы задать вопросы и получать уведомления о <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<!-- Call -->
<string name="incoming_video_call">Входящий видеозвонок</string>
<string name="incoming_audio_call">Входящий аудиозвонок</string>
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> хочет связаться с вами через </string>
<string name="video_call_no_encryption">видеозвонок (не e2e зашифрованный)</string>
<string name="encrypted_video_call">e2e зашифрованный видеозвонок</string>
<string name="audio_call_no_encryption">аудиозвонок (не e2e зашифрованный)</string>
<string name="encrypted_audio_call">e2e зашифрованный аудиозвонок</string>
<string name="accept">Принять</string>
<string name="reject">Отклонить</string>
<string name="ignore">Закрыть</string>
<string name="call_already_ended">Звонок уже завершен!</string>
<string name="icon_descr_video_call">видеозвонок</string>
<string name="icon_descr_audio_call">аудиозвонок</string>
<!-- Call settings -->
<string name="settings_audio_video_calls">Аудио- и видеозвонки</string>
<string name="your_calls">Ваши звонки</string>
<string name="connect_calls_via_relay">Соединяться через сервер (relay)</string>
<string name="call_on_lock_screen">Звонки на экране блокировки:</string>
<string name="accept_call_on_lock_screen">Принимать</string>
<string name="show_call_on_lock_screen">Показывать</string>
<string name="no_call_on_lock_screen">Выключить</string>
<!-- Call Lock Screen -->
<string name="open_simplex_chat_to_accept_call">Откройте <xliff:g id="appNameFull">SimpleX Chat</xliff:g>\nчтобы принять звонок</string>
<string name="allow_accepting_calls_from_lock_screen">Вы можете разрешить принимать звонки на экране блокировки через Настройки.</string>
<string name="open_verb">Открыть</string>
<!-- Call overlay -->
<string name="status_e2e_encrypted">e2e зашифровано</string>
<string name="status_no_e2e_encryption">нет e2e шифрования</string>
<string name="status_contact_has_e2e_encryption">у контакта есть e2e шифрование</string>
<string name="status_contact_has_no_e2e_encryption">у контакта нет e2e шифрования</string>
<string name="call_connection_peer_to_peer">peer-to-peer</string>
<string name="call_connection_via_relay">через relay сервер</string>
<string name="icon_descr_hang_up">Закончить звонок</string>
<string name="icon_descr_video_off">Выключить видео</string>
<string name="icon_descr_video_on">Включить видео</string>
<string name="icon_descr_audio_off">Выключить звук</string>
<string name="icon_descr_audio_on">Включить звук</string>
<string name="icon_descr_speaker_off">Выключить спикер</string>
<string name="icon_descr_speaker_on">Включить спикер</string>
<string name="icon_descr_flip_camera">Перевернуть камеру</string>
<!-- Call items -->
<string name="icon_descr_call_pending_sent">Входящий звонок</string>
<string name="icon_descr_call_missed">Пропущенный звонок</string>
<string name="icon_descr_call_rejected">Отклоненный звонок</string>
<string name="icon_descr_call_connecting">Соединяющийся звонок</string>
<string name="icon_descr_call_progress">Текущий звонок</string>
<string name="icon_descr_call_ended">Звонок завершен</string>
<string name="answer_call">Принять звонок</string>
<!-- Message integrity -->
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> пропущенных сообщений</string>
<string name="integrity_msg_bad_hash">ошибка хэш сообщения</string>
<string name="integrity_msg_bad_id">ошибка ID сообщения</string>
<string name="integrity_msg_duplicate">повторное сообщение</string>
<string name="alert_title_skipped_messages">Пропущенные сообщения</string>
<string name="alert_text_skipped_messages_it_can_happen_when">Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения.</string>
<!-- Privacy settings -->
<string name="privacy_and_security">Конфиденциальность</string>
<string name="your_privacy">Конфиденциальность</string>
<string name="auto_accept_images">Автоприем изображений</string>
<string name="send_link_previews">Отправлять картинки ссылок</string>
<!-- Settings sections -->
<string name="settings_section_title_you">ВЫ</string>
<string name="settings_section_title_settings">НАСТРОЙКИ</string>
<string name="settings_section_title_help">ПОМОЩЬ</string>
<string name="settings_section_title_develop">ДЛЯ РАЗРАБОТЧИКОВ</string>
<string name="settings_section_title_device">УСТРОЙСТВО</string>
<string name="settings_section_title_chats">ЧАТЫ</string>
<string name="settings_experimental_features">Экспериментальные функции</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">Данные чата</string>
<string name="run_chat_section">ЗАПУСТИТЬ ЧАТ</string>
<string name="chat_is_running">Чат запущен</string>
<string name="chat_is_stopped">Чат остановлен</string>
<string name="chat_database_section">АРХИВ ЧАТА</string>
<string name="export_database">Экспорт архива чата</string>
<string name="import_database">Импорт архива чата</string>
<string name="new_database_archive">Новый архив чата</string>
<string name="old_database_archive">Старый архив чата</string>
<string name="delete_database">Удалить данные чата</string>
<string name="error_starting_chat">Ошибка при запуске чата</string>
<string name="stop_chat_question">Остановить чат?</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен.</string>
<string name="stop_chat_confirmation">Остановить</string>
<string name="error_stopping_chat">Ошибка при остановке чата</string>
<string name="error_exporting_chat_database">Ошибка при экспорте архива чата</string>
<string name="import_database_question">Импортировать архив чата?</string>
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.\nЭто действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны.</string>
<string name="import_database_confirmation">Импортировать</string>
<string name="error_deleting_database">Ошибка при удалении данных чата</string>
<string name="error_importing_database">Ошибка при импорте архива чата</string>
<string name="chat_database_imported">Архив чата импортирован</string>
<string name="restart_the_app_to_use_imported_chat_database">Перезапустите приложение, чтобы использовать импортированные данные чата.</string>
<string name="delete_chat_profile_question">Удалить профиль?</string>
<string name="delete_chat_profile_action_cannot_be_undone_warning">Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны.</string>
<string name="chat_database_deleted">Данные чата удалены</string>
<string name="restart_the_app_to_create_a_new_chat_profile">Перезапустите приложение, чтобы создать новый профиль.</string>
<string name="you_must_use_the_most_recent_version_of_database">Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе вы можете перестать получать сообщения от некоторых контактов.</string>
<string name="stop_chat_to_enable_database_actions">Остановите чат, чтобы разблокировать операции с архивом чата.</string>
<string name="restart_the_app_to_use_new_chat_database">Перезапустите приложение, чтобы использовать новый архив чата.</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Чат остановлен</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Вы можете запустить чат через Настройки приложения или перезапустив приложение.</string>
<!-- ChatArchiveView.kt -->
<string name="chat_archive_header">Архив чата</string>
<string name="chat_archive_section">АРХИВ ЧАТА</string>
<string name="save_archive">Сохранить архив</string>
<string name="delete_archive">Удалить архив</string>
<string name="archive_created_on_ts">Дата создания <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="delete_chat_archive_question">Удалить архив чата?</string>
<!-- Groups -->
<string name="group_invitation_item_description">приглашение в группу <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Вступить в группу?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Вы приглашены в группу. Вступите, чтобы соединиться с членами группы.</string>
<string name="join_group_button">Вступить</string>
<string name="joining_group">Вступление в группу</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Вы вступили в эту группу. Устанавливается соединение с пригласившим членом группы.</string>
<string name="leave_group_button">Выйти</string>
<string name="leave_group_question">Выйти из группы</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Вы перестанете получать сообщения от этой группы. История чата будет сохранена.</string>
<string name="group_left_description">[покинута]</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>
<string name="you_are_invited_to_group">Вы приглашены в группу</string>
<string name="group_invitation_tap_to_join">Нажмите, чтобы вступить</string>
<string name="you_joined_this_group">Вы вступили в эту группу</string>
<string name="you_rejected_group_invitation">Вы отклонили приглашение в группу</string>
<string name="group_invitation_expired">Приглашение в группу истекло</string>
<!-- Group event chat items -->
<string name="rcv_group_event_member_added">пригласил(а) <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_connected">соединен(а)</string>
<string name="rcv_group_event_member_left">покинул(а) группу</string>
<string name="rcv_group_event_member_deleted">удалил(а) <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_user_deleted">удалил(а) вас из группы</string>
<string name="rcv_group_event_group_deleted">удалил(а) группу</string>
<string name="snd_group_event_member_deleted">вы удалили <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">вы покинули группу</string>
</resources>
@@ -1,7 +1,520 @@
<resources>
<string name="app_name">SimpleX</string>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
<string name="thousand_abbreviation">k</string>
<!-- Connect via Link - MainActivity.kt -->
<string name="connect_via_contact_link">Connect via contact link?</string>
<string name="connect_via_invitation_link">Connect via invitation link?</string>
<string name="profile_will_be_sent_to_contact_sending_link">Your profile will be sent to the contact that you received this link from.</string>
<string name="connect_via_link_verb">Connect</string>
<!-- Server info - ChatModel.kt -->
<string name="server_connected">Server connected</string>
<string name="server_connecting">Connecting server…</string>
<string name="connected_to_server_to_receive_messages_from_contact">You are connected to the server used to receive messages from this contact.</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Trying to connect to the server used to receive messages from this contact (error: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="trying_to_connect_to_server_to_receive_messages">Trying to connect to the server used to receive messages from this contact.</string>
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">deleted</string>
<string name="sending_files_not_yet_supported">sending files is not supported yet</string>
<string name="receiving_files_not_yet_supported">receiving files is not supported yet</string>
<string name="sender_you_pronoun">you</string>
<string name="unknown_message_format">unknown message format</string>
<string name="invalid_message_format">invalid message format</string>
<!-- PendingContactConnection - ChatModel.kt -->
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
<string name="display_name_connection_established">connection established</string>
<string name="display_name_invited_to_connect">invited to connect</string>
<string name="display_name_connecting">connecting…</string>
<string name="description_you_shared_one_time_link">you shared one-time link</string>
<string name="description_via_contact_address_link">via contact address link</string>
<string name="description_via_one_time_link">via one-time link</string>
<!-- SMP Server Information - SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Error saving SMP servers</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Make sure SMP server addresses are in correct format, line separated and are not duplicated.</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="contact_already_exists">Contact already exists</string>
<string name="you_are_already_connected_to_vName_via_this_link">You are already connected to <xliff:g id="contactName" example="Alice">%1$s!</xliff:g> via this link.</string>
<string name="invalid_connection_link">Invalid connection link</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Please check that you used the correct link or ask your contact to send you another one.</string>
<string name="connection_error_auth">Connection error (AUTH)</string>
<string name="connection_error_auth_desc">Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection.</string>
<string name="cannot_delete_contact">Can\'t delete contact!</string>
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Contact <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> cannot be deleted, they are a member of the group(s) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
<string name="error_deleting_contact">Error deleting contact</string>
<string name="error_deleting_group">Error deleting group</string>
<string name="error_deleting_contact_request">Error deleting contact request</string>
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Instant notifications</string>
<string name="private_instant_notifications">Private instant notifications!</string>
<string name="private_instant_notifications_disabled">Private notifications disabled!</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">To preserve your privacy, instead of push notifications the app has a <b><xliff:g id="appName">SimpleX</xliff:g> background service</b> it uses a few percent of the battery per day.</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>It can be disabled via settings</b> notifications will still be shown while the app is running.</string>
<string name="turn_off_battery_optimization">In order to use it, please <b>disable battery optimization</b> for <xliff:g id="appName">SimpleX</xliff:g> in the next dialog. Otherwise, the notifications will be disabled.</string>
<string name="turning_off_background_service">Battery optimization is active, turning off Private notifications. You can re-enable them via settings.</string>
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title">SimpleX Chat service</string>
<string name="simplex_service_notification_text">Waiting for incoming messages</string>
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> service</string>
<string name="simplex_service_notification_text">Receiving messages</string>
<!-- local authentication notice - SimpleXAPI.kt -->
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled.</string>
<string name="la_notice_turn_on">Turn on</string>
<!-- LocalAuthentication.kt -->
<string name="auth_simplex_lock_turned_on">SimpleX Lock turned on</string>
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">You will be required to authenticate when you start or resume the app after 30 seconds in background.</string>
<string name="auth_unlock">Unlock</string>
<string name="auth_log_in_using_credential">Log in using your credential</string>
<string name="auth_enable_simplex_lock">Enable SimpleX Lock</string>
<string name="auth_disable_simplex_lock">Disable SimpleX Lock</string>
<string name="auth_confirm_credential">Confirm your credential</string>
<string name="auth_error">Authentication error</string>
<string name="auth_error_w_desc">Authentication error: <xliff:g id="desc">%1$s</xliff:g></string>
<string name="auth_failed">Authentication failed</string>
<string name="auth_unavailable">Authentication unavailable</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</string>
<string name="auth_device_authentication_is_disabled_turning_off">Device authentication is disabled. Turning off SimpleX Lock.</string>
<string name="auth_retry">Retry</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Reply</string>
<string name="share_verb">Share</string>
<string name="copy_verb">Copy</string>
<string name="save_verb">Save</string>
<string name="edit_verb">Edit</string>
<string name="delete_verb">Delete</string>
<string name="delete_message__question">Delete message?</string>
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
<string name="for_me_only">For me only</string>
<string name="for_everybody">For everyone</string>
<!-- CIMetaView.kt -->
<string name="icon_descr_edited">edited</string>
<string name="icon_descr_sent_msg_status_sent">sent</string>
<string name="icon_descr_sent_msg_status_unauthorized_send">unauthorized send</string>
<string name="icon_descr_sent_msg_status_send_failed">send failed</string>
<string name="icon_descr_received_msg_status_unread">unread</string>
<!-- ChatListView.kt -->
<string name="personal_welcome">Welcome <xliff:g>%1$s</xliff:g>!</string>
<string name="welcome">Welcome!</string>
<string name="this_text_is_available_in_settings">This text is available in settings</string>
<string name="your_chats">Your chats</string>
<string name="contact_connection_pending">connecting…</string>
<string name="group_connection_pending">connecting…</string>
<!-- ComposeView.kt, helpers -->
<string name="attach">Attach</string>
<string name="icon_descr_context">Context icon</string>
<string name="icon_descr_cancel_image_preview">Cancel image preview</string>
<string name="icon_descr_cancel_file_preview">Cancel file preview</string>
<!-- Images - CIImageView.kt -->
<string name="image_descr">Image</string>
<string name="icon_descr_waiting_for_image">Waiting for image</string>
<string name="icon_descr_image_snd_complete">Image sent</string>
<string name="waiting_for_image">Waiting for image</string>
<string name="image_will_be_received_when_contact_is_online">Image will be received when your contact is online, please wait or check later!</string>
<string name="image_saved">Image saved to Gallery</string>
<!-- Files - CIFileView.kt -->
<string name="icon_descr_file">File</string>
<string name="large_file">Large file!</string>
<string name="contact_sent_large_file">Your contact sent a file that is larger than currently supported maximum size (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
<string name="maximum_supported_file_size">Currently maximum supported file size is <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
<string name="waiting_for_file">Waiting for file</string>
<string name="file_will_be_received_when_contact_is_online">File will be received when your contact is online, please wait or check later!</string>
<string name="file_saved">File saved</string>
<string name="file_not_found">File not found</string>
<string name="error_saving_file">Error saving file</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_chat_question">Delete chat?</string>
<string name="delete_chat_all_messages_deleted_cannot_undo_warning">Chat and all messages will be deleted - this cannot be undone!</string>
<string name="button_delete_contact">Delete contact</string>
<string name="icon_descr_server_status_connected">Connected</string>
<string name="icon_descr_server_status_disconnected">Disconnected</string>
<string name="icon_descr_server_status_error">Error</string>
<string name="icon_descr_server_status_pending">Pending</string>
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Send Message</string>
<!-- General Actions / Responses -->
<string name="back">Back</string>
<string name="cancel_verb">Cancel</string>
<string name="confirm_verb">Confirm</string>
<string name="ok">Ok</string>
<string name="no_details">no details</string>
<string name="add_contact">Add contact</string>
<!-- NewChatSheet -->
<string name="add_contact_to_start_new_chat">Add contact to start a new chat:</string>
<string name="create_one_time_link">Create link / QR code</string>
<string name="paste_received_link">Connect via received link</string>
<string name="scan_QR_code">Scan QR code</string>
<string name="to_share_with_your_contact">(to share with your contact)</string>
<string name="in_person_or_in_video_call__bracketed">(in person or in video call)</string>
<string name="paste_received_link_from_clipboard">(paste link from clipboard)</string>
<!-- GetImageView -->
<string name="toast_permission_denied">Permission Denied!</string>
<string name="use_camera_button">Use Camera</string>
<string name="from_gallery_button">From Gallery</string>
<string name="choose_file">Choose file</string>
<!-- help - ChatHelpView.kt -->
<string name="thank_you_for_installing_simplex">Thank you for installing <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
<string name="you_can_connect_to_simplex_chat_founder">You can <font color="#0088ff">connect to <xliff:g id="appNameFull">SimpleX Chat</xliff:g> developers to ask any questions and to receive updates</font>.</string>
<string name="to_start_a_new_chat_help_header">To start a new chat</string>
<string name="chat_help_tap_button">Tap button</string>
<string name="above_then_preposition_continuation">above, then:</string>
<string name="add_new_contact_to_create_one_time_QR_code"><b>Add new contact</b>: to create your one-time QR Code for your contact.</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Scan QR code</b>: to connect to your contact who shows QR code to you.</string>
<string name="to_connect_via_link_title">To connect via link</string>
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">If you received <xliff:g id="appName">SimpleX Chat</xliff:g> invitation link, you can open it in your browser:</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 desktop: scan displayed QR code from the app, via <b>Scan QR code</b>.</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 mobile: tap <b>Open in mobile app</b>, then tap <b>Connect</b> in the app.</string>
<!-- Contact Request Alert Dialogue - ChatListNavLinkView.kt -->
<string name="accept_connection_request__question">Accept connection request?</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">If you choose to reject sender will NOT be notified.</string>
<string name="accept_contact_button">Accept</string>
<string name="reject_contact_button">Reject</string>
<!-- Clear Chat - ChatListNavLinkView.kt -->
<string name="clear_chat_question">Clear chat?</string>
<string name="clear_chat_warning">All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you.</string>
<string name="clear_verb">Clear</string>
<string name="clear_chat_button">Clear chat</string>
<string name="mark_read">Mark read</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">You invited your contact</string>
<string name="you_accepted_connection">You accepted connection</string>
<string name="delete_pending_connection__question">Delete pending connection?</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">The contact you shared this link with will NOT be able to connect!</string>
<string name="connection_you_accepted_will_be_cancelled">The connection you accepted will be cancelled!</string>
<!-- Contact Pending Alert Dialogue - ChatListNavLinkView.kt -->
<string name="alert_title_contact_connection_pending">Contact is not connected yet!</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link).</string>
<!-- Contact Request Information - ContactRequestView.kt -->
<string name="contact_wants_to_connect_with_you">wants to connect to you!</string>
<!-- Image Placeholder - ChatInfoImage.kt -->
<string name="icon_descr_profile_image_placeholder">profile image placeholder</string>
<string name="image_descr_profile_image">profile image</string>
<!-- Content Descriptions -->
<string name="icon_descr_close_button">Close button</string>
<string name="image_descr_link_preview">link preview image</string>
<string name="icon_descr_cancel_link_preview">cancel link preview</string>
<string name="icon_descr_settings">Settings</string>
<string name="image_descr_qr_code">QR Code</string>
<string name="icon_descr_address"><xliff:g id="appName">SimpleX</xliff:g> Address</string>
<string name="icon_descr_help">help</string>
<string name="icon_descr_simplex_team"><xliff:g id="appName">SimpleX</xliff:g> Team</string>
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> Logo</string>
<string name="icon_descr_email">Email</string>
<!-- Add Contact - AddContactView.kt -->
<string name="invalid_QR_code">Invalid QR code</string>
<string name="this_QR_code_is_not_a_link">This QR code is not a link!</string>
<string name="invalid_contact_link">Invalid link!</string>
<string name="this_link_is_not_a_valid_connection_link">This link is not a valid connection link!</string>
<string name="connection_request_sent">Connection request sent!</string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">You will be connected when your connection request is accepted, please wait or check later!</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">You will be connected when your contact\'s device is online, please wait or check later!</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Show QR code for your contact\nto scan from the app</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">If you cannot meet in person, you can <b>show QR code in the video call</b>, or you can share the invitation link via any other channel.</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Your chat profile will be sent\nto your contact</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">If you cannot meet in person, you can <b>scan QR code in the video call</b>, or your contact can share an invitation link.</string>
<string name="share_invitation_link">Share invitation link</string>
<string name="paste_connection_link_below_to_connect">Paste the link you received into the box below to connect with your contact.</string>
<!-- PasteToConnect.kt -->
<string name="connect_via_link">Connect via link</string>
<string name="this_string_is_not_a_connection_link">This string is not a connection link!</string>
<string name="you_can_also_connect_by_clicking_the_link">You can also connect by clicking the link. If it opens in the browser, click <b>Open in mobile app</b> button.</string>
<!-- settings - SettingsView.kt -->
<string name="your_settings">Your settings</string>
<string name="your_simplex_contact_address">Your <xliff:g id="appName">SimpleX</xliff:g> contact address</string>
<string name="database_export_and_import">Database export &amp; import</string>
<string name="about_simplex_chat">About <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="how_to_use_simplex_chat">How to use it</string>
<string name="markdown_help">Markdown help</string>
<string name="markdown_in_messages">Markdown in messages</string>
<string name="chat_with_the_founder">Connect to the developers</string>
<string name="send_us_an_email">Send us email</string>
<string name="private_notifications">Private notifications</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Chat console</string>
<string name="smp_servers">SMP servers</string>
<string name="install_simplex_chat_for_terminal">Install <font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</font></string>
<string name="use_simplex_chat_servers__question">Use <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers?</string>
<string name="saved_SMP_servers_will_br_removed">Saved SMP servers will be removed.</string>
<string name="your_SMP_servers">Your SMP servers</string>
<string name="configure_SMP_servers">Configure SMP servers</string>
<string name="using_simplex_chat_servers">Using <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers.</string>
<string name="enter_one_SMP_server_per_line">Enter one SMP server per line:</string>
<string name="how_to">How to</string>
<string name="save_servers_button">Save</string>
<!-- Address Items - UserAddressView.kt -->
<string name="create_address">Create address</string>
<string name="delete_address__question">Delete address?</string>
<string name="all_your_contacts_will_remain_connected">All your contacts will remain connected.</string>
<string name="your_chat_address">Your chat address</string>
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">You can share your address as a link or as a QR code - anybody will be able to connect to you.</string>
<string name="if_you_delete_address_you_wont_lose_contacts">If you later delete it - you won\'t lose your contacts.</string>
<string name="share_link">Share link</string>
<string name="delete_address">Delete address</string>
<!-- User profile details - UserProfileView.kt -->
<string name="display_name__field">Display name:</string>
<string name="full_name__field">"Full name:</string>
<string name="your_chat_profile">Your chat profile</string>
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts.\n\n<xliff:g id="appName">SimpleX</xliff:g> servers cannot see your profile.</string>
<string name="edit_image">Edit image</string>
<string name="delete_image">Delete image</string>
<string name="save_and_notify_contacts">Save (and notify contacts)</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">You control your chat!</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">The messaging and application platform protecting your privacy and security.</string>
<string name="we_do_not_store_contacts_or_messages_on_servers">We don\'t store any of your contacts or messages (once delivered) on the servers.</string>
<string name="create_profile">Create profile</string>
<string name="your_profile_is_stored_on_your_device">Your profile, contacts and delivered messages are stored on your device.</string>
<string name="profile_is_only_shared_with_your_contacts">The profile is only shared with your contacts.</string>
<string name="display_name_cannot_contain_whitespace">Display name cannot contain whitespace.</string>
<string name="display_name">Display Name</string>
<string name="full_name_optional__prompt">Full Name (optional)</string>
<string name="create_profile_button">Create</string>
<string name="about_simplex">About SimpleX</string>
<!-- markdown demo - MarkdownHelpView.kt -->
<string name="how_to_use_markdown">How to use markdown</string>
<string name="you_can_use_markdown_to_format_messages__prompt">You can use markdown to format messages:</string>
<string name="bold">bold</string>
<string name="italic">italic</string>
<string name="strikethrough">strike</string>
<string name="a_plus_b">a + b</string>
<string name="colored">colored</string>
<string name="secret">secret</string>
<!-- CICallStatus - in chat items -->
<string name="callstatus_calling">calling…</string>
<string name="callstatus_missed">missed call</string>
<string name="callstatus_rejected">rejected call</string>
<string name="callstatus_accepted">accepted call</string>
<string name="callstatus_connecting">connecting call…</string>
<string name="callstatus_in_progress">call in progress</string>
<string name="callstatus_ended">call ended <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstatus_error">call error</string>
<!-- CallState -->
<string name="callstate_starting">starting…</string>
<string name="callstate_waiting_for_answer">waiting for answer…</string>
<string name="callstate_waiting_for_confirmation">waiting for confirmation…</string>
<string name="callstate_received_answer">received answer…</string>
<string name="callstate_received_confirmation">received confirmation…</string>
<string name="callstate_connecting">connecting…</string>
<string name="callstate_connected">connected</string>
<string name="callstate_ended">ended</string>
<!-- SimpleXInfo -->
<string name="next_generation_of_private_messaging">The next generation of private messaging</string>
<string name="privacy_redefined">Privacy redefined</string>
<string name="first_platform_without_user_ids">The 1st platform without any user identifiers private by design.</string>
<string name="immune_to_spam_and_abuse">Immune to spam and abuse</string>
<string name="people_can_connect_only_via_links_you_share">People can connect to you only via the links you share.</string>
<string name="decentralized">Decentralized</string>
<string name="opensource_protocol_and_code_anybody_can_run_servers">Open-source protocol and code anybody can run the servers.</string>
<string name="create_your_profile">Create your profile</string>
<string name="make_private_connection">Make a private connection</string>
<string name="how_it_works">How it works</string>
<!-- How SimpleX Works -->
<string name="how_simplex_works">How <xliff:g id="appName">SimpleX</xliff:g> works</string>
<string name="many_people_asked_how_can_it_deliver">Many people asked: <i>if <xliff:g id="appName">SimpleX</xliff:g> has no user identifiers, how can it deliver messages?</i></string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">To protect privacy, instead of user IDs used by all other platforms, <xliff:g id="appName">SimpleX</xliff:g> has identifiers for message queues, separate for each of your contacts.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">You control through which server(s) <b>to receive</b> the messages, your contacts the servers you use to message them.</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Only client devices store user profiles, contacts, groups, and messages sent with <b>2-layer end-to-end encryption</b>.</string>
<string name="read_more_in_github">Read more in our GitHub repository.</string>
<string name="read_more_in_github_with_link">Read more in our <font color="#0088ff">GitHub repository</font>.</string>
<!-- MakeConnection -->
<string name="to_make_your_first_private_connection_choose">To make your first private connection, choose <b>one of the following</b>:</string>
<string name="create_1_time_link_qr_code">Create 1-time link / QR code</string>
<string name="it_s_secure_to_share__only_one_contact_can_use_it">It\'s secure to share - only one contact can use it.</string>
<string name="paste_the_link_you_received">Paste the link you received</string>
<string name="or_open_the_link_in_the_browser_and_tap_open_in_mobile">Or open the link in the browser and tap <b>Open in mobile</b>.</string>
<string name="scan_contact_s_qr_code">Scan contact\'s QR code</string>
<string name="in_person_or_via_a_video_call__the_most_secure_way_to_connect">In person or via a video call the most secure way to connect.</string>
<string name="or">or</string>
<string name="connect_with_the_developers">Connect with the developers</string>
<string name="to_ask_any_questions_and_to_receive_simplex_chat_updates">To ask any questions and to receive <xliff:g id="appNameFull">SimpleX Chat</xliff:g> updates.</string>
<!-- Call -->
<string name="incoming_video_call">Incoming video call</string>
<string name="incoming_audio_call">Incoming audio call</string>
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> wants to connect with you via</string>
<string name="video_call_no_encryption">video call (not e2e encrypted)</string>
<string name="encrypted_video_call">e2e encrypted video call</string>
<string name="audio_call_no_encryption">audio call (not e2e encrypted)</string>
<string name="encrypted_audio_call">e2e encrypted audio call</string>
<string name="accept">Accept</string>
<string name="reject">Reject</string>
<string name="ignore">Ignore</string>
<string name="call_already_ended">Call already ended!</string>
<string name="icon_descr_video_call">video call</string>
<string name="icon_descr_audio_call">audio call</string>
<!-- Call settings -->
<string name="settings_audio_video_calls">Audio &amp; video calls</string>
<string name="your_calls">Your calls</string>
<string name="connect_calls_via_relay">Connect via relay</string>
<string name="call_on_lock_screen">Calls on lock screen:</string>
<string name="accept_call_on_lock_screen">Accept</string>
<string name="show_call_on_lock_screen">Show</string>
<string name="no_call_on_lock_screen">Disable</string>
<!-- Call Lock Screen -->
<string name="open_simplex_chat_to_accept_call">Open <xliff:g id="appNameFull">SimpleX Chat</xliff:g> to accept call</string>
<string name="allow_accepting_calls_from_lock_screen">Enable calls from lock screen via Settings.</string>
<string name="open_verb">Open</string>
<!-- Call overlay -->
<string name="status_e2e_encrypted">e2e encrypted</string>
<string name="status_no_e2e_encryption">no e2e encryption</string>
<string name="status_contact_has_e2e_encryption">contact has e2e encryption</string>
<string name="status_contact_has_no_e2e_encryption">contact has no e2e encryption</string>
<string name="call_connection_peer_to_peer">peer-to-peer</string>
<string name="call_connection_via_relay">via relay</string>
<string name="icon_descr_hang_up">Hang up</string>
<string name="icon_descr_video_off">Video off</string>
<string name="icon_descr_video_on">Video on</string>
<string name="icon_descr_audio_off">Audio off</string>
<string name="icon_descr_audio_on">Audio on</string>
<string name="icon_descr_speaker_off">Speaker off</string>
<string name="icon_descr_speaker_on">Speaker on</string>
<string name="icon_descr_flip_camera">Flip camera</string>
<!-- Call items -->
<string name="icon_descr_call_pending_sent">Pending call</string>
<string name="icon_descr_call_missed">Missed call</string>
<string name="icon_descr_call_rejected">Rejected call</string>
<string name="icon_descr_call_connecting">Connecting call</string>
<string name="icon_descr_call_progress">Call in progress</string>
<string name="icon_descr_call_ended">Call ended</string>
<string name="answer_call">Answer call</string>
<!-- Message integrity -->
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> skipped message(s)</string>
<string name="integrity_msg_bad_hash">bad message hash</string>
<string name="integrity_msg_bad_id">bad message ID</string>
<string name="integrity_msg_duplicate">duplicate message</string>
<string name="alert_title_skipped_messages">Skipped messages</string>
<string name="alert_text_skipped_messages_it_can_happen_when">It can happen when:\n1. The messages expire on the server if they were not received for 30 days,\n2. The server you use to receive the messages from this contact was updated and restarted.\n3. The connection is compromised.\nPlease connect to the developers via Settings to receive the updates about the servers.\nWe will be adding server redundancy to prevent lost messages.</string>
<!-- Privacy settings -->
<string name="privacy_and_security">Privacy &amp; security</string>
<string name="your_privacy">Your privacy</string>
<string name="auto_accept_images">Auto-accept images</string>
<string name="send_link_previews">Send link previews</string>
<!-- Settings sections -->
<string name="settings_section_title_you">YOU</string>
<string name="settings_section_title_settings">SETTINGS</string>
<string name="settings_section_title_help">HELP</string>
<string name="settings_section_title_develop">DEVELOP</string>
<string name="settings_section_title_device">DEVICE</string>
<string name="settings_section_title_chats">CHATS</string>
<string name="settings_experimental_features">Experimental features</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">Your chat database</string>
<string name="run_chat_section">RUN CHAT</string>
<string name="chat_is_running">Chat is running</string>
<string name="chat_is_stopped">Chat is stopped</string>
<string name="chat_database_section">CHAT DATABASE</string>
<string name="export_database">Export database</string>
<string name="import_database">Import database</string>
<string name="new_database_archive">New database archive</string>
<string name="old_database_archive">Old database archive</string>
<string name="delete_database">Delete database</string>
<string name="error_starting_chat">Error starting chat</string>
<string name="stop_chat_question">Stop chat?</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped.</string>
<string name="stop_chat_confirmation">Stop</string>
<string name="error_stopping_chat">Error stopping chat</string>
<string name="error_exporting_chat_database">Error exporting chat database</string>
<string name="import_database_question">Import chat database?</string>
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Your current chat database will be DELETED and REPLACED with the imported one.\nThis action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.</string>
<string name="import_database_confirmation">Import</string>
<string name="error_deleting_database">Error deleting chat database</string>
<string name="error_importing_database">Error importing chat database</string>
<string name="chat_database_imported">Chat database imported</string>
<string name="restart_the_app_to_use_imported_chat_database">Restart the app to use imported chat database.</string>
<string name="delete_chat_profile_question">Delete chat profile?</string>
<string name="delete_chat_profile_action_cannot_be_undone_warning">This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.</string>
<string name="chat_database_deleted">Chat database deleted</string>
<string name="restart_the_app_to_create_a_new_chat_profile">Restart the app to create a new chat profile.</string>
<string name="you_must_use_the_most_recent_version_of_database">You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts.</string>
<string name="stop_chat_to_enable_database_actions">Stop chat to enable database actions.</string>
<string name="restart_the_app_to_use_new_chat_database">Restart the app to use new chat database.</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Chat is stopped</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">You can start chat via app Settings / Database or by restarting the app.</string>
<!-- ChatArchiveView.kt -->
<string name="chat_archive_header">Chat archive</string>
<string name="chat_archive_section">CHAT ARCHIVE</string>
<string name="save_archive">Save archive</string>
<string name="delete_archive">Delete archive</string>
<string name="archive_created_on_ts">Created on <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="delete_chat_archive_question">Delete chat archive?</string>
<!-- Groups -->
<string name="group_invitation_item_description">invitation to group <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Join group?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">You are invited to group. Join to connect with group members.</string>
<string name="join_group_button">Join</string>
<string name="joining_group">Joining group</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">You joined this group. Connecting to inviting group member.</string>
<string name="leave_group_button">Leave</string>
<string name="leave_group_question">Leave group?</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">You will stop receiving messages from this group. Chat history will be preserved.</string>
<string name="group_left_description">[left]</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">You sent group invitation</string>
<string name="you_are_invited_to_group">You are invited to group</string>
<string name="group_invitation_tap_to_join">Tap to join</string>
<string name="you_joined_this_group">You joined this group</string>
<string name="you_rejected_group_invitation">You rejected group invitation</string>
<string name="group_invitation_expired">Group invitation expired</string>
<!-- Group event chat items -->
<string name="rcv_group_event_member_added">invited <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_connected">connected</string>
<string name="rcv_group_event_member_left">left</string>
<string name="rcv_group_event_member_deleted">removed <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_user_deleted">removed you</string>
<string name="rcv_group_event_group_deleted">deleted group</string>
<string name="snd_group_event_member_deleted">you removed <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">you left</string>
</resources>
+3 -3
View File
@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.2'
classpath 'com.android.tools.build:gradle:7.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
@@ -16,8 +16,8 @@ buildscript {
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.1.2' apply false
id 'com.android.library' version '7.1.2' apply false
id 'com.android.application' version '7.2.0' apply false
id 'com.android.library' version '7.2.0' apply false
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
}
+1 -1
View File
@@ -1,6 +1,6 @@
#Mon Feb 14 14:23:51 GMT 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

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