Compare commits

...

259 Commits

Author SHA1 Message Date
Stanislav Dmitrenko 74421a5f52 android: Hide role changing (#1204) 2022-10-12 22:52:56 +01:00
JRoberts 8c3c639d7f android: missing translations (#1201) 2022-10-12 20:29:06 +04:00
Evgeny Poberezkin 91460932fe website: fix contact page 2022-10-12 17:16:10 +01:00
Evgeny Poberezkin f70a203f0e website: fix connection page for firefox, fix contact page (#1202) 2022-10-12 17:12:01 +01:00
JRoberts 3113aab8a8 4.1.0 2022-10-12 19:04:37 +04:00
JRoberts d0b5f16be6 android: version 4.1 (61) 2022-10-12 18:58:50 +04:00
JRoberts 41a4e13ff6 ios: version 4.1 (83) 2022-10-12 18:51:45 +04:00
JRoberts 3ac7d5977b android: german translations for v4.1 (#1200) 2022-10-12 18:47:33 +04:00
mlanp 0f0cb234bd ios: german translations for v4.1 (#1199)
* modified:   apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff

* Localizable.strings

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-10-12 18:07:33 +04:00
Stanislav Dmitrenko c14c289ba7 android: Image gallery (#1198)
* android: Image gallery

* Do not scroll to active item

* Infinity pager

* Fixed small issue with the first page

* Limited image height

* Revert "Limited image height"

This reverts commit d5733da6a3.

* Scroll at top position after click on quote

* Better scrolling
2022-10-12 14:59:59 +01:00
Stanislav Dmitrenko c20c7085b0 android: Scroll to quoted message (#1195)
* android: Scroll to quoted message

* Click only on quote makes the list scrolling

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-12 13:39:17 +01:00
Stanislav Dmitrenko 05122fc476 android: Update chats when enter the app from background (#1196)
* android: Update chats when enter the app from background

* Nullify current chat id if the chat was deleted in background
2022-10-11 11:35:47 +01:00
Stanislav Dmitrenko ee5997cdf7 android: Multiple images can be sent at the same time in different messages (#1193)
* android: Multiple images can be sent at the same time in different messages

* Padding

* Removing an ability to delete selected image, filtered out videos

* Optimization
2022-10-11 09:15:47 +01:00
JRoberts 563687f9e4 ios: version 4.1 (82) 2022-10-10 17:46:14 +04:00
JRoberts 3d3503498a ios: fix muted notification (#1192) 2022-10-10 17:35:28 +04:00
Stanislav Dmitrenko 4b905a80e6 android: New chat sheet animation (#1174)
* Color blending on buttons

* android: New chat sheet animation

* Faster animation

* Another try

* Experiment

* Experiment

* Animation of the year

* Faster animation

* Faster animation

* Faster animation

* Useless

* Top bar

* combine animations

* Experiment

* Fastest animation ever

* Animation per row

* Removed logs

* Combined animations

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-10 14:09:07 +01:00
JRoberts fee9b3ae84 ios: version 4.1 (81) 2022-10-10 13:54:19 +04:00
Evgeny Poberezkin 4c8bc19182 ios: send multiple images (#1188)
* ios: send multiple images

* multi-select works (TODO race conditions)

* send multiple images, progress indicator in compose view

* scroll between fullscreen images, scroll to quoted item

* add swipe animation

* fix model state when sending the image

* fix sending multiple images

* use MainActor

* improve scrolling

* faster scroll

* improve scroll animation

* fix model updates
2022-10-10 13:40:30 +04:00
JRoberts f9be6e6434 update simplexmq (lock on async commands retries) (#1191) 2022-10-10 13:40:06 +04:00
Stanislav Dmitrenko cf001054c2 core: Fix member role that wasn't applied to a member object (#1190) 2022-10-08 19:49:42 +01:00
Stanislav Dmitrenko 54705c07bd android: Fixed bug with modal views (#1189) 2022-10-07 21:40:37 +01:00
Stanislav Dmitrenko ca423024c5 android: Change member role (#1186)
* android: Change member role

* Don't do anything when role is unchanged
2022-10-07 21:05:48 +01:00
JRoberts af17daafaa ios: version 4.1 (80) 2022-10-07 19:39:02 +04:00
JRoberts f0b551cffd ios: comment member roles ui (#1185) 2022-10-07 19:07:08 +04:00
JRoberts 56727a8672 ios: version 4.1 (79) 2022-10-07 18:08:58 +04:00
JRoberts 5fd522c79c mobile: change ttl setting text (#1184) 2022-10-07 17:20:09 +04:00
JRoberts b66eb5b67f mobile: disable setting chat item TTL if chat db changed (#1183) 2022-10-07 15:01:17 +04:00
JRoberts 88645cb003 core: check chat store hasn't changed in APISetChatItemTTL (#1182) 2022-10-07 13:53:05 +04:00
Evgeny Poberezkin fff0659b1e ios: fix picker heights (#1181) 2022-10-07 13:31:31 +04:00
Stanislav Dmitrenko 04719ff8df android: Automatic message deletion (#1171)
* android: Automatic message deletion

* Disable changing TTL when this operation is already happening

* corrections

* update translations

* afterSetCiTTL

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-10-07 13:29:13 +04:00
JRoberts 83c1340830 ios: update chats after setting chat item TTL (#1179) 2022-10-07 10:55:54 +04:00
JRoberts 6d06dda645 core: allow to call APIGetChats with stopped chat (#1180) 2022-10-07 10:55:47 +04:00
Evgeny Poberezkin 868f0abaaf ios: connection information/alias sheet (#1178)
* ios: connection information/alias sheet

* add swipe button

* add localizations

* fix padding

* fix intermittent bug with multiple edits
2022-10-06 15:02:58 +01:00
JRoberts 3649321b67 core: catch errors during chat item expiration (#1177) 2022-10-06 14:00:02 +04:00
JRoberts 135bdf3842 core: optimize bulk chat item deletion 2 (#1172) 2022-10-05 19:54:28 +04:00
Evgeny Poberezkin 22edd92079 android: change scrim color for new chat button in dark mode 2022-10-05 08:50:24 +01:00
Stanislav Dmitrenko 059f1e1e79 android: New chat dialog (#1173)
* android: New chat dialog

* Click indication was disabled

* Color experiments

* Padding

* Padding

* Second variant

* Third variant

* Another style

* Another part

* update buttons

* Another try

* Elevation

* Empty view when no chats available

* update buttons

* do not hide "you have no chats" with new chat dialog

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-05 08:37:04 +01:00
Stanislav Dmitrenko b263d7e547 android: Connection info (#1169)
* android: Connection info

* UI changes

* Icon in text field

* Alias text field

* Revert "Alias text field"

This reverts commit 2ac694db4d.

* Padding

* Layout changes

* Bigger delay

* UI changes

* UI changes
2022-10-04 17:25:53 +01:00
Evgeny Poberezkin 7a54351f15 core: update simplexmq (connection-level locks) (#1170)
* core: update simplexmq (connection-level locks)

* update simplexmq

* update simplexmq

* update simplexmq

* only run file tests

* update simplexmq

* enable all tests

* update simplexmq
2022-10-04 17:19:00 +01:00
Evgeny Poberezkin f9c691cab1 ios: change member role (#1164)
* ios: change member role

* chat item types, error alerts

* update alert

* translations

* update messages

* 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>

* update translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-10-04 09:53:43 +01:00
JRoberts cd6cad9a96 core: optimize bulk chat item deletion (#1168)
* core: optimize bulk chat item deletion

* test file deletion

* refactor

* refactor
2022-10-03 22:33:36 +01:00
Stanislav Dmitrenko e8ad216b26 Dividers and padding (#1166) 2022-10-03 15:16:04 +01:00
JRoberts 3808b7415b core: allow to set chat item ttl when chat is stopped (#1165) 2022-10-03 17:44:56 +04:00
JRoberts e1454b1445 ios: refactor delete after x seconds option 2022-10-03 16:47:45 +04:00
JRoberts 6e9e6057af ios: automatic message deletion (#1160) 2022-10-03 16:42:43 +04:00
JRoberts 575706c7c7 core: platform independent json encoding for MsgErrorType (#1163) 2022-10-03 12:55:59 +04:00
Evgeny Poberezkin 58f6b168e6 core: protocol/commands to change member role (#1159)
* core: protocol/commands to change member role

* change member roles

* add test

* correction

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

* add member profile to group member role events

* resend invitation when invited member role changes

* test role change with invitation, fix

* add delays to tests

* add test delay

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-10-03 09:00:47 +01:00
Stanislav Dmitrenko 841afa1e80 android: UI overhaul start (#1146)
* android: UI overhaul start

* Moved title upper in a view and modified UI of some elements

* AppBar refactoring

* Color for settings modal in a light theme

* Returned big title

* Animation between screens

* Title placement

* Animation between screens

* Properly removing a value

* Properly removing a value

* make entry/exit animation the same

* fix first screen

* update first screen logo

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-02 16:05:50 +01:00
Evgeny Poberezkin 9c5acd609c core: author role, allow member to add new members on the receiving end (#1149)
* core: author role, allow member to add new members on the receiving end

* remove unused name

* remove author role from parser
2022-10-01 20:30:47 +01:00
Evgeny Poberezkin 23212def51 update simplexmq v3.3.0 2022-10-01 15:19:41 +01:00
Evgeny Poberezkin 06e46b0bea mobile: disable notifications on group events, make member role default (#1158)
* ios: disable notifications on group events, make member role default

* same for android

* change mute criteria

* update
2022-10-01 14:46:48 +01:00
JRoberts a3bd51a5fa core: speed up tests (#1157) 2022-10-01 14:54:02 +04:00
Evgeny Poberezkin 5c49e8f7ea core: add localAlias to Contact when it is created 2022-10-01 11:38:54 +01:00
JRoberts ef28215284 core: fully delete group chat items instead of overwriting content (#1154) 2022-10-01 14:31:21 +04:00
JRoberts 7f70fe4d64 core: fix /image command error messages (closes #1153) (#1156) 2022-10-01 14:29:02 +04:00
Evgeny Poberezkin 05385ce997 ios: set alias on connection link, see link again, remove QR code on connection (#1155)
* ios: set alias on connection link, see link again, remove QR code on connection

* update UX for connection alias

* change layout

* layout

* return pencil

* incognito

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

* color

* style

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

* fix

* pencil color

* update

* remove UB sanitizer

* exit edit mode

* fix flicker

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-10-01 10:57:18 +01:00
Evgeny Poberezkin f0f7226fa5 ios: update library 2022-09-30 13:48:04 +01:00
JRoberts 428d3cdba5 core: delete connections asynchronously (#1151) 2022-09-30 16:18:43 +04:00
Stanislav Dmitrenko dd5e99ea42 android: Allowed to route audio to earpiece on Android S+ (#1148)
* android: Allowed to route audio to earpiece on Android S+

* Clear communication device after call ends
2022-09-30 09:00:44 +01:00
Evgeny Poberezkin 628f119151 core: lints (#1144) 2022-09-29 16:26:43 +01:00
Stanislav Dmitrenko 0e411b0eac android: Prevent shutting down a microphone when screen is off (#1145) 2022-09-28 20:46:57 +01:00
Stanislav Dmitrenko 7fd4b7edae android: Sharing text and files from other apps and from Simplex itself to Simplex (#1140)
* android: Sharing text and files from other apps and from Simplex itself to Simplex

* Different look and feel of share list

* Strings
2022-09-28 18:03:31 +01:00
JRoberts 9cb2542079 core: scheduled deletion (#1075)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-28 20:47:06 +04:00
Evgeny Poberezkin 07d2c9ff49 update readme 2022-09-28 11:25:35 +01:00
Evgeny Poberezkin 6730a687e2 readme: release announcement (#1142)
* readme: release announcement

* roadmap

* correction

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

* update

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-09-28 09:23:28 +01:00
Stanislav Dmitrenko 86c782d08b android: Playing call sound in different stream type (#1138)
* android: Playing call sound in different stream type

* Changes
2022-09-27 22:09:14 +01:00
Evgeny Poberezkin fe6cd91a61 core: connection alias, store invitation link with connection (#1137)
* core: connection alias, store invitation link with connection

* update contact with connection alias, test

* use type alias

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

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-09-27 20:45:46 +01:00
JRoberts bf46e0dc57 ios: don't show clear swipe action for empty chats (#1139) 2022-09-27 20:24:25 +04:00
Evgeny Poberezkin 937492c204 blog: v4 (#1078) 2022-09-27 14:46:37 +04:00
JRoberts c0878769be 4.0.1 2022-09-27 12:36:29 +04:00
JRoberts e2c332c230 android: version 4.0.1 (60) 2022-09-27 10:02:41 +04:00
mlanp 629c5d7434 ios: update german translation (#1136)
* iOS German translations corrections

* import translations

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-26 22:24:16 +01:00
Evgeny Poberezkin 090e54c8d8 ios: fix translations on user profile page (#1134) 2022-09-26 20:32:24 +01:00
Stanislav Dmitrenko 1a2efc3b4f android: RTL support when device language is NOT RTL (#1133)
* android: RTL support when device language is NOT RTL

* Follow up

* Refactoring

* Removed unused changes

* Comment
2022-09-26 20:31:38 +01:00
Evgeny Poberezkin 4e57f7c488 ci: fix windows build (#1132)
* ci: fix windows build

* pass bin_path via temp file
2022-09-26 19:33:54 +01:00
Evgeny Poberezkin 31d8f73eac core: disable connection handshake notifications for groups with disabled notifications (#1131) 2022-09-26 18:09:45 +01:00
Stanislav Dmitrenko 409982ad71 android: Two more notifications when contact connected or connection request received (#1129)
* Two more notifications when contact connected or connection request received

* Large image in notification

* Do not show contact's icon when it't not permitted

* Sound and vibrate for notification

* No large icon in other cases

* Small change
2022-09-26 17:28:51 +01:00
Evgeny Poberezkin 6c7e81777e ci: windows build (#1128)
* ci: windows build

* enable windows build

* fix intermittently failing test
2022-09-26 17:02:06 +01:00
Stanislav Dmitrenko 4934045f4e Closing qr scanner/connect via link screens after successfull request (#1130) 2022-09-26 17:01:47 +01:00
Evgeny Poberezkin aeebb89dc2 ios: version 4.0 (78) 2022-09-26 00:37:08 +01:00
Evgeny Poberezkin 1246ad2437 ios: German localizations (#1111)
* ios: German localizations

* add some translations

* more translations

* translations

* copied all translations

* update android translations

* update ios

* iOS German translations completed

* update de.xliff

* update translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
Co-authored-by: mlanp <github@lang.xyz>
2022-09-26 00:32:57 +01:00
Evgeny Poberezkin f46b813235 android: change german strings for clear/delete chat 2022-09-25 22:38:21 +01:00
Evgeny Poberezkin 83b77748b6 ios: fix: storing passphrase makes in available in NSE, do not allow empty current passphrase, reset files count after deletion (#1125) 2022-09-25 20:53:32 +01:00
Evgeny Poberezkin 4dde46a646 ios: verion 4.0 (77) 2022-09-25 14:31:24 +01:00
Evgeny Poberezkin b7dd787043 ios: remove key from log (#1123) 2022-09-25 13:17:04 +01:00
Evgeny Poberezkin bce8af16de website: add call page back (#1121) 2022-09-25 11:16:28 +01:00
Evgeny Poberezkin 96b8f0e979 package: update @simplex-chat/webrtc version 0.1.1 2022-09-25 11:01:58 +01:00
Evgeny Poberezkin 36f635f6f0 ios: prevent messages from being sent twice (#1120) 2022-09-24 22:20:56 +01:00
sh d15fec4fab docs/WEBRTC.md: new docs (#1118)
* docs/WEBRTC.md: new docs

* app configuration

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-24 21:15:49 +01:00
Evgeny Poberezkin 1b435d89f3 ios: fix chatlist search bar appearing in chat view (#1119) 2022-09-24 21:02:01 +01:00
Evgeny Poberezkin bbaa45a0e1 ios: fix layout for messages in right-to-left languages, #1032 (#1117)
* ios: fix layout for messages in right-to-left languages, #1032

* text alignment in compose message view

* more right-to-left alignment
2022-09-24 19:26:55 +01:00
JRoberts 5578183777 android: version 4.0 (59) 2022-09-24 20:02:02 +04:00
JRoberts 520800ded0 android: disable new chat buttons and notifications settings when chat is stopped (#1116) 2022-09-24 19:59:35 +04:00
JRoberts 47a6a81854 android: version 4.0 (58) 2022-09-24 18:57:47 +04:00
JRoberts 2a4b7b83a4 android: fix 'choose attachment' layout for German language (#1115) 2022-09-24 18:39:02 +04:00
Stanislav Dmitrenko 792c442aa3 Fixes #1105 (#1108) 2022-09-24 15:29:42 +01:00
JRoberts 54b39e8d00 android: ui fixes (#1114)
* new chat buttons alignment

* translations
2022-09-24 15:24:54 +01:00
Stanislav Dmitrenko 39f82e9e1a android: single call to initialize chat, fix (#1109)
* JNI experiments

* Next try

* Next try

* Final JNI code for the new library method

* remove unused functions

* android: refactor, fix; ios: change entropy bounds

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-24 13:45:10 +01:00
Evgeny Poberezkin ab7fed1628 ios: use single call to initialize chat controller (#1110)
* ios: use single call to initialize chat controller

* update logs

* comments
2022-09-24 09:28:22 +01:00
Evgeny Poberezkin c94691c89e android: fix string 2022-09-23 23:01:02 +01:00
Evgeny Poberezkin 3c95b76e5a android: add German translations (#1079)
* android: add file for German translations

* translations

* complete translation

* translations (up to line 630)

* update german strings file

* remove unused strings

* translations for Android completed

Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: M Sarmad Qadeer <MSarmadQadeer@gmail.com>
2022-09-23 22:57:00 +01:00
Evgeny Poberezkin a977a0dd17 core: single function to initialize the chat controller only if encryption key is correct (#1107) 2022-09-23 22:22:56 +04:00
JRoberts e1a7b02e59 android: version 4.0-beta.3 (57) 2022-09-23 18:40:28 +04:00
JRoberts 3537d3871c android: version 4.0-beta.3 (56) 2022-09-23 18:01:26 +04:00
JRoberts 093e7b4c78 ios: version 4.0 (75) 2022-09-23 16:52:47 +04:00
Evgeny Poberezkin 6e9cf2ba91 ios: fix background refresh crash, memoize migrateChatDatabase (#1103)
* ios: fix background refresh crash, memoize migrateChatDatabase

* store returned value
2022-09-23 12:51:40 +01:00
Evgeny Poberezkin 7c06961ff8 android: update/remove strings (#1104) 2022-09-23 12:51:27 +01:00
Stanislav Dmitrenko 2b53406ccf android: floating button for creating a new chat (#1102)
* Floating button for creating a new chat
- also fixed two-line preview in a chat list

* Button color in light theme

* update button color, remove elevation

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-23 00:03:06 +01:00
Stanislav Dmitrenko 06c79cc2bc android: Icons (#1100)
* Icons

* Icons in app's info

* Icon foreground

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-22 23:28:17 +01:00
Evgeny Poberezkin 5e870ec30f ios: fix chat list layout (#1094)
* ios: fix chat list layout

* set chat row height depending on dynamic type size

* update chat list layout sizes for ios15/16
2022-09-22 22:27:20 +01:00
Stanislav Dmitrenko 6a6d246dc5 android: UI fixes (#1099)
* UI fixes

* eol
2022-09-22 21:50:12 +01:00
Stanislav Dmitrenko 07191bfb61 android: UX for making connections (#1098)
* android: UX for making connections

* Tabs background color was specified

* Different icon and description

* update texts, fix layout on ios small screens

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-22 21:27:46 +01:00
JRoberts b62895ca76 android: version 4.0-beta.2 (55) 2022-09-22 22:08:15 +04:00
Evgeny Poberezkin bd2a748169 ios: allow export of unencrypted database (#1097) 2022-09-22 13:10:25 +01:00
Evgeny Poberezkin 06c7b9a995 update simplemq (sql function) 2022-09-22 12:29:50 +01:00
JRoberts 42b6bf96ff ios: version 4.0 (74) 2022-09-22 14:20:41 +04:00
JRoberts aa6fb2fc12 core: ignore file cancel errors when deleting chat/chat item (#1093) 2022-09-22 13:06:50 +04:00
Evgeny Poberezkin 8bfeab7071 ios: update simplex library 2022-09-22 10:04:26 +01:00
Stanislav Dmitrenko 97662b040e android: Replace "make connection" screen with two buttons (#1091) 2022-09-22 09:35:44 +01:00
Stanislav Dmitrenko a9ba16b07a android: Allow configuring WebRTC ICE servers (#1090)
* Allow configuring WebRTC ICE servers

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-22 09:16:25 +01:00
JRoberts 36d93f5b0e android: alerts on connection errors (#1087) 2022-09-22 11:55:11 +04:00
Evgeny Poberezkin ba25850b73 ios: update icons and logos (#1089) 2022-09-22 08:37:45 +01:00
Evgeny Poberezkin 9b75553ddc ios: UX for making connections (#1088)
* ios: UX for making connections

* combine paste and scan into one view

* translations
2022-09-22 08:36:39 +01:00
Evgeny Poberezkin b390630f4b ios: fix occasionally broken QR code when creating invitation link (#1086) 2022-09-21 16:28:01 +01:00
Evgeny Poberezkin df329d305b ios: replace "make connection" screen with two buttons (#1084)
* ios: replace "make connection" screen with two buttons

* add "no chats", update translations
2022-09-21 15:11:52 +01:00
JRoberts 59b4ce2474 mobile: decrease mark read delay (#1085) 2022-09-21 17:39:29 +04:00
JRoberts 9442656bbe ios: alerts on connection errors (#1080)
* ios: alerts on connection errors

* fixes

* refactor

* refactor

* refactor

* translations

* delete contact with groups error

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-09-21 17:18:48 +04:00
Evgeny Poberezkin 0494cce77d ios: remove destructive role from swipe actions to avoid pre-emptive row deletion before confirmations (#1083) 2022-09-21 11:52:28 +01:00
Evgeny Poberezkin 909a8aaf9c webrtc: change server ports to 443, ios: allow configuring WebRTC ICE servers (#1077)
* webrtc: change server ports to 443

* pass stun/turn servers from user default

* ios: configure WebRTC ICE servers

* translations

* update servers

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

* section title, corrections

* remove icon

* change translation

* revert disabled unless stopped

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

* display current storage state

* alert

* fix

* simplify

* remove unused function

* fix log

* replace prints with logger

* Apply suggestions from code review

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

* low res will remain text

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

* refactor

* move check to send command

* revert diff

* refactor

* android

* Apply suggestions from code review

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

* fix

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

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

* wip

* wip

* refactor

* clean up

* simplify

* simplify

* refactor

* rename

* rename consts

* refactor

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

* Removed unused code

* Safer way of doing some things

* Ordering

* Increased possible diff in time to 10 seconds

* update strings

* Alert confirmation

* update strings

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

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

* Better idea of when to show background notice

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

* update icon

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

* uncomment

* comment

* ci split build step

* check openssl

* openssl version

* add stack params

* update mac openssl parameters

* ls openssl

* clean stack.yaml

* clean up build.yml

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

* wip

* wip

* schema

* schema

* wip

* wip

* rework

* revert

* update simplexmq

* async commands

* corr id wip

* wip

* update simplexmq

* corr id

* wip

* rename variable

* wip

* refactor

* ACK continuation

* wip

* fix queries

* fix queries

* clean up schema

* update simplexmq, do not lock on stopping chat

* clean up

* refactor

* refactor

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

* fix incorrect language in NSE localizations

* corrections

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

* translations

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

* Don't regenerate key if it exists

* Made code shorter

* Refactoring

* Initial support of encryped database

* Changes in UI and notifications about database problems

* Small changes to how we use chatController instance

* Show unlock view in console automatically

* Fixed wrong place of saving a key

* Fixed a crash

* update icons

* Changing controller correctly

* Enable migration

* fix JNI

* Fixed startup

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

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

* Scrollable columns and restarted service and worker

* translations

* database passphrase

* update translations

* translations

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

* update translations

* update translations

* update icon colors, show empty passphrase as not stored

* update translation

* update translations

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

* correction

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

* update translations

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

* Provide correct mime type when saving an image

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

* add chat responses

* add client API

* fix chat bot example, readme

* update readme

* update main readme

* readme

* corrections

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

* correction

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

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

* add 11ty files to website folder

* add web.yml

* add simplex web files

* add font matter to some blogs

* remove unnecessary things

* change few settings

* add a web script

* update web.yml

* update image format & add an image

* add font matter to blogs

* update blog.html

* add article layout & give that layout to blogs

* update the location of _includes

* update article layout

* change original blog links

* add styling to blog

* improve the links of blogs

* update web.sh

* add favicon

* update a tag in a blog

* improve stylings of article page

* improve styling of blog page

* update the theme

* update font matter and update links in new blog.

* add style changes

* apply reverse chronology sort on articles

* shift blogs links back to hashes

* add ids to headers & smooth scrolling

* make all blog links relative

* add smooth scrolling & add relative to absolute links converter

* add navigation

* improve mobile nav

* change desktop header style

* convert blogs link text to "Read More"

* change desktop header style

* style mobile nav

* fix landing page styling

* update web workflow

* update web workflow

* nav setting

* add tailwind links

* update web workflow

* remove app demo folder

* remove special characters from the links

* fix the issue of links

* make web.sh executable

* update blog links

* move web.sh to website folder

* code style

* EOLs

* format index.css & contact.css

* add markdown-it configuration

* add outline none on focus

* remove extra Javascript

* make mobile nav display none by default

* add permalinks to markdown files

* update 11ty config

* update web.sh

* update article

* resolve issue of special characters in header ids
introduce slugify

* add target _blank to whitepaper link

* add last post

* EOLs

* try to resolve bullets issue

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

* add missing images, simpligy link parsing

* add CNAME file

* add CNAME file, rename config

* fix jumping table issue

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

* typo

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

* fixes

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

* UI for db encryption

* database passphrase UI

* show orange icon when database is not encrypted

* call encrypt

* more ios ux

* basic UX for passphrase complete

* with animation

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

* passphrase complexity, fixes

* fix moving entry field

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

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

* set -e

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

* Removed line

* Added text to alert before changing network settings

* Strings

* change alert title

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

* More stable notification worker

* Allowed to run periodic notifications when battery optimization is on

* corrections

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

* correction

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

* correction

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

* Changes to notifications flow

* correction

* Made delay for receiving messages in worker longer

* correction

* check interval

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

* update strings

* Strings

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

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

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

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

* Small changes to layout of alias text field

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

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

* typo

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

* add images

* images

* fix link

* update blog links

* correction

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

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

* layout

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

* do not compress all files in res folder

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

* check DB key error on start

* function to encrypt database

* encrypt database command

* decrypt, rekey

* remove rekey, refactor

* test for db encryption/decryption

* update simplexmq
2022-08-31 18:07:34 +01:00
Evgeny Poberezkin 5e5c851173 update simplexmq 2022-08-30 16:35:56 +01:00
Evgeny Poberezkin ed519a5cfe Merge branch 'master' into sqlcipher 2022-08-30 16:35:12 +01:00
Evgeny Poberezkin 025f838f43 update dependencies 2022-08-30 14:33:43 +01:00
Evgeny Poberezkin e651952a34 Merge branch 'master' into sqlcipher 2022-08-30 12:52:06 +01:00
Evgeny Poberezkin 02ca7234fb use SQLCipher (#981)
* use SQLCipher

* pass encryption key via CLI options

* update dependencies to use git

* add CONTRIBUTING.md

* move flag, enable build in sqlcipher branch

* update dependencies
2022-08-30 12:49:07 +01:00
436 changed files with 26956 additions and 4102 deletions
+43 -20
View File
@@ -5,6 +5,7 @@ on:
branches:
- master
- stable
- sqlcipher
tags:
- "v*"
pull_request:
@@ -49,50 +50,74 @@ jobs:
matrix:
include:
- os: ubuntu-20.04
cache_path: ~/.stack
cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-20_04-x86-64
- os: ubuntu-18.04
cache_path: ~/.stack
cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-18_04-x86-64
- os: macos-latest
cache_path: ~/.stack
cache_path: ~/.cabal/store
asset_name: simplex-chat-macos-x86-64
- os: windows-latest
cache_path: C:/sr
cache_path: C:/cabal
asset_name: simplex-chat-windows-x86-64
steps:
- name: Clone project
uses: actions/checkout@v2
- name: Setup Stack
- name: Setup Haskell
uses: haskell/actions/setup@v1
with:
ghc-version: '8.10.7'
enable-stack: true
stack-version: 'latest'
ghc-version: "8.10.7"
cabal-version: "latest"
- name: Cache dependencies
uses: actions/cache@v2
with:
path: ${{ matrix.cache_path }}
key: ${{ matrix.os }}-${{ hashFiles('stack.yaml') }}
path: |
${{ matrix.cache_path }}
dist-newstyle
key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
# / Unix
- name: Unix prepare cabal.project.local for Mac
if: matrix.os == 'macos-latest'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Unix prepare cabal.project.local for Ubuntu
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-18.04'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Unix build
id: unix_build
if: matrix.os != 'windows-latest'
shell: bash
run: |
stack build --test
echo "::set-output name=local_install_root::$(stack path --local-install-root)"
cabal build --enable-tests
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
- name: Unix test
if: matrix.os != 'windows-latest'
shell: bash
run: cabal test --test-show-details=direct
- name: Unix upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.unix_build.outputs.local_install_root }}/bin/simplex-chat
file: ${{ steps.unix_build.outputs.bin_path }}
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
@@ -102,25 +127,23 @@ jobs:
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
# * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
# * So we're running a separate set of actions for Windows build
# TODO run tests on Windows
- name: Windows build
id: windows_build
if: matrix.os == 'windows-latest'
shell: cmd
run: |
stack build
stack path --local-install-root > tmp_file
set /p local_install_root= < tmp_file
echo ::set-output name=local_install_root::%local_install_root%
cabal build --enable-tests
cabal list-bin simplex-chat > tmp_bin_path
set /p bin_path= < tmp_bin_path
echo ::set-output name=bin_path::%bin_path%
- name: Windows upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.windows_build.outputs.local_install_root }}\bin\simplex-chat.exe
file: ${{ steps.windows_build.outputs.bin_path }}
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
+38
View File
@@ -0,0 +1,38 @@
name: Build Eleventy
on:
push:
branches:
- master
- stable
paths:
- website/**
- images/**
- blog/**
- .github/workflows/web.yml
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies & build
run: |
./website/web.sh
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
publish_dir: ./website/_site
github_token: ${{ secrets.GITHUB_TOKEN }}
+29
View File
@@ -43,3 +43,32 @@ stack.yaml.lock
# Temporary test files
tests/tmp
logs/
# for website
website/node_modules/
website/src/blog/
website/src/img/images/
website/src/images/
# Generated files
website/package/generated*
# Ignore build tool output, e.g. code coverage
website/.nyc_output/
website/coverage/
# Ignore API documentation
website/api-docs/
# Ignore folders from source code editors
website/.vscode
website/.idea
# Ignore eleventy output when doing manual tests
website/_site/
website/package-lock.json
# Ignore test files
website/.cache
website/test/stubs-layout-cache/_includes/*.js
+30 -21
View File
@@ -24,6 +24,8 @@
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
**NEW**: v4.0 is released - now local chat database is encrypted with passphrase! See [the release announcement](./blog/20220928-simplex-chat-v4-encrypted-database.md).
## Contents
- [Why privacy matters](#why-privacy-matters)
@@ -79,18 +81,16 @@ You can use SimpleX with your own servers and still communicate with people usin
## News and updates
Selected updates:
Recent updates:
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced netwrok settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md)
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md)
[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md)
[Jan 12, 2022. SimpleX v1 released: the only messaging and application platform without user identities](./20220112-simplex-chat-v1-released.md)
[All updates](./blog)
## Make a private connection
@@ -142,22 +142,23 @@ What is already implemented:
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
We plan to add soon:
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
2. Local database encryption. Currently the local chat database stored on your device is not encrypted.
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
4. Independent implementation audit.
## For developers
We plan that the SimpleX platform will grow into the platform supporting any distributed Internet application. This will allow you to build any service that people can access via chat, with custom web-based UI widgets that anybody with basic HTML/CSS/JavaScript knowledge can create in a few hours.
You can:
You already can:
- use SimpleX Chat library to integrate chat functionality into your apps.
- use SimpleX Chat bot templates in Haskell to build your own chat bot services (TypeScript SDK is coming soon).
- use SimpleX Chat library to integrate chat functionality into your mobile apps.
- create chat bots and services in Haskell - see [simple](./apps/simplex-bot/) and more [advanced chat bot example](./apps/simplex-bot-advanced/).
- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScipt chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
If you are considering developing with SimpleX platform please get in touch for any advice and support.
@@ -177,20 +178,27 @@ If you are considering developing with SimpleX platform please get in touch for
- ✅ Chat database export and import
- ✅ Chat groups in mobile apps.
- ✅ Connecting to messaging servers via Tor.
- 🏗 Dual server addresses to access messaging servers as v3 hidden services (in progress).
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
- Chat database encryption.
- Dual server addresses to access messaging servers as v3 hidden services.
- Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (ready for announcement).
- ✅ Incognito mode to share a new random name with each contact.
- ✅ Chat database encryption.
- 🏗 Automatic chat history deletion.
- 🏗 SMP queue redundancy and rotation.
- 🏗 Links to join groups and improve groups stability.
- Feeds/broadcasts
- Disappearing messages, with mutual agreement.
- Voice messages
- Video messages
- Web widgets for custom interactivity in the chats.
- SMP protocol improvements:
- SMP queue redundancy and rotation.
- Message delivery confirmation.
- Supporting the same profile on multiple devices.
- Message delivery confirmation.
- Supporting the same profile on multiple devices.
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
- keep all your contacts and groups even if you lose the domain.
- the server doesn't have information about your contacts and groups.
- Channels server for large groups and broadcast channels.
- Media server to optimize sending large files to groups.
- Desktop client.
- Using the same profile on multiple devices.
## Help us pay for 3rd party security audit
@@ -209,6 +217,7 @@ It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
Thank you,
+1
View File
@@ -16,3 +16,4 @@
.externalNativeBuild
.cxx
local.properties
app/src/main/cpp/libs/
+2
View File
@@ -13,7 +13,9 @@
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
<arrangement>
<rules>
-1
View File
@@ -1,6 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>
+7 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 51
versionName "3.2"
versionCode 61
versionName "4.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -112,6 +112,7 @@ dependencies {
//Camera Permission
implementation "com.google.accompanist:accompanist-permissions:0.23.0"
implementation "com.google.accompanist:accompanist-pager:0.25.1"
// Link Previews
implementation 'org.jsoup:jsoup:1.13.1'
@@ -119,6 +120,10 @@ dependencies {
// Biometric authentication
implementation 'androidx.biometric:biometric:1.2.0-alpha04'
// GIFs support
implementation "io.coil-kt:coil-compose:2.1.0"
implementation "io.coil-kt:coil-gif:2.1.0"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
@@ -63,6 +63,12 @@
<data android:pathPrefix="/invitation" />
<data android:pathPrefix="/contact" />
</intent-filter>
<!-- Receive files from other apps -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<activity-alias
@@ -103,6 +109,12 @@
android:resource="@xml/file_paths" />
</provider>
<!-- NtfManager action processing (buttons in notifications) -->
<receiver
android:name=".model.NtfManager$NtfActionReceiver"
android:enabled="true"
android:exported="false" />
<!-- SimplexService foreground service -->
<service
android:name=".SimplexService"
+9 -4
View File
@@ -24,8 +24,8 @@ var TransformOperation;
let activeCall;
const processCommand = (function () {
const defaultIceServers = [
{ urls: ["stun:stun.simplex.im:5349"] },
{ urls: ["turn:turn.simplex.im:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
{ urls: ["stun:stun.simplex.im:443"] },
{ urls: ["turn:turn.simplex.im:443"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
];
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
return {
@@ -495,6 +495,7 @@ function callCryptoFunction() {
const initialPlainTextRequired = {
key: 10,
delta: 3,
empty: 1,
};
const IV_LENGTH = 12;
function encryptFrame(key) {
@@ -505,7 +506,9 @@ function callCryptoFunction() {
const initial = data.subarray(0, n);
const plaintext = data.subarray(n, data.byteLength);
try {
const ciphertext = new Uint8Array(plaintext.length ? await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext) : 0);
const ciphertext = plaintext.length
? new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext))
: new Uint8Array(0);
frame.data = concatN(initial, ciphertext, iv).buffer;
controller.enqueue(frame);
}
@@ -523,7 +526,9 @@ function callCryptoFunction() {
const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH);
const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength);
try {
const plaintext = new Uint8Array(ciphertext.length ? await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext) : 0);
const plaintext = ciphertext.length
? new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext))
: new Uint8Array(0);
frame.data = concatN(initial, plaintext).buffer;
controller.enqueue(frame);
}
+4 -1
View File
@@ -53,6 +53,9 @@ add_library( support SHARED IMPORTED )
set_target_properties( support PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
add_library( crypto SHARED IMPORTED )
set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libcrypto.so)
# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
@@ -61,7 +64,7 @@ set_target_properties( support PROPERTIES IMPORTED_LOCATION
target_link_libraries( # Specifies the target library.
app-lib
simplex support
simplex support crypto
# Links the target library to the log library
# included in the NDK.
+22 -9
View File
@@ -22,20 +22,33 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass
}
// from simplex-chat
typedef void* chat_ctrl;
typedef long* chat_ctrl;
extern chat_ctrl chat_init(const char *path);
extern char *chat_migrate_init(const char *path, const char *key, chat_ctrl *ctrl);
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl);
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
const char *_data = (*env)->GetStringUTFChars(env, datadir, JNI_FALSE);
jlong res = (jlong)chat_init(_data);
(*env)->ReleaseStringUTFChars(env, datadir, _data);
return res;
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
jlong _ctrl = (jlong) 0;
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, &_ctrl));
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
// Java's String
(*env)->SetObjectArrayElement(env, ret, 0, res);
// Java's Long
(*env)->SetObjectArrayElement(env, ret, 1,
(*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Long"),
(*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Long"), "<init>", "(J)V"),
_ctrl));
return ret;
}
JNIEXPORT jstring JNICALL
@@ -4,6 +4,7 @@ import android.app.Application
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.os.SystemClock.elapsedRealtime
import android.util.Log
import androidx.activity.compose.setContent
@@ -15,13 +16,12 @@ import androidx.compose.material.Surface
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Replay
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.ui.theme.SimpleButton
@@ -30,28 +30,43 @@ import chat.simplex.app.views.SplashView
import chat.simplex.app.views.call.ActiveCallView
import chat.simplex.app.views.call.IncomingCallAlertView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.chatlist.*
import chat.simplex.app.views.database.DatabaseErrorView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.connectViaUri
import chat.simplex.app.views.newchat.withUriAction
import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay
import java.util.concurrent.TimeUnit
class MainActivity: FragmentActivity(), LifecycleEventObserver {
class MainActivity: FragmentActivity() {
companion object {
/**
* We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user
* clicks on new message in notification. In this case savedInstanceState will be null (this prevents restoring the values)
* See [SimplexService.onTaskRemoved] for another part of the logic which nullifies the values when app closed by the user
* */
val userAuthorized = mutableStateOf<Boolean?>(null)
val enteredBackground = mutableStateOf<Long?>(null)
// Remember result and show it after orientation change
private val laFailed = mutableStateOf(false)
fun clearAuthState() {
userAuthorized.value = null
enteredBackground.value = null
}
}
private val vm by viewModels<SimplexViewModel>()
private val chatController by lazy { (application as SimplexApp).chatController }
private val userAuthorized = mutableStateOf<Boolean?>(null)
private val enteredBackground = mutableStateOf<Long?>(null)
private val laFailed = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
// testJson()
val m = vm.chatModel
processNotificationIntent(intent, m)
// When call ended and orientation changes, it re-process old intent, it's unneeded.
// Only needed to be processed on first creation of activity
if (savedInstanceState == null) {
processNotificationIntent(intent, m)
processExternalIntent(intent, m)
}
setContent {
SimpleXTheme {
Surface(
@@ -71,27 +86,38 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
}
}
SimplexApp.context.schedulePeriodicServiceRestartWorker()
SimplexApp.context.schedulePeriodicWakeUp()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
processIntent(intent, vm.chatModel)
processExternalIntent(intent, vm.chatModel)
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
withApi {
when (event) {
Lifecycle.Event.ON_STOP -> {
enteredBackground.value = elapsedRealtime()
}
Lifecycle.Event.ON_START -> {
val enteredBackgroundVal = enteredBackground.value
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
runAuthenticate()
}
}
else -> {}
}
override fun onStart() {
super.onStart()
val enteredBackgroundVal = enteredBackground.value
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
runAuthenticate()
}
}
override fun onStop() {
super.onStop()
enteredBackground.value = elapsedRealtime()
}
override fun onBackPressed() {
super.onBackPressed()
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
clearAuthState()
laFailed.value = true
}
if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
// Drop shared content
SimplexApp.context.chatModel.sharedContent.value = null
}
}
@@ -223,13 +249,20 @@ fun MainPage(
showLANotice: () -> Unit
) {
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
var chatsAccessAuthorized by remember { mutableStateOf(false) }
var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(userAuthorized.value) {
if (chatModel.controller.appPrefs.performLA.get()) {
delay(500L)
}
chatsAccessAuthorized = userAuthorized.value == true
}
var showChatDatabaseError by rememberSaveable {
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
}
LaunchedEffect(chatModel.chatDbStatus.value) {
showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null
}
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
LaunchedEffect(showAdvertiseLAAlert) {
if (
@@ -275,6 +308,11 @@ fun MainPage(
val onboarding = chatModel.onboardingStage.value
val userCreated = chatModel.userCreated.value
when {
showChatDatabaseError -> {
chatModel.chatDbStatus.value?.let {
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
}
}
onboarding == null || userCreated == null -> SplashView()
!chatsAccessAuthorized -> {
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
@@ -289,15 +327,17 @@ fun MainPage(
else {
showAdvertiseLAAlert = true
val stopped = chatModel.chatRunning.value == false
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA, stopped)
if (chatModel.chatId.value == null) {
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, setPerformLA, stopped)
else
ShareListView(chatModel, stopped)
}
else ChatView(chatModel)
}
}
}
onboarding == OnboardingStage.Step1_SimpleXInfo ->
Box(Modifier.padding(horizontal = 20.dp)) {
SimpleXInfo(chatModel, onboarding = true)
}
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
}
ModalManager.shared.showInView()
@@ -347,6 +387,27 @@ fun processIntent(intent: Intent?, chatModel: ChatModel) {
}
}
fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
when (intent?.action) {
Intent.ACTION_SEND -> {
// Close active chat and show a list of chats
chatModel.chatId.value = null
chatModel.clearOverlays.value = true
when {
"text/plain" == intent.type -> intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
chatModel.sharedContent.value = SharedContent.Text(it)
}
intent.type?.startsWith("image/") == true -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
chatModel.sharedContent.value = SharedContent.Image(it)
} // All other mime types
else -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
chatModel.sharedContent.value = SharedContent.File(it)
}
}
}
}
}
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
@@ -6,10 +6,11 @@ import android.util.Log
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.getFilesDirectory
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*
@@ -26,21 +27,53 @@ external fun pipeStdOutToSocket(socketName: String) : Int
// SimpleX API
typealias ChatCtrl = Long
external fun chatInit(path: String): ChatCtrl
external fun chatMigrateInit(dbPath: String, dbKey: String): Array<Any>
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
external fun chatRecvMsg(ctrl: ChatCtrl): String
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
class SimplexApp: Application(), LifecycleEventObserver {
val chatController: ChatController by lazy {
val ctrl = chatInit(getFilesDirectory(applicationContext))
ChatController(ctrl, ntfManager, applicationContext, appPreferences)
lateinit var chatController: ChatController
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey)
val res: DBMigrationResult = kotlin.runCatching {
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
val ctrl = if (res is DBMigrationResult.OK) {
migrated[1] as Long
} else null
if (::chatController.isInitialized) {
chatController.ctrl = ctrl
} else {
chatController = ChatController(ctrl, ntfManager, applicationContext, appPreferences)
}
chatModel.chatDbEncrypted.value = dbKey != ""
chatModel.chatDbStatus.value = res
if (res != DBMigrationResult.OK) {
Log.d(TAG, "Unable to migrate successfully: $res")
} else if (startChat) {
// If we migrated successfully means previous re-encryption process on database level finished successfully too
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
withApi {
val user = chatController.apiGetActiveUser()
if (user == null) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
chatController.startChat(user)
chatController.showBackgroundServiceNoticeIfNeeded()
if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
SimplexService.start(applicationContext)
}
}
}
}
val chatModel: ChatModel by lazy {
chatController.chatModel
}
val chatModel: ChatModel
get() = chatController.chatModel
private val ntfManager: NtfManager by lazy {
NtfManager(applicationContext, appPreferences)
@@ -53,36 +86,47 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun onCreate() {
super.onCreate()
context = this
initChatController()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
withApi {
val user = chatController.apiGetActiveUser()
if (user == null) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
chatController.startChat(user)
}
}
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Log.d(TAG, "onStateChanged: $event")
withApi {
when (event) {
Lifecycle.Event.ON_STOP ->
if (!appPreferences.runServiceInBackground.get()) SimplexService.stop(applicationContext)
Lifecycle.Event.ON_START ->
if (chatModel.chatRunning.value != false) SimplexService.start(applicationContext)
Lifecycle.Event.ON_RESUME ->
Lifecycle.Event.ON_START -> {
if (chatModel.chatRunning.value == true) {
kotlin.runCatching {
val chats = chatController.apiGetChats()
chatModel.updateChats(chats)
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
}
}
Lifecycle.Event.ON_RESUME -> {
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
chatController.showBackgroundServiceNoticeIfNeeded()
}
/**
* We're starting service here instead of in [Lifecycle.Event.ON_START] because
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
* It can happen when app was started and a user enables battery optimization while app in background
* */
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
SimplexService.start(applicationContext)
}
else -> {}
}
}
}
fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
appPrefs.runServiceInBackground.get() && isIgnoringBatteryOptimizations(chatModel.controller.appContext)
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name &&
(!NotificationsMode.SERVICE.requiresIgnoringBattery || isIgnoringBatteryOptimizations(chatModel.controller.appContext))
}
private fun allowToStartPeriodically() = with(chatModel.controller) {
appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC.name &&
(!NotificationsMode.PERIODIC.requiresIgnoringBattery || isIgnoringBatteryOptimizations(chatModel.controller.appContext))
}
/*
@@ -109,6 +153,13 @@ class SimplexApp: Application(), LifecycleEventObserver {
WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
}
fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch {
if (!allowToStartPeriodically()) {
return@launch
}
MessagesFetcherWorker.scheduleWork()
}
companion object {
lateinit var context: SimplexApp private set
@@ -9,7 +9,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -24,7 +24,6 @@ class SimplexService: Service() {
private var isStartingService = false
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
private val chatController by lazy { (application as SimplexApp).chatController }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand startId: $startId")
@@ -33,7 +32,6 @@ class SimplexService: Service() {
Log.d(TAG, "intent action $action")
when (action) {
Action.START.name -> startService()
Action.STOP.name -> stopService()
else -> Log.e(TAG, "No action in the intent")
}
} else {
@@ -56,7 +54,7 @@ class SimplexService: Service() {
Log.d(TAG, "Simplex service destroyed")
stopService()
// If private notifications are enabled and battery optimization is disabled, restart the service
// If notification service is enabled and battery optimization is disabled, restart the service
if (SimplexApp.context.allowToStartServiceAfterAppExit())
sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
super.onDestroy()
@@ -68,19 +66,21 @@ class SimplexService: Service() {
val self = this
isStartingService = true
withApi {
val chatController = (application as SimplexApp).chatController
try {
val user = chatController.apiGetActiveUser()
if (user == null) {
chatController.chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
Log.w(TAG, "Starting foreground service")
chatController.startChat(user)
isServiceStarted = true
saveServiceState(self, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
acquire()
}
Log.w(TAG, "Starting foreground service")
val chatDbStatus = chatController.chatModel.chatDbStatus.value
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
showPassphraseNotification(chatDbStatus)
stopService()
return@withApi
}
isServiceStarted = true
saveServiceState(self, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
acquire()
}
}
} finally {
@@ -151,7 +151,10 @@ class SimplexService: Service() {
// re-schedules the task when "Clear recent apps" is pressed
override fun onTaskRemoved(rootIntent: Intent) {
// If private notifications aren't enabled or battery optimization isn't disabled, we shouldn't restart the service
// Just to make sure that after restart of the app the user will need to re-authenticate
MainActivity.clearAuthState()
// If notification service isn't enabled or battery optimization isn't disabled, we shouldn't restart the service
if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
return
}
@@ -209,7 +212,6 @@ class SimplexService: Service() {
enum class Action {
START,
STOP
}
enum class ServiceState {
@@ -226,6 +228,8 @@ class SimplexService: Service() {
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change!
private const val PASSPHRASE_NOTIFICATION_ID = 1535
private const val WAKE_LOCK_TAG = "SimplexService::lock"
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS"
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
@@ -240,7 +244,7 @@ class SimplexService: Service() {
suspend fun start(context: Context) = serviceAction(context, Action.START)
suspend fun stop(context: Context) = serviceAction(context, Action.STOP)
fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java))
private suspend fun serviceAction(context: Context, action: Action) {
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
@@ -270,6 +274,41 @@ class SimplexService: Service() {
return ServiceState.valueOf(value!!)
}
fun showPassphraseNotification(chatDbStatus: DBMigrationResult?) {
val pendingIntent: PendingIntent = Intent(SimplexApp.context, MainActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(SimplexApp.context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
}
val title = when(chatDbStatus) {
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_title)
is DBMigrationResult.OK -> return
else -> generalGetString(R.string.database_initialization_error_title)
}
val description = when(chatDbStatus) {
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_desc)
is DBMigrationResult.OK -> return
else -> generalGetString(R.string.database_initialization_error_desc)
}
val builder = NotificationCompat.Builder(SimplexApp.context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_service_icon)
.setColor(0x88FFFF)
.setContentTitle(title)
.setContentText(description)
.setContentIntent(pendingIntent)
.setSilent(true)
.setShowWhen(false)
val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(PASSPHRASE_NOTIFICATION_ID, builder.build())
}
fun cancelPassphraseNotification() {
val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(PASSPHRASE_NOTIFICATION_ID)
}
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
}
}
@@ -10,8 +10,10 @@ import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.NotificationPreviewMode
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.datetime.*
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
@@ -25,6 +27,9 @@ class ChatModel(val controller: ChatController) {
val userCreated = mutableStateOf<Boolean?>(null)
val chatRunning = mutableStateOf<Boolean?>(null)
val chatDbChanged = mutableStateOf<Boolean>(false)
val chatDbEncrypted = mutableStateOf<Boolean?>(false)
val chatDbStatus = mutableStateOf<DBMigrationResult?>(null)
val chatDbDeleted = mutableStateOf(false)
val chats = mutableStateListOf<Chat>()
// current chat
@@ -32,10 +37,10 @@ class ChatModel(val controller: ChatController) {
val chatItems = mutableStateListOf<ChatItem>()
val groupMembers = mutableStateListOf<GroupMember>()
var connReqInvitation: String? = null
val terminalItems = mutableStateListOf<TerminalItem>()
val userAddress = mutableStateOf<String?>(null)
val userSMPServers = mutableStateOf<(List<String>)?>(null)
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
// set when app opened from external intent
val clearOverlays = mutableStateOf<Boolean>(false)
@@ -44,7 +49,8 @@ class ChatModel(val controller: ChatController) {
val appOpenUrl = mutableStateOf<Uri?>(null)
// preferences
val runServiceInBackground = mutableStateOf(true)
val notificationsMode = mutableStateOf(NotificationsMode.default)
var notificationPreviewMode = mutableStateOf(NotificationPreviewMode.default)
val performLA = mutableStateOf(false)
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
var incognito = mutableStateOf(false)
@@ -58,6 +64,12 @@ class ChatModel(val controller: ChatController) {
val showCallView = mutableStateOf(false)
val switchingCall = mutableStateOf(false)
// currently showing QR code
val connReqInv = mutableStateOf(null as String?)
// working with external intents
val sharedContent = mutableStateOf(null as SharedContent?)
fun updateUserProfile(profile: LocalProfile) {
val user = currentUser.value
if (user != null) {
@@ -102,6 +114,12 @@ class ChatModel(val controller: ChatController) {
}
chats.clear()
chats.addAll(mergedChats)
val cId = chatId.value
// If chat is null, it was deleted in background after apiGetChats call
if (cId != null && getChat(cId) == null) {
chatId.value = null
}
}
fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) {
@@ -160,6 +178,10 @@ class ChatModel(val controller: ChatController) {
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
if (pItem.isRcvNew && !cItem.isRcvNew) {
// status changed from New to Read, update counter
decreaseCounterInChat(cInfo.id)
}
}
res = false
} else {
@@ -248,6 +270,18 @@ class ChatModel(val controller: ChatController) {
return markedRead
}
private fun decreaseCounterInChat(chatId: ChatId) {
val chatIndex = getChatIndex(chatId)
if (chatIndex == -1) return
val chat = chats[chatIndex]
chats[chatIndex] = chat.copy(
chatStats = chat.chatStats.copy(
unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0),
)
)
}
// func popChat(_ id: String) {
// if let i = getChatIndex(id) {
// popChat_(i)
@@ -259,6 +293,15 @@ class ChatModel(val controller: ChatController) {
chats.add(index = 0, chat)
}
fun dismissConnReqView(id: String) {
if (connReqInv.value == null) return
val info = getChat(id)?.chatInfo as? ChatInfo.ContactConnection ?: return
if (info.contactConnection.connReqInv == connReqInv.value) {
connReqInv.value = null
ModalManager.shared.closeModals()
}
}
fun removeChat(id: String) {
chats.removeAll { it.id == id }
}
@@ -599,7 +642,7 @@ data class GroupInfo (
override val chatType get() = ChatType.Group
override val id get() = "#$groupId"
override val apiId get() = groupId
override val ready get() = true
override val ready get() = membership.memberActive
override val sendMsgEnabled get() = membership.memberActive
override val ntfsEnabled get() = chatSettings.enableNtfs
override val displayName get() = groupProfile.displayName
@@ -696,12 +739,18 @@ class GroupMember (
GroupMemberStatus.MemCreator -> true
}
fun canBeRemoved(membership: GroupMember): Boolean {
val userRole = membership.memberRole
fun canBeRemoved(groupInfo: GroupInfo): Boolean {
val userRole = groupInfo.membership.memberRole
return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft
&& userRole >= GroupMemberRole.Admin && userRole >= memberRole && membership.memberCurrent
&& userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberCurrent
}
fun canChangeRoleTo(groupInfo: GroupInfo): List<GroupMemberRole>? =
if (!canBeRemoved(groupInfo)) null
else groupInfo.membership.memberRole.let { userRole ->
GroupMemberRole.values().filter { it <= userRole }
}
val memberIncognito = memberProfile.profileId != memberContactProfileId
companion object {
@@ -854,6 +903,8 @@ class PendingContactConnection(
val pccConnStatus: ConnStatus,
val viaContactUri: Boolean,
val customUserProfileId: Long? = null,
val connReqInv: String? = null,
override val localAlias: String,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -865,6 +916,7 @@ class PendingContactConnection(
override val ntfsEnabled get() = false
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
override val displayName: String get() {
if (localAlias.isNotEmpty()) return localAlias
val initiated = pccConnStatus.initiated
return if (initiated == null) {
// this should not be in the chat list
@@ -878,7 +930,6 @@ class PendingContactConnection(
}
override val fullName get() = ""
override val image get() = null
override val localAlias get() = ""
val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
@@ -903,6 +954,7 @@ class PendingContactConnection(
pccAgentConnId = "abcd",
pccConnStatus = status,
viaContactUri = viaContactUri,
localAlias = "",
customUserProfileId = null,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
@@ -975,6 +1027,24 @@ data class ChatItem (
else -> false
}
val isMutedMemberEvent: Boolean get() =
when (content) {
is CIContent.RcvGroupEventContent ->
when (content.rcvGroupEvent) {
is RcvGroupEvent.GroupUpdated -> true
is RcvGroupEvent.MemberConnected -> true
is RcvGroupEvent.UserDeleted -> false
is RcvGroupEvent.GroupDeleted -> false
is RcvGroupEvent.MemberAdded -> false
is RcvGroupEvent.MemberLeft -> false
is RcvGroupEvent.MemberRole -> true
is RcvGroupEvent.UserRole -> false
is RcvGroupEvent.MemberDeleted -> false
}
is CIContent.SndGroupEventContent -> true
else -> false
}
fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))
companion object {
@@ -1166,11 +1236,11 @@ class CIQuote (
): ItemContent {
override val text: String get() = content.text
fun sender(user: User): String? = when (chatDir) {
fun sender(membership: GroupMember?): String? = when (chatDir) {
is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun)
is CIDirection.DirectRcv -> null
is CIDirection.GroupSnd -> user.displayName
is CIDirection.GroupRcv -> chatDir.groupMember.memberProfile.displayName
is CIDirection.GroupSnd -> membership?.displayName
is CIDirection.GroupRcv -> chatDir.groupMember.displayName
null -> null
}
@@ -1420,6 +1490,9 @@ enum class FormatColor(val color: String) {
@Serializable
class SndFileTransfer() {}
@Serializable
class RcvFileTransfer() {}
@Serializable
class FileTransferMeta() {}
@@ -1468,6 +1541,8 @@ sealed class RcvGroupEvent() {
@Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
@Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent()
@Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent()
@Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): RcvGroupEvent()
@Serializable @SerialName("userRole") class UserRole(val role: GroupMemberRole): RcvGroupEvent()
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
@Serializable @SerialName("userDeleted") class UserDeleted(): RcvGroupEvent()
@Serializable @SerialName("groupDeleted") class GroupDeleted(): RcvGroupEvent()
@@ -1477,6 +1552,8 @@ sealed class RcvGroupEvent() {
is MemberAdded -> String.format(generalGetString(R.string.rcv_group_event_member_added), profile.profileViewName)
is MemberConnected -> generalGetString(R.string.rcv_group_event_member_connected)
is MemberLeft -> generalGetString(R.string.rcv_group_event_member_left)
is MemberRole -> String.format(generalGetString(R.string.member_role), profile.profileViewName, role.text)
is UserRole -> String.format(generalGetString(R.string.your_member_role), role.text)
is MemberDeleted -> String.format(generalGetString(R.string.rcv_group_event_member_deleted), profile.profileViewName)
is UserDeleted -> generalGetString(R.string.rcv_group_event_user_deleted)
is GroupDeleted -> generalGetString(R.string.rcv_group_event_group_deleted)
@@ -1486,13 +1563,48 @@ sealed class RcvGroupEvent() {
@Serializable
sealed class SndGroupEvent() {
@Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): SndGroupEvent()
@Serializable @SerialName("userRole") class UserRole(val role: GroupMemberRole): SndGroupEvent()
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
@Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent()
val text: String get() = when (this) {
is MemberRole -> String.format(generalGetString(R.string.member_role), profile.profileViewName, role.text)
is UserRole -> String.format(generalGetString(R.string.your_member_role), role.text)
is MemberDeleted -> String.format(generalGetString(R.string.snd_group_event_member_deleted), profile.profileViewName)
is UserLeft -> generalGetString(R.string.snd_group_event_user_left)
is GroupUpdated -> generalGetString(R.string.snd_group_event_group_profile_updated)
}
}
sealed class ChatItemTTL: Comparable<ChatItemTTL?> {
object Day: ChatItemTTL()
object Week: ChatItemTTL()
object Month: ChatItemTTL()
data class Seconds(val secs: Long): ChatItemTTL()
object None: ChatItemTTL()
override fun compareTo(other: ChatItemTTL?): Int = (seconds ?: Long.MAX_VALUE).compareTo(other?.seconds ?: Long.MAX_VALUE)
val seconds: Long?
get() =
when (this) {
is None -> null
is Day -> 86400L
is Week -> 7 * 86400L
is Month -> 30 * 86400L
is Seconds -> secs
}
companion object {
fun fromSeconds(seconds: Long?): ChatItemTTL =
when (seconds) {
null -> None
86400L -> Day
7 * 86400L -> Week
30 * 86400L -> Month
else -> Seconds(seconds)
}
}
}
@@ -10,8 +10,9 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.helpers.base64ToBitmap
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.views.chatlist.acceptContactRequest
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.NotificationPreviewMode
import kotlinx.datetime.Clock
class NtfManager(val context: Context, private val appPreferences: AppPreferences) {
@@ -26,6 +27,8 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
const val CallNotificationId: Int = -1
private const val ChatIdKey: String = "chatId"
}
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -33,13 +36,17 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
private val msgNtfTimeoutMs = 30000L
init {
manager.createNotificationChannel(NotificationChannel(MessageChannel, "SimpleX Chat messages", NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, "SimpleX Chat calls (lock screen)", NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(callNotificationChannel())
}
enum class NotificationAction {
ACCEPT_CONTACT_REQUEST
}
private fun callNotificationChannel(): NotificationChannel {
val callChannel = NotificationChannel(CallChannel, "SimpleX Chat calls", NotificationManager.IMPORTANCE_HIGH)
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
val attrs = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
@@ -63,30 +70,69 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
}
}
fun notifyContactRequestReceived(cInfo: ChatInfo.ContactRequest) {
notifyMessageReceived(
chatId = cInfo.id,
displayName = cInfo.displayName,
msgText = generalGetString(R.string.notification_new_contact_request),
image = cInfo.image,
listOf(NotificationAction.ACCEPT_CONTACT_REQUEST)
)
}
fun notifyContactConnected(contact: Contact) {
notifyMessageReceived(
chatId = contact.id,
displayName = contact.displayName,
msgText = generalGetString(R.string.notification_contact_connected)
)
}
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
if (!cInfo.ntfsEnabled) return
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
}
fun notifyMessageReceived(chatId: String, displayName: String, msgText: String) {
fun notifyMessageReceived(chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
Log.d(TAG, "notifyMessageReceived $chatId")
val now = Clock.System.now().toEpochMilliseconds()
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
prevNtfTime[chatId] = now
val notification = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(displayName)
.setContentText(msgText)
val previewMode = appPreferences.notificationPreviewMode.get()
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(R.string.notification_preview_somebody) else displayName
val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(R.string.notification_preview_new_message) else msgText
val largeIcon = when {
actions.isEmpty() -> null
image == null || previewMode == NotificationPreviewMode.HIDDEN.name -> BitmapFactory.decodeResource(context.resources, R.drawable.icon)
else -> base64ToBitmap(image)
}
val builder = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(title)
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setSmallIcon(R.drawable.ntf_icon)
.setLargeIcon(largeIcon)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setVibrate(if (actions.isEmpty()) null else longArrayOf(0, 250, 250, 250))
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
.setSilent(recentNotification)
.build()
.setSilent(if (actions.isEmpty()) recentNotification else false)
for (action in actions) {
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val actionIntent = Intent(SimplexApp.context, NtfActionReceiver::class.java)
actionIntent.action = action.name
actionIntent.putExtra(ChatIdKey, chatId)
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
val actionButton = when (action) {
NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(R.string.accept)
}
builder.addAction(0, actionButton, actionPendingIntent)
}
val summary = NotificationCompat.Builder(context, MessageChannel)
.setSmallIcon(R.drawable.ntf_icon)
@@ -99,7 +145,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
with(NotificationManagerCompat.from(context)) {
// using cInfo.id only shows one notification per chat and updates it when the message arrives
notify(chatId.hashCode(), notification)
notify(chatId.hashCode(), builder.build())
notify(0, summary)
}
}
@@ -132,13 +178,23 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
}
)
val previewMode = appPreferences.notificationPreviewMode.get()
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
generalGetString(R.string.notification_preview_somebody)
else
invitation.contact.displayName
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
BitmapFactory.decodeResource(context.resources, R.drawable.icon)
else
base64ToBitmap(image)
ntfBuilder = ntfBuilder
.setContentTitle(invitation.contact.displayName)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setSmallIcon(R.drawable.ntf_icon)
.setLargeIcon(if (image == null) BitmapFactory.decodeResource(context.resources, R.drawable.icon) else base64ToBitmap(image))
.setLargeIcon(largeIcon)
.setColor(0x88FFFF)
.setAutoCancel(true)
with(NotificationManagerCompat.from(context)) {
@@ -173,10 +229,31 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
var intent = Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.setAction(intentAction)
if (chatId != null) intent = intent.putExtra("chatId", chatId)
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
}
}
/**
* Processes every action specified by [NotificationCompat.Builder.addAction] that comes with [NotificationAction]
* and [ChatInfo.id] as [ChatIdKey] in extra
* */
class NtfActionReceiver: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
val cInfo = SimplexApp.context.chatModel.getChat(chatId)?.chatInfo
when (intent.action) {
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
if (cInfo !is ChatInfo.ContactRequest) return
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
}
else -> {
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
}
}
}
}
}
@@ -6,7 +6,6 @@ import android.app.ActivityManager.RunningAppProcessInfo
import android.app.Application
import android.content.*
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
@@ -25,7 +24,10 @@ import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ConnectViaLinkTab
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.NotificationPreviewMode
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@@ -61,7 +63,12 @@ enum class CallOnLockScreen {
class AppPreferences(val context: Context) {
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
// deprecated, remove in 2024
private val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
val notificationsMode = mkStrPreference(SHARED_PREFS_NOTIFICATIONS_MODE,
if (!runServiceInBackground.get()) NotificationsMode.OFF.name else NotificationsMode.default.name
)
val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name)
val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false)
val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
@@ -80,6 +87,7 @@ class AppPreferences(val context: Context) {
)
val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false)
val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false)
val webrtcIceServers = mkStrPreference(SHARED_PREFS_WEBRTC_ICE_SERVERS, null)
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
@@ -98,6 +106,13 @@ class AppPreferences(val context: Context) {
val networkTCPKeepIntvl = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_INTVL, KeepAliveOpts.defaults.keepIntvl)
val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt)
val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false)
val connectViaLinkTab = mkStrPreference(SHARED_PREFS_CONNECT_VIA_LINK_TAB, ConnectViaLinkTab.SCAN.name)
val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true)
val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false)
val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null)
val initializationVectorDBPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE, null)
val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true)
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb())
@@ -134,25 +149,34 @@ class AppPreferences(val context: Context) {
set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply()
)
private fun mkDatePreference(prefName: String, default: Instant?): Preference<Instant?> =
/**
* Provide `[commit] = true` to save preferences right now, not after some unknown period of time.
* So in case of a crash this value will be saved 100%
* */
private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): Preference<Instant?> =
Preference(
get = {
val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString())
pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) }
},
set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).apply()
set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).let {
if (commit) it.commit() else it.apply()
}
)
companion object {
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
private const val SHARED_PREFS_NOTIFICATIONS_MODE = "NotificationsMode"
private const val SHARED_PREFS_NOTIFICATION_PREVIEW_MODE = "NotificationPreviewMode"
private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown"
private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown"
private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay"
private const val SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN = "CallsOnLockScreen"
private const val SHARED_PREFS_PERFORM_LA = "PerformLA"
private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown"
private const val SHARED_PREFS_WEBRTC_ICE_SERVERS = "WebrtcICEServers"
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
@@ -171,6 +195,12 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_NETWORK_TCP_KEEP_INTVL = "NetworkTCPKeepIntvl"
private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt"
private const val SHARED_PREFS_INCOGNITO = "Incognito"
private const val SHARED_PREFS_CONNECT_VIA_LINK_TAB = "ConnectViaLinkTab"
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase"
private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase"
private const val SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE = "InitializationVectorDBPassphrase"
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme"
private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor"
}
@@ -178,12 +208,17 @@ class AppPreferences(val context: Context) {
private const val MESSAGE_TIMEOUT: Int = 15_000_000
open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) {
open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) {
val chatModel = ChatModel(this)
private var receiverStarted = false
var lastMsgReceivedTimestamp: Long = System.currentTimeMillis()
private set
init {
chatModel.runServiceInBackground.value = appPrefs.runServiceInBackground.get()
chatModel.notificationsMode.value =
kotlin.runCatching { NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!) }.getOrDefault(NotificationsMode.default)
chatModel.notificationPreviewMode.value =
kotlin.runCatching { NotificationPreviewMode.valueOf(appPrefs.notificationPreviewMode.get()!!) }.getOrDefault(NotificationPreviewMode.default)
chatModel.performLA.value = appPrefs.performLA.get()
chatModel.incognito.value = appPrefs.incognito.get()
}
@@ -199,6 +234,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
apiSetIncognito(chatModel.incognito.value)
chatModel.userAddress.value = apiGetUserAddress()
chatModel.userSMPServers.value = getUserSMPServers()
chatModel.chatItemTTL.value = getChatItemTTL()
val chats = apiGetChats()
chatModel.updateChats(chats)
chatModel.currentUser.value = user
@@ -228,10 +264,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
suspend fun sendCmd(cmd: CC): CR {
val ctrl = ctrl ?: throw Exception("Controller is not initialized")
return withContext(Dispatchers.IO) {
val c = cmd.cmdString
if (cmd !is CC.ApiParseMarkdown) {
chatModel.terminalItems.add(TerminalItem.cmd(cmd))
chatModel.terminalItems.add(TerminalItem.cmd(cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
}
val json = chatSendCmd(ctrl, c)
@@ -247,7 +285,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
private suspend fun recvMsg(): CR? {
private suspend fun recvMsg(ctrl: ChatCtrl): CR? {
return withContext(Dispatchers.IO) {
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
if (json == "") {
@@ -262,7 +300,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
private suspend fun recvMspLoop() {
val msg = recvMsg()
val msg = recvMsg(ctrl ?: return)
if (msg != null) processReceivedMsg(msg)
recvMspLoop()
}
@@ -283,7 +321,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
suspend fun apiStartChat(): Boolean {
val r = sendCmd(CC.StartChat())
val r = sendCmd(CC.StartChat(expire = true))
when (r) {
is CR.ChatStarted -> return true
is CR.ChatRunning -> return false
@@ -329,10 +367,17 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
throw Error("failed to delete storage: ${r.responseType} ${r.details}")
}
private suspend fun apiGetChats(): List<Chat> {
suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): CR.ChatCmdError? {
val r = sendCmd(CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey)))
if (r is CR.CmdOk) return null
else if (r is CR.ChatCmdError) return r
throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}")
}
suspend fun apiGetChats(): List<Chat> {
val r = sendCmd(CC.ApiGetChats())
if (r is CR.ApiChats ) return r.chats
throw Error("failed getting the list of chats: ${r.responseType} ${r.details}")
throw Exception("failed getting the list of chats: ${r.responseType} ${r.details}")
}
suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
@@ -345,9 +390,15 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent): AChatItem? {
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc)
val r = sendCmd(cmd)
if (r is CR.NewChatItem ) return r.chatItem
Log.e(TAG, "apiSendMessage bad response: ${r.responseType} ${r.details}")
return null
return when (r) {
is CR.NewChatItem -> r.chatItem
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiSendMessage", generalGetString(R.string.error_sending_message), r)
}
null
}
}
}
suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? {
@@ -386,6 +437,18 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
suspend fun getChatItemTTL(): ChatItemTTL {
val r = sendCmd(CC.APIGetChatItemTTL())
if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL)
throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}")
}
suspend fun setChatItemTTL(chatItemTTL: ChatItemTTL) {
val r = sendCmd(CC.APISetChatItemTTL(chatItemTTL.seconds))
if (r is CR.CmdOk) return
throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}")
}
suspend fun apiGetNetworkConfig(): NetCfg? {
val r = sendCmd(CC.APIGetNetworkConfig())
if (r is CR.NetworkConfig) return r.networkConfig
@@ -435,9 +498,15 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
suspend fun apiAddContact(): String? {
val r = sendCmd(CC.AddContact())
if (r is CR.Invitation) return r.connReqInvitation
Log.e(TAG, "apiAddContact bad response: ${r.responseType} ${r.details}")
return null
return when (r) {
is CR.Invitation -> r.connReqInvitation
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiAddContact", generalGetString(R.string.connection_error), r)
}
null
}
}
}
suspend fun apiConnect(connReq: String): Boolean {
@@ -469,7 +538,9 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return false
}
else -> {
apiErrorAlert("apiConnect", "Connection error", r)
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiConnect", generalGetString(R.string.connection_error), r)
}
return false
}
}
@@ -486,7 +557,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.cannot_delete_contact),
String.format(generalGetString(R.string.contact_cannot_be_deleted_as_they_are_in_groups), e.errorType.contact.displayName, e.errorType.groupNames)
String.format(generalGetString(R.string.contact_cannot_be_deleted_as_they_are_in_groups), e.errorType.contact.displayName, e.errorType.groupNames.joinToString(", "))
)
}
}
@@ -539,11 +610,24 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return null
}
suspend fun apiSetConnectionAlias(connId: Long, localAlias: String): PendingContactConnection? {
val r = sendCmd(CC.ApiSetConnectionAlias(connId, localAlias))
if (r is CR.ConnectionAliasUpdated) return r.toConnection
Log.e(TAG, "apiSetConnectionAlias bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiCreateUserAddress(): String? {
val r = sendCmd(CC.CreateMyAddress())
if (r is CR.UserContactLinkCreated) return r.connReqContact
Log.e(TAG, "apiCreateUserAddress bad response: ${r.responseType} ${r.details}")
return null
return when (r) {
is CR.UserContactLinkCreated -> r.connReqContact
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiCreateUserAddress", generalGetString(R.string.error_creating_address), r)
}
null
}
}
}
suspend fun apiDeleteUserAddress(): Boolean {
@@ -566,9 +650,24 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
suspend fun apiAcceptContactRequest(contactReqId: Long): Contact? {
val r = sendCmd(CC.ApiAcceptContact(contactReqId))
if (r is CR.AcceptingContactRequest) return r.contact
Log.e(TAG, "apiAcceptContactRequest bad response: ${r.responseType} ${r.details}")
return null
return when {
r is CR.AcceptingContactRequest -> r.contact
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
&& r.chatError.agentError is AgentErrorType.SMP
&& r.chatError.agentError.smpErr is SMPErrorType.AUTH -> {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.connection_error_auth),
generalGetString(R.string.sender_may_have_deleted_the_connection_request)
)
null
}
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiAcceptContactRequest", generalGetString(R.string.error_accepting_contact_request), r)
}
null
}
}
}
suspend fun apiRejectContactRequest(contactReqId: Long): Boolean {
@@ -626,9 +725,22 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
suspend fun apiReceiveFile(fileId: Long): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId))
if (r is CR.RcvFileAccepted) return r.chatItem
Log.e(TAG, "apiReceiveFile bad response: ${r.responseType} ${r.details}")
return null
return when (r) {
is CR.RcvFileAccepted -> r.chatItem
is CR.RcvFileAcceptedSndCancelled -> {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.cannot_receive_file),
generalGetString(R.string.sender_cancelled_file_transfer)
)
null
}
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiReceiveFile", generalGetString(R.string.error_receiving_file), r)
}
null
}
}
}
suspend fun apiNewGroup(p: GroupProfile): GroupInfo? {
@@ -640,9 +752,15 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? {
val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole))
if (r is CR.SentGroupInvitation) return r.member
Log.e(TAG, "apiAddMember bad response: ${r.responseType} ${r.details}")
return null
return when (r) {
is CR.SentGroupInvitation -> r.member
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiAddMember", generalGetString(R.string.error_adding_members), r)
}
null
}
}
}
suspend fun apiJoinGroup(groupId: Long) {
@@ -659,20 +777,35 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
} else if (e is ChatError.ChatErrorStore && e.storeError is StoreError.GroupNotFound) {
deleteGroup()
AlertManager.shared.showAlertMsg(generalGetString(R.string.alert_title_no_group), generalGetString(R.string.alert_message_no_group))
} else {
AlertManager.shared.showAlertMsg(generalGetString(R.string.alert_title_join_group_error), "$e")
} else if (!(networkErrorAlert(r))) {
apiErrorAlert("apiJoinGroup", generalGetString(R.string.error_joining_group), r)
}
}
else -> Log.e(TAG, "apiJoinGroup bad response: ${r.responseType} ${r.details}")
else -> apiErrorAlert("apiJoinGroup", generalGetString(R.string.error_joining_group), r)
}
}
suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? {
val r = sendCmd(CC.ApiRemoveMember(groupId, memberId))
if (r is CR.UserDeletedMember) return r.member
Log.e(TAG, "apiRemoveMember bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? =
when (val r = sendCmd(CC.ApiRemoveMember(groupId, memberId))) {
is CR.UserDeletedMember -> r.member
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiRemoveMember", generalGetString(R.string.error_removing_member), r)
}
null
}
}
suspend fun apiMemberRole(groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember =
when (val r = sendCmd(CC.ApiMemberRole(groupId, memberId, memberRole))) {
is CR.MemberRoleUser -> r.member
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiMemberRole", generalGetString(R.string.error_changing_role), r)
}
throw Exception("failed to change member role: ${r.responseType} ${r.details}")
}
}
suspend fun apiLeaveGroup(groupId: Long): GroupInfo? {
val r = sendCmd(CC.ApiLeaveGroup(groupId))
@@ -706,6 +839,30 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
private fun networkErrorAlert(r: CR): Boolean {
return when {
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
&& r.chatError.agentError is AgentErrorType.BROKER
&& r.chatError.agentError.brokerErr is BrokerErrorType.TIMEOUT -> {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.connection_timeout),
generalGetString(R.string.network_error_desc)
)
true
}
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
&& r.chatError.agentError is AgentErrorType.BROKER
&& r.chatError.agentError.brokerErr is BrokerErrorType.NETWORK -> {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.connection_error),
generalGetString(R.string.network_error_desc)
)
true
}
else -> false
}
}
fun apiErrorAlert(method: String, title: String, r: CR) {
val errMsg = "${r.responseType}: ${r.details}"
Log.e(TAG, "$method bad response: $errMsg")
@@ -713,6 +870,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
fun processReceivedMsg(r: CR) {
lastMsgReceivedTimestamp = System.currentTimeMillis()
chatModel.terminalItems.add(TerminalItem.resp(r))
when (r) {
is CR.NewContactConnection -> {
@@ -723,19 +881,21 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
is CR.ContactConnected -> {
chatModel.updateContact(r.contact)
chatModel.dismissConnReqView(r.contact.activeConn.id)
chatModel.removeChat(r.contact.activeConn.id)
chatModel.updateNetworkStatus(r.contact.id, Chat.NetworkStatus.Connected())
// NtfManager.shared.notifyContactConnected(contact)
ntfManager.notifyContactConnected(r.contact)
}
is CR.ContactConnecting -> {
chatModel.updateContact(r.contact)
chatModel.dismissConnReqView(r.contact.activeConn.id)
chatModel.removeChat(r.contact.activeConn.id)
}
is CR.ReceivedContactRequest -> {
val contactRequest = r.contactRequest
val cInfo = ChatInfo.ContactRequest(contactRequest)
chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
// NtfManager.shared.notifyContactRequest(contactRequest)
ntfManager.notifyContactRequestReceived(cInfo)
}
is CR.ContactUpdated -> {
val cInfo = ChatInfo.Direct(r.toContact)
@@ -762,10 +922,10 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
val cItem = r.chatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
val file = cItem.file
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE && appPrefs.privacyAcceptImages.get()) {
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) }
}
if (!cItem.chatDir.sent && !cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
@@ -804,6 +964,10 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
chatModel.upsertGroupMember(r.groupInfo, r.deletedMember)
is CR.LeftMember ->
chatModel.upsertGroupMember(r.groupInfo, r.member)
is CR.MemberRole ->
chatModel.upsertGroupMember(r.groupInfo, r.member)
is CR.MemberRoleUser ->
chatModel.upsertGroupMember(r.groupInfo, r.member)
is CR.GroupDeleted -> // TODO update user member
chatModel.updateGroup(r.groupInfo)
is CR.UserJoinedGroup ->
@@ -841,7 +1005,16 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey)
val useRelay = chatModel.controller.appPrefs.webrtcPolicyRelay.get()
chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey, relay = useRelay)
val iceServers = getIceServers()
Log.d(TAG, ".callOffer iceServers $iceServers")
chatModel.callCommand.value = WCallCommand.Offer(
offer = r.offer.rtcSession,
iceCandidates = r.offer.rtcIceCandidates,
media = r.callType.media,
aesKey = r.sharedKey,
iceServers = iceServers,
relay = useRelay
)
}
}
is CR.CallAnswer -> {
@@ -927,56 +1100,66 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
fun showBackgroundServiceNoticeIfNeeded() {
val mode = NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!)
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
if (!appPrefs.backgroundServiceNoticeShown.get()) {
// the branch for the new users who have never seen service notice
if (isIgnoringBatteryOptimizations(appContext)) {
showBGServiceNotice()
if (!mode.requiresIgnoringBattery || isIgnoringBatteryOptimizations(appContext)) {
showBGServiceNotice(mode)
} else {
showBGServiceNoticeIgnoreOptimization()
showBGServiceNoticeIgnoreOptimization(mode)
}
// set both flags, so that if the user doesn't allow ignoring optimizations, the service will be disabled without additional notice
appPrefs.backgroundServiceNoticeShown.set(true)
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
} else if (!isIgnoringBatteryOptimizations(appContext) && appPrefs.runServiceInBackground.get()) {
} else if (mode.requiresIgnoringBattery && !isIgnoringBatteryOptimizations(appContext)) {
// the branch for users who have app installed, and have seen the service notice,
// but the battery optimization for the app is on (Android 12) AND the service is running
if (appPrefs.backgroundServiceBatteryNoticeShown.get()) {
// users have been presented with battery notice before - they did not allow ignoring optimizations -> disable service
showDisablingServiceNotice()
appPrefs.runServiceInBackground.set(false)
chatModel.runServiceInBackground.value = false
showDisablingServiceNotice(mode)
appPrefs.notificationsMode.set(NotificationsMode.OFF.name)
chatModel.notificationsMode.value = NotificationsMode.OFF
SimplexService.StartReceiver.toggleReceiver(false)
MessagesFetcherWorker.cancelAll()
SimplexService.stop(SimplexApp.context)
} else {
// show battery optimization notice
showBGServiceNoticeIgnoreOptimization()
showBGServiceNoticeIgnoreOptimization(mode)
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
}
} else {
// service is allowed and battery optimization is disabled
// service or periodic mode was chosen and battery optimization is disabled
SimplexApp.context.schedulePeriodicServiceRestartWorker()
SimplexApp.context.schedulePeriodicWakeUp()
}
}
private fun showBGServiceNotice() = AlertManager.shared.showAlert {
private fun showBGServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert {
AlertDialog(
onDismissRequest = AlertManager.shared::hideAlert,
title = {
Row {
Icon(
Icons.Outlined.Bolt,
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
contentDescription =
if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications),
)
Text(
if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications),
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold)
}
},
text = {
Column {
Text(
annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery),
if (mode == NotificationsMode.SERVICE) annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(R.string.periodic_notifications_desc),
Modifier.padding(bottom = 8.dp)
)
Text(annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown))
Text(
annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown)
)
}
},
confirmButton = {
@@ -985,7 +1168,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
)
}
private fun showBGServiceNoticeIgnoreOptimization() = AlertManager.shared.showAlert {
private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode) = AlertManager.shared.showAlert {
val ignoreOptimization = {
AlertManager.shared.hideAlert()
askAboutIgnoringBatteryOptimization(appContext)
@@ -996,15 +1179,19 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
Row {
Icon(
Icons.Outlined.Bolt,
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
contentDescription =
if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications),
)
Text(
if (mode == NotificationsMode.SERVICE) stringResource(R.string.service_notifications) else stringResource(R.string.periodic_notifications),
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold)
}
},
text = {
Column {
Text(
annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery),
if (mode == NotificationsMode.SERVICE) annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(R.string.periodic_notifications_desc),
Modifier.padding(bottom = 8.dp)
)
Text(annotatedStringResource(R.string.turn_off_battery_optimization))
@@ -1016,22 +1203,26 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
)
}
private fun showDisablingServiceNotice() = AlertManager.shared.showAlert {
private fun showDisablingServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert {
AlertDialog(
onDismissRequest = AlertManager.shared::hideAlert,
title = {
Row {
Icon(
Icons.Outlined.Bolt,
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
contentDescription =
if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications),
)
Text(
if (mode == NotificationsMode.SERVICE) stringResource(R.string.service_notifications_disabled) else stringResource(R.string.periodic_notifications_disabled),
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.private_instant_notifications_disabled), fontWeight = FontWeight.Bold)
}
},
text = {
Column {
Text(
annotatedStringResource(R.string.turning_off_background_service),
annotatedStringResource(R.string.turning_off_service_and_periodic),
Modifier.padding(bottom = 8.dp)
)
}
@@ -1086,14 +1277,11 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
val powerManager = context.getSystemService(Application.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(context.packageName)
}
private fun askAboutIgnoringBatteryOptimization(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
Intent().apply {
@SuppressLint("BatteryLife")
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
@@ -1157,13 +1345,14 @@ sealed class CC {
class Console(val cmd: String): CC()
class ShowActiveUser: CC()
class CreateActiveUser(val profile: Profile): CC()
class StartChat: CC()
class StartChat(val expire: Boolean): CC()
class ApiStopChat: CC()
class SetFilesFolder(val filesFolder: String): CC()
class SetIncognito(val incognito: Boolean): CC()
class ApiExportArchive(val config: ArchiveConfig): CC()
class ApiImportArchive(val config: ArchiveConfig): CC()
class ApiDeleteStorage: CC()
class ApiStorageEncryption(val config: DBEncryptionConfig): CC()
class ApiGetChats: CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
@@ -1172,13 +1361,15 @@ sealed class CC {
class NewGroup(val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
// class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC()
class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC()
class ApiRemoveMember(val groupId: Long, val memberId: Long): CC()
class ApiLeaveGroup(val groupId: Long): CC()
class ApiListMembers(val groupId: Long): CC()
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
class GetUserSMPServers: CC()
class SetUserSMPServers(val smpServers: List<String>): CC()
class APISetChatItemTTL(val seconds: Long?): CC()
class APIGetChatItemTTL: CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
class APIGetNetworkConfig: CC()
class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC()
@@ -1192,6 +1383,7 @@ sealed class CC {
class ApiUpdateProfile(val profile: Profile): CC()
class ApiParseMarkdown(val text: String): CC()
class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC()
class CreateMyAddress: CC()
class DeleteMyAddress: CC()
class ShowMyAddress: CC()
@@ -1211,13 +1403,14 @@ sealed class CC {
is Console -> cmd
is ShowActiveUser -> "/u"
is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}"
is StartChat -> "/_start"
is StartChat -> "/_start subscribe=on expire=${onOff(expire)}"
is ApiStopChat -> "/_stop"
is SetFilesFolder -> "/_files_folder $filesFolder"
is SetIncognito -> "/incognito ${if (incognito) "on" else "off"}"
is SetIncognito -> "/incognito ${onOff(incognito)}"
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
is ApiDeleteStorage -> "/_db delete"
is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}"
is ApiGetChats -> "/_get chats pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
@@ -1226,12 +1419,15 @@ sealed class CC {
is NewGroup -> "/_group ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}"
is ApiRemoveMember -> "/_remove #$groupId $memberId"
is ApiLeaveGroup -> "/_leave #$groupId"
is ApiListMembers -> "/_members #$groupId"
is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
is GetUserSMPServers -> "/smp_servers"
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is APISetChatItemTTL -> "/_ttl ${chatItemTTLStr(seconds)}"
is APIGetChatItemTTL -> "/ttl"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
is APIGetNetworkConfig -> "/network"
is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
@@ -1245,6 +1441,7 @@ sealed class CC {
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
is ApiParseMarkdown -> "/_parse $text"
is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}"
is CreateMyAddress -> "/address"
is DeleteMyAddress -> "/delete_address"
is ShowMyAddress -> "/show_address"
@@ -1272,6 +1469,7 @@ sealed class CC {
is ApiExportArchive -> "apiExportArchive"
is ApiImportArchive -> "apiImportArchive"
is ApiDeleteStorage -> "apiDeleteStorage"
is ApiStorageEncryption -> "apiStorageEncryption"
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiSendMessage -> "apiSendMessage"
@@ -1280,12 +1478,15 @@ sealed class CC {
is NewGroup -> "newGroup"
is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup"
is ApiMemberRole -> "apiMemberRole"
is ApiRemoveMember -> "apiRemoveMember"
is ApiLeaveGroup -> "apiLeaveGroup"
is ApiListMembers -> "apiListMembers"
is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
is GetUserSMPServers -> "getUserSMPServers"
is SetUserSMPServers -> "setUserSMPServers"
is APISetChatItemTTL -> "apiSetChatItemTTL"
is APIGetChatItemTTL -> "apiGetChatItemTTL"
is APISetNetworkConfig -> "/apiSetNetworkConfig"
is APIGetNetworkConfig -> "/apiGetNetworkConfig"
is APISetChatSettings -> "/apiSetChatSettings"
@@ -1299,6 +1500,7 @@ sealed class CC {
is ApiUpdateProfile -> "updateProfile"
is ApiParseMarkdown -> "apiParseMarkdown"
is ApiSetContactAlias -> "apiSetContactAlias"
is ApiSetConnectionAlias -> "apiSetConnectionAlias"
is CreateMyAddress -> "createMyAddress"
is DeleteMyAddress -> "deleteMyAddress"
is ShowMyAddress -> "showMyAddress"
@@ -1317,6 +1519,21 @@ sealed class CC {
class ItemRange(val from: Long, val to: Long)
fun chatItemTTLStr(seconds: Long?): String {
if (seconds == null) return "none"
return seconds.toString()
}
val obfuscated: CC
get() = when (this) {
is ApiStorageEncryption -> ApiStorageEncryption(DBEncryptionConfig(obfuscate(config.currentKey), obfuscate(config.newKey)))
else -> this
}
private fun obfuscate(s: String): String = if (s.isEmpty()) "" else "***"
private fun onOff(b: Boolean): String = if (b) "on" else "off"
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
@@ -1348,6 +1565,9 @@ class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgCon
@Serializable
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
@Serializable
class DBEncryptionConfig(val currentKey: String, val newKey: String)
@Serializable
data class NetCfg(
val socksProxy: String? = null,
@@ -1464,6 +1684,7 @@ sealed class CR {
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): CR()
@Serializable @SerialName("chatItemTTL") class ChatItemTTL(val chatItemTTL: Long? = null): CR()
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
@Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR()
@Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR()
@@ -1476,6 +1697,7 @@ sealed class CR {
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR()
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR()
@Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
@Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR()
@Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR()
@@ -1509,6 +1731,8 @@ sealed class CR {
@Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val groupInfo: GroupInfo): CR()
@Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR()
@Serializable @SerialName("memberRole") class MemberRole(val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
@Serializable @SerialName("memberRoleUser") class MemberRoleUser(val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
@Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("deletedMember") class DeletedMember(val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR()
@Serializable @SerialName("leftMember") class LeftMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
@@ -1522,6 +1746,7 @@ sealed class CR {
@Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR()
// receiving file events
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR()
// sending file events
@@ -1551,6 +1776,7 @@ sealed class CR {
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
is UserSMPServers -> "userSMPServers"
is ChatItemTTL -> "chatItemTTL"
is NetworkConfig -> "networkConfig"
is ContactInfo -> "contactInfo"
is GroupMemberInfo -> "groupMemberInfo"
@@ -1563,6 +1789,7 @@ sealed class CR {
is UserProfileNoChange -> "userProfileNoChange"
is UserProfileUpdated -> "userProfileUpdated"
is ContactAliasUpdated -> "contactAliasUpdated"
is ConnectionAliasUpdated -> "connectionAliasUpdated"
is ParsedMarkdown -> "apiParsedMarkdown"
is UserContactLink -> "userContactLink"
is UserContactLinkCreated -> "userContactLinkCreated"
@@ -1595,6 +1822,8 @@ sealed class CR {
is ReceivedGroupInvitation -> "receivedGroupInvitation"
is GroupDeletedUser -> "groupDeletedUser"
is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting"
is MemberRole -> "memberRole"
is MemberRoleUser -> "memberRoleUser"
is DeletedMemberUser -> "deletedMemberUser"
is DeletedMember -> "deletedMember"
is LeftMember -> "leftMember"
@@ -1606,6 +1835,7 @@ sealed class CR {
is ConnectedToGroupMember -> "connectedToGroupMember"
is GroupRemoved -> "groupRemoved"
is GroupUpdated -> "groupUpdated"
is RcvFileAcceptedSndCancelled -> "rcvFileAcceptedSndCancelled"
is RcvFileAccepted -> "rcvFileAccepted"
is RcvFileStart -> "rcvFileStart"
is RcvFileComplete -> "rcvFileComplete"
@@ -1636,6 +1866,7 @@ sealed class CR {
is ApiChats -> json.encodeToString(chats)
is ApiChat -> json.encodeToString(chat)
is UserSMPServers -> json.encodeToString(smpServers)
is ChatItemTTL -> json.encodeToString(chatItemTTL)
is NetworkConfig -> json.encodeToString(networkConfig)
is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}"
is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}"
@@ -1648,6 +1879,7 @@ sealed class CR {
is UserProfileNoChange -> noDetails()
is UserProfileUpdated -> json.encodeToString(toProfile)
is ContactAliasUpdated -> json.encodeToString(toContact)
is ConnectionAliasUpdated -> json.encodeToString(toConnection)
is ParsedMarkdown -> json.encodeToString(formattedText)
is UserContactLink -> connReqContact
is UserContactLinkCreated -> connReqContact
@@ -1680,6 +1912,8 @@ sealed class CR {
is ReceivedGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole"
is GroupDeletedUser -> json.encodeToString(groupInfo)
is JoinedGroupMemberConnecting -> "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member"
is MemberRole -> "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole"
is MemberRoleUser -> "groupInfo: $groupInfo\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole"
is DeletedMemberUser -> "groupInfo: $groupInfo\nmember: $member"
is DeletedMember -> "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember"
is LeftMember -> "groupInfo: $groupInfo\nmember: $member"
@@ -1691,6 +1925,7 @@ sealed class CR {
is ConnectedToGroupMember -> "groupInfo: $groupInfo\nmember: $member"
is GroupRemoved -> json.encodeToString(groupInfo)
is GroupUpdated -> json.encodeToString(toGroup)
is RcvFileAcceptedSndCancelled -> noDetails()
is RcvFileAccepted -> json.encodeToString(chatItem)
is RcvFileStart -> json.encodeToString(chatItem)
is RcvFileComplete -> json.encodeToString(chatItem)
@@ -1752,22 +1987,26 @@ sealed class ChatError {
is ChatErrorChat -> "chat ${errorType.string}"
is ChatErrorAgent -> "agent ${agentError.string}"
is ChatErrorStore -> "store ${storeError.string}"
is ChatErrorDatabase -> "database ${databaseError.string}"
}
@Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError()
@Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError()
@Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError()
@Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError()
}
@Serializable
sealed class ChatErrorType {
val string: String get() = when (this) {
is NoActiveUser -> "noActiveUser"
is InvalidConnReq -> "invalidConnReq"
is ContactGroups -> "groupNames $groupNames"
is NoActiveUser -> "noActiveUser"
is СommandError -> "commandError $message"
}
@Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType()
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
@Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List<String>): ChatErrorType()
@Serializable @SerialName("commandError") class СommandError(val message: String): ChatErrorType()
}
@Serializable
@@ -1780,6 +2019,28 @@ sealed class StoreError {
@Serializable @SerialName("groupNotFound") class GroupNotFound: StoreError()
}
@Serializable
sealed class DatabaseError {
val string: String get() = when (this) {
is ErrorEncrypted -> "errorEncrypted"
is ErrorPlaintext -> "errorPlaintext"
is ErrorNoFile -> "errorPlaintext"
is ErrorExport -> "errorNoFile"
is ErrorOpen -> "errorExport"
}
@Serializable @SerialName("errorEncrypted") object ErrorEncrypted: DatabaseError()
@Serializable @SerialName("errorPlaintext") object ErrorPlaintext: DatabaseError()
@Serializable @SerialName("errorNoFile") class ErrorNoFile(val dbFile: String): DatabaseError()
@Serializable @SerialName("errorExport") class ErrorExport(val sqliteError: SQLiteError): DatabaseError()
@Serializable @SerialName("errorOpen") class ErrorOpen(val sqliteError: SQLiteError): DatabaseError()
}
@Serializable
sealed class SQLiteError {
@Serializable @SerialName("errorNotADatabase") object ErrorNotADatabase: SQLiteError()
@Serializable @SerialName("error") class Error(val error: String): SQLiteError()
}
@Serializable
sealed class AgentErrorType {
val string: String get() = when (this) {
@@ -24,5 +24,6 @@ val GroupDark = Color(80, 80, 80, 60)
val IncomingCallLight = Color(239, 237, 236, 255)
val IncomingCallDark = Color(34, 30, 29, 255)
val WarningOrange = Color(255, 127, 0, 255)
val WarningYellow = Color(255, 192, 0, 255)
val FileLight = Color(183, 190, 199, 255)
val FileDark = Color(101, 101, 106, 255)
@@ -1,15 +1,23 @@
package chat.simplex.app.ui.theme
import android.app.UiModeManager
import android.content.Context
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import chat.simplex.app.SimplexApp
import kotlinx.coroutines.flow.MutableStateFlow
enum class DefaultTheme {
SYSTEM, DARK, LIGHT
}
val DEFAULT_PADDING = 16.dp
val DEFAULT_SPACE_AFTER_ICON = 4.dp
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
val DarkColorPalette = darkColors(
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
primaryVariant = SimplexGreen,
@@ -35,7 +43,11 @@ val LightColorPalette = lightColors(
// onSurface = Color.Black,
)
val CurrentColors: MutableStateFlow<Pair<Colors, DefaultTheme>> = MutableStateFlow(ThemeManager.currentColors(true))
val CurrentColors: MutableStateFlow<Pair<Colors, DefaultTheme>> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
// Non-@Composable implementation
private fun isInNightMode() =
(SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
@Composable
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.first.isLight
@@ -1,5 +1,6 @@
package chat.simplex.app.views
import android.content.Context
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
@@ -7,42 +8,101 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@Composable
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
BackHandler(onBack = close)
TerminalLayout(
chatModel.terminalItems,
composeState,
sendCommand = {
withApi {
// show "in progress"
chatModel.controller.sendCmd(CC.Console(composeState.value.message))
composeState.value = ComposeState(useLinkPreviews = false)
// hide "in progress"
val authorized = remember { mutableStateOf(!chatModel.controller.appPrefs.performLA.get()) }
val context = LocalContext.current
LaunchedEffect(authorized.value) {
if (!authorized.value) {
runAuth(authorized = authorized, context)
}
}
if (authorized.value) {
TerminalLayout(
chatModel.terminalItems,
composeState,
sendCommand = { sendCommand(chatModel, composeState) },
close
)
} else {
Surface(Modifier.fillMaxSize()) {
Column(Modifier.background(MaterialTheme.colors.background)) {
CloseSheetBar(close)
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(R.string.auth_unlock),
icon = Icons.Outlined.Lock,
click = {
runAuth(authorized = authorized, context)
}
)
}
}
},
close
}
}
}
private fun runAuth(authorized: MutableState<Boolean>, context: Context) {
authenticate(
generalGetString(R.string.auth_open_chat_console),
generalGetString(R.string.auth_log_in_using_credential),
context as FragmentActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success, LAResult.Unavailable -> authorized.value = true
is LAResult.Error, LAResult.Failed -> authorized.value = false
}
}
)
}
private fun sendCommand(chatModel: ChatModel, composeState: MutableState<ComposeState>) {
val developerTools = chatModel.controller.appPrefs.developerTools.get()
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
val s = composeState.value.message
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
val resp = CR.ChatCmdError(ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
chatModel.terminalItems.add(TerminalItem.cmd(CC.Console(s)))
chatModel.terminalItems.add(TerminalItem.resp(resp))
composeState.value = ComposeState(useLinkPreviews = false)
} else {
withApi {
// show "in progress"
chatModel.controller.sendCmd(CC.Console(s))
composeState.value = ComposeState(useLinkPreviews = false)
// hide "in progress"
}
}
}
@Composable
fun TerminalLayout(
terminalItems: List<TerminalItem>,
@@ -82,35 +142,24 @@ fun TerminalLayout(
@Composable
fun TerminalLog(terminalItems: List<TerminalItem>) {
val listState = rememberLazyListState()
val keyboardState by getKeyboardState()
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
mutableStateOf(CIListState(false, terminalItems.count(), keyboardState))
}
val scope = rememberCoroutineScope()
LazyColumn(state = listState) {
items(terminalItems) { item ->
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
LazyColumn(state = listState, reverseLayout = true) {
items(reversedTerminalItems) { item ->
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()
.clickable {
ModalManager.shared.showModal {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(item.details)
}
}
}
}.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
val len = terminalItems.count()
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
scope.launch {
ciListState.value = CIListState(true, len, keyboardState)
listState.animateScrollToItem(len - 1)
}
}
}
}
@@ -1,7 +1,6 @@
package chat.simplex.app.views
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
@@ -26,12 +25,13 @@ import chat.simplex.app.R
import chat.simplex.app.SimplexService
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.AppBarTitle
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.onboarding.ReadableText
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.delay
fun isValidDisplayName(name: String) : Boolean {
return (name.firstOrNull { it.isWhitespace() }) == null
@@ -45,13 +45,9 @@ fun CreateProfilePanel(chatModel: ChatModel) {
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
Column(
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
) {
Text(
stringResource(R.string.create_profile),
style = MaterialTheme.typography.h4,
modifier = Modifier.padding(vertical = 5.dp)
)
AppBarTitle(stringResource(R.string.create_profile), false)
ReadableText(R.string.your_profile_is_stored_on_your_device)
ReadableText(R.string.profile_is_only_shared_with_your_contacts)
Spacer(Modifier.height(10.dp))
@@ -102,6 +98,7 @@ fun CreateProfilePanel(chatModel: ChatModel) {
}
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
}
@@ -51,7 +51,14 @@ class CallManager(val chatModel: ChatModel) {
)
showCallView.value = true
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
callCommand.value = WCallCommand.Start (media = invitation.callType.media, aesKey = invitation.sharedKey, relay = useRelay)
val iceServers = getIceServers()
Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
callCommand.value = WCallCommand.Start(
media = invitation.callType.media,
aesKey = invitation.sharedKey,
iceServers = iceServers,
relay = useRelay
)
callInvitations.remove(invitation.contact.id)
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
activeCallInvitation.value = null
@@ -1,8 +1,13 @@
package chat.simplex.app.views.call
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.util.Log
import android.view.ViewGroup
import android.webkit.*
@@ -29,24 +34,43 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.usersettings.NotificationsMode
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@SuppressLint("SourceLockedOrientationActivity")
@Composable
fun ActiveCallView(chatModel: ChatModel) {
BackHandler(onBack = {
val call = chatModel.activeCall.value
if (call != null) withApi { chatModel.callManager.endCall(call) }
})
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
LaunchedEffect(Unit) {
// Start service when call happening since it's not already started.
// It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off
if (!ntfModeService) SimplexService.start(SimplexApp.context)
}
DisposableEffect(Unit) {
onDispose {
// Stop it when call ended
if (!ntfModeService) SimplexService.stop(SimplexApp.context)
// Clear selected communication device to default value after we changed it in call
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.clearCommunicationDevice()
}
}
}
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
Box(Modifier.fillMaxSize()) {
@@ -122,6 +146,17 @@ fun ActiveCallView(chatModel: ChatModel) {
val call = chatModel.activeCall.value
if (call != null) ActiveCallOverlay(call, chatModel)
}
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
// Lock orientation to portrait in order to have good experience with calls
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
onDispose {
// Unlock orientation
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}
@Composable
@@ -150,9 +185,19 @@ private fun setCallSound(cxt: Context, call: Call) {
if (call.soundSpeaker) {
am.mode = AudioManager.MODE_NORMAL
am.isSpeakerphoneOn = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }?.let {
am.setCommunicationDevice(it)
}
}
} else {
am.mode = AudioManager.MODE_IN_CALL
am.isSpeakerphoneOn = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE }?.let {
am.setCommunicationDevice(it)
}
}
}
}
@@ -337,6 +382,8 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
val wv = webView.value
if (wv != null) processCommand(wv, WCallCommand.End)
lifecycleOwner.lifecycle.removeObserver(observer)
webView.value?.destroy()
webView.value = null
}
}
LaunchedEffect(callCommand.value, webView.value) {
@@ -46,15 +46,18 @@ fun IncomingCallAlertLayout(
acceptCall: () -> Unit
) {
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
Column(Modifier.fillMaxWidth().background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
IncomingCallInfo(invitation)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
Spacer(Modifier.fillMaxWidth().weight(1f))
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
}
Row(verticalAlignment = Alignment.CenterVertically) {
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
}
}
}
}
@@ -1,21 +1,33 @@
package chat.simplex.app.views.call
import android.content.Context
import android.media.MediaPlayer
import android.media.*
import android.net.Uri
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.core.content.ContextCompat
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.views.helpers.withScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
class SoundPlayer {
var player: MediaPlayer? = null
private var player: MediaPlayer? = null
var playing = false
fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) {
if (sound) player = MediaPlayer.create(cxt, R.raw.ring_once)
player?.reset()
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build()
)
setDataSource(SimplexApp.context, Uri.parse("android.resource://" + SimplexApp.context.packageName + "/" + R.raw.ring_once))
prepare()
}
val vibrator = ContextCompat.getSystemService(cxt, Vibrator::class.java)
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
playing = true
@@ -3,11 +3,13 @@ package chat.simplex.app.views.call
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.Contact
import chat.simplex.app.views.helpers.generalGetString
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.net.URI
data class Call(
val contact: Contact,
@@ -115,7 +117,7 @@ sealed class WCallResponse {
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
@Serializable class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
@Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type
@Serializable
@@ -153,4 +155,48 @@ class ConnectionState(
val iceConnectionState: String,
val iceGatheringState: String,
val signalingState: String
)
)
// the servers are expected in this format:
// stun:stun.simplex.im:443
// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443
fun parseRTCIceServer(str: String): RTCIceServer? {
var s = replaceScheme(str, "stun:")
s = replaceScheme(s, "turn:")
val u = runCatching { URI(s) }.getOrNull()
if (u != null) {
val scheme = u.scheme
val host = u.host
val port = u.port
if (u.path == "" && (scheme == "stun" || scheme == "turn")) {
val userInfo = u.userInfo?.split(":")
return RTCIceServer(
urls = listOf("$scheme:$host:$port"),
username = userInfo?.getOrNull(0),
credential = userInfo?.getOrNull(1)
)
}
}
return null
}
private fun replaceScheme(s: String, scheme: String): String = if (s.startsWith(scheme)) s.replace(scheme, "$scheme//") else s
fun parseRTCIceServers(servers: List<String>): List<RTCIceServer>? {
val iceServers: ArrayList<RTCIceServer> = ArrayList()
for (s in servers) {
val server = parseRTCIceServer(s)
if (server != null) {
iceServers.add(server)
} else {
return null
}
}
return if (iceServers.isEmpty()) null else iceServers
}
fun getIceServers(): List<RTCIceServer>? {
val value = SimplexApp.context.chatController.appPrefs.webrtcIceServers.get() ?: return null
val servers: List<String> = value.split("\n")
return parseRTCIceServers(servers)
}
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -34,6 +35,9 @@ import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@Composable
fun ChatInfoView(
@@ -61,42 +65,10 @@ fun ChatInfoView(
},
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
changeNtfsState = { enabled ->
changeNtfsState(enabled, chat, chatModel)
},
)
}
}
fun changeNtfsState(enabled: Boolean, chat: Chat, chatModel: ChatModel) {
val newChatInfo = when(chat.chatInfo) {
is ChatInfo.Direct -> with (chat.chatInfo) {
ChatInfo.Direct(contact.copy(chatSettings = contact.chatSettings.copy(enableNtfs = enabled)))
}
is ChatInfo.Group -> with(chat.chatInfo) {
ChatInfo.Group(groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(enableNtfs = enabled)))
}
else -> null
}
withApi {
val res = when (newChatInfo) {
is ChatInfo.Direct -> with(newChatInfo) {
chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings)
}
is ChatInfo.Group -> with(newChatInfo) {
chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings)
}
else -> false
}
if (res && newChatInfo != null) {
chatModel.updateChatInfo(newChatInfo)
if (!enabled) {
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
}
}
}
}
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_contact_question),
@@ -145,7 +117,6 @@ fun ChatInfoLayout(
onLocalAliasChanged: (String) -> Unit,
deleteContact: () -> Unit,
clearChat: () -> Unit,
changeNtfsState: (Boolean) -> Unit,
) {
Column(
Modifier
@@ -173,7 +144,11 @@ fun ChatInfoLayout(
if (connStats != null) {
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SectionItemView {
SectionItemView({
AlertManager.shared.showAlertMsg(
generalGetString(R.string.network_status),
chat.serverInfo.networkStatus.statusExplanation
)}) {
NetworkStatusRow(chat.serverInfo.networkStatus)
}
val rcvServers = connStats.rcvServers
@@ -189,26 +164,10 @@ fun ChatInfoLayout(
}
SectionSpacer()
}
var ntfsEnabled by remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
SectionView(title = stringResource(R.string.settings_section_title_settings)) {
SectionItemView {
NtfsSwitch(ntfsEnabled) {
ntfsEnabled = !ntfsEnabled
changeNtfsState(ntfsEnabled)
}
}
}
SectionSpacer()
SectionView {
SectionItemView {
ClearChatButton(clearChat)
}
ClearChatButton(clearChat)
SectionDivider()
SectionItemView {
DeleteContactButton(deleteContact)
}
DeleteContactButton(deleteContact)
}
SectionSpacer()
@@ -249,41 +208,56 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
}
@Composable
private fun LocalAliasEditor(initialValue: String, updateValue: (String) -> Unit) {
var value by remember { mutableStateOf(initialValue) }
DefaultBasicTextField(
Modifier.fillMaxWidth().padding(horizontal = 10.dp),
initialValue,
{
Text(
generalGetString(R.string.text_field_set_contact_placeholder),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = HighOrLowlight
)
},
color = HighOrLowlight,
textStyle = TextStyle.Default.copy(textAlign = TextAlign.Center),
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
) {
value = it
fun LocalAliasEditor(
initialValue: String,
center: Boolean = true,
leadingIcon: Boolean = false,
focus: Boolean = false,
updateValue: (String) -> Unit
) {
var value by rememberSaveable { mutableStateOf(initialValue) }
val modifier = if (center)
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp)
else
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).fillMaxWidth()
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (center) Arrangement.Center else Arrangement.Start) {
DefaultBasicTextField(
modifier,
value,
{
Text(
generalGetString(R.string.text_field_set_contact_placeholder),
textAlign = if (center) TextAlign.Center else TextAlign.Start,
color = HighOrLowlight
)
},
leadingIcon = if (leadingIcon) {{ Icon(Icons.Default.Edit, null, Modifier.padding(start = 7.dp)) }} else null,
color = HighOrLowlight,
focus = focus,
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
) {
value = it
}
}
LaunchedEffect(Unit) {
snapshotFlow { value }
.onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing
.conflate() // get the latest value
.filter { it == value } // don't process old ones
.collect {
updateValue(value)
}
}
DisposableEffect(Unit) {
onDispose { updateValue(value) }
onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
}
}
@Composable
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
Row(
Modifier
.fillMaxSize()
.clickable {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.network_status),
networkStatus.statusExplanation
)
},
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -338,71 +312,25 @@ fun SimplexServers(text: String, servers: List<String>) {
}
@Composable
fun NtfsSwitch(
ntfsEnabled: Boolean,
toggleNtfs: (Boolean) -> Unit
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Outlined.Notifications,
stringResource(R.string.notifications),
tint = HighOrLowlight
)
Text(stringResource(R.string.notifications))
}
Switch(
checked = ntfsEnabled,
onCheckedChange = toggleNtfs,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
fun ClearChatButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Restore,
stringResource(R.string.clear_chat_button),
click = onClick,
textColor = WarningOrange,
iconColor = WarningOrange,
)
}
@Composable
fun ClearChatButton(clearChat: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { clearChat() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Restore,
stringResource(R.string.clear_chat_button),
tint = WarningOrange
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.clear_chat_button), color = WarningOrange)
}
}
@Composable
fun DeleteContactButton(deleteContact: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { deleteContact() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.button_delete_contact),
tint = Color.Red
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_delete_contact), color = Color.Red)
}
fun DeleteContactButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Delete,
stringResource(R.string.button_delete_contact),
click = onClick,
textColor = Color.Red,
iconColor = Color.Red,
)
}
private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel, onChatUpdated: (Chat) -> Unit) = withApi {
@@ -424,7 +352,6 @@ fun PreviewChatInfoLayout() {
),
Contact.sampleData,
localAlias = "",
changeNtfsState = {},
developerTools = false,
connStats = null,
onLocalAliasChanged = {},
@@ -1,6 +1,8 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
@@ -28,13 +30,14 @@ import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.chat.group.*
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.chatlist.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.helpers.AppBarHeight
@@ -43,15 +46,20 @@ import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
import java.io.File
import kotlin.math.sign
import kotlin.math.roundToInt
@Composable
fun ChatView(chatModel: ChatModel) {
var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
val searchText = remember { mutableStateOf("") }
val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) }
val attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }
val composeState = rememberSaveable(saver = ComposeState.saver()) {
mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews))
}
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
@@ -76,7 +84,6 @@ fun ChatView(chatModel: ChatModel) {
} else {
val chat = activeChat!!
BackHandler { chatModel.chatId.value = null }
// We need to have real unreadCount value for displaying it inside top right button
// Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember {
@@ -111,25 +118,15 @@ fun ChatView(chatModel: ChatModel) {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) {
activeChat = it
}
ModalManager.shared.showModalCloseable(true) { close ->
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) {
activeChat = it
}
}
} else if (cInfo is ChatInfo.Group) {
setGroupMembers(cInfo.groupInfo, chatModel)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupChatInfoView(chatModel, close)
}
ModalManager.shared.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, close)
}
}
}
@@ -137,13 +134,8 @@ fun ChatView(chatModel: ChatModel) {
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
}
ModalManager.shared.showModalCloseable(true) { close ->
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
}
}
},
@@ -193,13 +185,8 @@ fun ChatView(chatModel: ChatModel) {
addMembers = { groupInfo ->
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, chatModel, close)
}
}
}
},
@@ -214,6 +201,7 @@ fun ChatView(chatModel: ChatModel) {
)
}
},
changeNtfsState = { enabled, currentValue -> changeNtfsStatePerChat(enabled, currentValue, chat, chatModel) },
onSearchValueChanged = { value ->
if (searchText.value == value) return@ChatLayout
val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout
@@ -251,6 +239,7 @@ fun ChatLayout(
acceptCall: (Contact) -> Unit,
addMembers: (GroupInfo) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
) {
Surface(
@@ -277,7 +266,7 @@ fun ChatLayout(
}
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, onSearchValueChanged) },
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, changeNtfsState, onSearchValueChanged) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
@@ -302,10 +291,12 @@ fun ChatInfoToolbar(
info: () -> Unit,
startCall: (CallMediaType) -> Unit,
addMembers: (GroupInfo) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
) {
var showMenu by remember { mutableStateOf(false) }
var showSearch by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var showMenu by rememberSaveable { mutableStateOf(false) }
var showSearch by rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch) {
back()
@@ -349,6 +340,22 @@ fun ChatInfoToolbar(
}
}
}
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
menuItems.add {
ItemAction(
if (ntfsEnabled.value) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
if (ntfsEnabled.value) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
onClick = {
showMenu = false
// Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu
scope.launch {
delay(200)
changeNtfsState(!ntfsEnabled.value, ntfsEnabled)
}
}
)
}
barButtons.add {
IconButton({ showMenu = true }) {
Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
@@ -440,7 +447,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
var shouldAutoScroll by rememberSaveable { mutableStateOf(true) }
@@ -469,8 +475,14 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
val scrollToItem: (Long) -> Unit = { itemId: Long ->
val index = reversedChatItems.indexOfFirst { it.id == itemId }
if (index != -1) {
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
}
}
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems) { i, cItem ->
CompositionLocalProvider(
@@ -498,7 +510,16 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
}
val provider = {
providerForGallery(i, chatItems, cItem.id) { indexInReversed ->
scope.launch {
listState.scrollToItem(
kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1),
-maxHeightRounded
)
}
}
}
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
@@ -524,11 +545,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
} else { // direct message
@@ -539,14 +560,14 @@ fun BoxWithConstraintsScope.ChatItemsList(
end = if (sent) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
if (cItem.isRcvNew) {
LaunchedEffect(cItem.id) {
scope.launch {
delay(750)
delay(600)
markRead(CC.ItemRange(cItem.id, cItem.id), null)
}
}
@@ -568,10 +589,9 @@ fun BoxWithConstraintsScope.FloatingButtons(
listState: LazyListState
) {
val scope = rememberCoroutineScope()
var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) }
var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) }
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
@@ -591,18 +611,15 @@ fun BoxWithConstraintsScope.FloatingButtons(
lastIndexOfVisibleItems = it
}
}
val bottomUnreadCount by remember {
derivedStateOf {
if (unreadCount.value == 0) return@derivedStateOf 0
val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
if (chatItems.size <= from || from < 0) return@derivedStateOf 0
chatItems.subList(from, chatItems.size).count { it.isRcvNew }
}
}
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
@@ -767,6 +784,69 @@ private fun bottomEndFloatingButton(
}
}
private fun providerForGallery(
listStateIndex: Int,
chatItems: List<ChatItem>,
cItemId: Long,
scrollTo: (Int) -> Unit
): ImageGalleryProvider {
fun canShowImage(item: ChatItem): Boolean =
item.content.msgContent is MsgContent.MCImage && item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null
fun item(skipInternalIndex: Int, initialChatId: Long): Pair<Int, ChatItem>? {
var processedInternalIndex = -skipInternalIndex.sign
val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId }
for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) {
val item = chatItems[chatItemsIndex]
if (canShowImage(item)) {
processedInternalIndex += skipInternalIndex.sign
}
if (processedInternalIndex == skipInternalIndex) {
return chatItemsIndex to item
}
}
return null
}
var initialIndex = Int.MAX_VALUE / 2
var initialChatId = cItemId
return object: ImageGalleryProvider {
override val initialIndex: Int = initialIndex
override val totalImagesSize = mutableStateOf(Int.MAX_VALUE)
override fun getImage(index: Int): Pair<Bitmap, Uri>? {
val internalIndex = initialIndex - index
val file = item(internalIndex, initialChatId)?.second?.file
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
val filePath = getLoadedFilePath(SimplexApp.context, file)
return if (imageBitmap != null && filePath != null) {
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
imageBitmap to uri
} else null
}
override fun currentPageChanged(index: Int) {
val internalIndex = initialIndex - index
val item = item(internalIndex, initialChatId) ?: return
initialIndex = index
initialChatId = item.second.id
}
override fun scrollToStart() {
initialIndex = 0
initialChatId = chatItems.first { canShowImage(it) }.id
}
override fun onDismiss(index: Int) {
val internalIndex = initialIndex - index
val indexInChatItems = item(internalIndex, initialChatId)?.first ?: return
val indexInReversed = chatItems.lastIndex - indexInChatItems
// Do not scroll to active item, just to different items
if (indexInReversed == listStateIndex) return
scrollTo(indexInReversed)
}
}
}
private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
override val longPressTimeoutMillis
get() =
@@ -828,7 +908,7 @@ fun PreviewChatLayout() {
chatModelIncognito = false,
back = {},
info = {},
showMemberInfo = {_, _ -> },
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
@@ -837,6 +917,7 @@ fun PreviewChatLayout() {
acceptCall = { _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
)
}
@@ -885,7 +966,7 @@ fun PreviewGroupChatLayout() {
chatModelIncognito = false,
back = {},
info = {},
showMemberInfo = {_, _ -> },
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
@@ -894,6 +975,7 @@ fun PreviewGroupChatLayout() {
acceptCall = { _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
)
}
@@ -1,45 +1,52 @@
package chat.simplex.app.views.chat
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.DEFAULT_PADDING_HALF
import chat.simplex.app.views.chat.item.SentColorLight
import chat.simplex.app.views.helpers.base64ToBitmap
@Composable
fun ComposeImageView(image: String, cancelImage: () -> Unit, cancelEnabled: Boolean) {
fun ComposeImageView(images: List<String>, cancelImages: () -> Unit, cancelEnabled: Boolean) {
Row(
Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
val imageBitmap = base64ToBitmap(image).asImageBitmap()
Image(
imageBitmap,
"preview image",
modifier = Modifier
.width(80.dp)
.height(60.dp)
.padding(end = 8.dp)
)
Spacer(Modifier.weight(1f))
LazyRow(
Modifier.weight(1f).padding(start = DEFAULT_PADDING_HALF, end = if (cancelEnabled) 0.dp else DEFAULT_PADDING_HALF),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF),
) {
items(images.size) { index ->
val imageBitmap = base64ToBitmap(images[index]).asImageBitmap()
Image(
imageBitmap,
"preview image",
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
)
}
}
if (cancelEnabled) {
IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) {
IconButton(onClick = cancelImages) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
@@ -1,14 +1,13 @@
package chat.simplex.app.views.chat
import ComposeFileView
import ComposeImageView
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.*
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.graphics.drawable.AnimatedImageDrawable
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
@@ -26,6 +25,7 @@ import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Reply
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -42,21 +42,26 @@ import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import java.io.File
@Serializable
sealed class ComposePreview {
object NoPreview: ComposePreview()
class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
class ImagePreview(val image: String): ComposePreview()
class FilePreview(val fileName: String): ComposePreview()
@Serializable object NoPreview: ComposePreview()
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
@Serializable class FilePreview(val fileName: String): ComposePreview()
}
@Serializable
sealed class ComposeContextItem {
object NoContextItem: ComposeContextItem()
class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
class EditingItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable object NoContextItem: ComposeContextItem()
@Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
}
@Serializable
data class ComposeState(
val message: String = "",
val preview: ComposePreview = ComposePreview.NoPreview,
@@ -99,13 +104,22 @@ data class ComposeState(
is ComposePreview.CLinkPreview -> preview.linkPreview
else -> null
}
companion object {
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
save = { json.encodeToString(serializer(), it.value) },
restore = {
mutableStateOf(json.decodeFromString(it))
}
)
}
}
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
return when (val mc = chatItem.content.msgContent) {
is MsgContent.MCText -> ComposePreview.NoPreview
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image)
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
is MsgContent.MCFile -> {
val fileName = chatItem.file?.fileName ?: ""
ComposePreview.FilePreview(fileName)
@@ -131,7 +145,7 @@ fun ComposeView(
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
// attachments
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val chosenContent = remember { mutableStateOf<List<UploadContent>>(emptyList()) }
val chosenFile = remember { mutableStateOf<Uri?>(null) }
val photoUri = remember { mutableStateOf<Uri?>(null) }
val photoTmpFile = remember { mutableStateOf<File?>(null) }
@@ -168,9 +182,9 @@ fun ComposeView(
val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? ->
if (bitmap != null) {
chosenImage.value = bitmap
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
chosenContent.value = listOf(UploadContent.SimpleImage(bitmap))
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview)))
}
}
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
@@ -180,16 +194,39 @@ fun ComposeView(
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val processPickedImage = { uris: List<Uri> ->
val content = ArrayList<UploadContent>()
val imagesPreview = ArrayList<String>()
uris.forEach { uri ->
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
chosenImage.value = bitmap
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
val drawable = ImageDecoder.decodeDrawable(source)
var bitmap: Bitmap? = ImageDecoder.decodeBitmap(source)
if (drawable is AnimatedImageDrawable) {
// It's a gif or webp
val fileSize = getFileSize(context, uri)
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
content.add(UploadContent.AnimatedImage(uri))
} else {
bitmap = null
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
)
}
} else {
if (bitmap != null) content.add(UploadContent.SimpleImage(bitmap))
}
if (bitmap != null) {
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
}
}
if (imagesPreview.isNotEmpty()) {
chosenContent.value = content
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagesPreview))
}
}
val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
val processPickedFile = { uri: Uri? ->
if (uri != null) {
val fileSize = getFileSize(context, uri)
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
@@ -206,6 +243,9 @@ fun ComposeView(
}
}
}
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery(), processPickedImage)
val galleryLauncherFallback = rememberGetMultipleContentsLauncher(processPickedImage)
val filesLauncher = rememberGetContentLauncher(processPickedFile)
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
@@ -221,7 +261,11 @@ fun ComposeView(
attachmentOption.value = null
}
AttachmentOption.PickImage -> {
galleryLauncher.launch("image/*")
try {
galleryLauncher.launch(0)
} catch (e: ActivityNotFoundException) {
galleryLauncherFallback.launch("image/*")
}
attachmentOption.value = null
}
AttachmentOption.PickFile -> {
@@ -306,7 +350,7 @@ fun ComposeView(
fun clearState() {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
textStyle.value = smallFont
chosenImage.value = null
chosenContent.value = emptyList()
chosenFile.value = null
linkUrl.value = null
prevLinkUrl.value = null
@@ -336,26 +380,30 @@ fun ComposeView(
}
}
else -> {
var mc: MsgContent? = null
var file: String? = null
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<String> = ArrayList()
when (val preview = cs.preview) {
ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.ImagePreview -> {
val chosenImageVal = chosenImage.value
if (chosenImageVal != null) {
file = saveImage(context, chosenImageVal)
chosenContent.value.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(context, it.bitmap)
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
}
if (file != null) {
mc = MsgContent.MCImage(cs.message, preview.image)
files.add(file)
msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index]))
}
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
file = saveFileFromUri(context, chosenFileVal)
val file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
mc = MsgContent.MCFile(cs.message)
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else ""))
}
}
}
@@ -364,17 +412,19 @@ fun ComposeView(
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
else -> null
}
if (mc != null) {
if (msgs.isNotEmpty()) {
withApi {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
file = file,
quotedItemId = quotedItemId,
mc = mc
)
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
msgs.forEachIndexed { index, content ->
if (index > 0) delay(100)
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
file = files.getOrNull(index),
quotedItemId = if (index == 0) quotedItemId else null,
mc = content
)
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
}
clearState()
}
} else {
@@ -406,9 +456,9 @@ fun ComposeView(
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
}
fun cancelImage() {
fun cancelImages() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenImage.value = null
chosenContent.value = emptyList()
}
fun cancelFile() {
@@ -422,8 +472,8 @@ fun ComposeView(
ComposePreview.NoPreview -> {}
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
is ComposePreview.ImagePreview -> ComposeImageView(
preview.image,
::cancelImage,
preview.images,
::cancelImages,
cancelEnabled = !composeState.value.editing
)
is ComposePreview.FilePreview -> ComposeFileView(
@@ -447,6 +497,16 @@ fun ComposeView(
}
}
LaunchedEffect(chatModel.sharedContent.value) {
when (val shared = chatModel.sharedContent.value) {
is SharedContent.Text -> onMessageChange(shared.text)
is SharedContent.Image -> processPickedImage(listOf(shared.uri))
is SharedContent.File -> processPickedFile(shared.uri)
null -> {}
}
chatModel.sharedContent.value = null
}
Column {
contextItemView()
when {
@@ -486,3 +546,29 @@ fun ComposeView(
}
}
}
class PickFromGallery: ActivityResultContract<Int, List<Uri>>() {
override fun createIntent(context: Context, input: Int) =
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
type = "image/*"
}
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
if (intent?.data != null)
listOf(intent.data!!)
else if (intent?.clipData != null)
with(intent.clipData!!) {
val uris = ArrayList<Uri>()
for (i in 0 until kotlin.math.min(itemCount, 10)) {
val uri = getItemAt(i).uri
if (uri != null) uris.add(uri)
}
if (itemCount > 10) {
AlertManager.shared.showAlertMsg(R.string.images_limit_title, R.string.images_limit_desc)
}
uris
}
else
emptyList()
}
@@ -52,7 +52,8 @@ fun ContextItemView(
)
MarkdownText(
contextItem.text, contextItem.formattedText,
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
modifier = Modifier.fillMaxWidth(),
)
}
IconButton(onClick = cancelContextItem) {
@@ -18,17 +18,21 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.text.BidiFormatter
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.coroutines.delay
import kotlin.math.min
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -48,7 +52,7 @@ fun SendMsgView(
delay(50)
keyboard?.show()
}
val isRtl = remember(cs.message) { BidiFormatter.getInstance().isRtl(cs.message.subSequence(0, min(50, cs.message.length))) }
BasicTextField(
value = cs.message,
onValueChange = onMessageChange,
@@ -69,14 +73,18 @@ fun SendMsgView(
Modifier.background(MaterialTheme.colors.background),
verticalAlignment = Alignment.Bottom
) {
Box(
Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.padding(top = 5.dp)
.padding(bottom = 7.dp)
CompositionLocalProvider(
LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
) {
innerTextField()
Box(
Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.padding(top = 5.dp)
.padding(bottom = 7.dp)
) {
innerTextField()
}
}
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
@@ -29,11 +29,12 @@ import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
@Composable
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
val selectedContacts = remember { mutableStateListOf<Long>() }
val selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) }
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
BackHandler(onBack = close)
AddGroupMembersLayout(
@@ -43,10 +44,12 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () ->
selectedRole = selectedRole,
inviteMembers = {
withApi {
selectedContacts.forEach {
val member = chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value)
for (contactId in selectedContacts) {
val member = chatModel.controller.apiAddMember(groupInfo.groupId, contactId, selectedRole.value)
if (member != null) {
chatModel.upsertGroupMember(groupInfo, member)
} else {
break
}
}
close.invoke()
@@ -89,6 +92,7 @@ fun AddGroupMembersLayout(
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.button_add_members))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
@@ -118,9 +122,7 @@ fun AddGroupMembersLayout(
RoleSelectionRow(groupInfo, selectedRole)
}
SectionDivider()
SectionItemView {
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty())
}
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty())
}
SectionCustomFooter {
InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection)
@@ -136,99 +138,49 @@ fun AddGroupMembersLayout(
}
@Composable
fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(R.string.new_member_role))
RoleDropdownMenu(groupInfo, selectedRole)
}
}
@Composable
fun RoleDropdownMenu(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
val options = GroupMemberRole.values()
.filter { it <= groupInfo.membership.memberRole }
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
Row(
Modifier.fillMaxWidth(0.7f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
selectedRole.value.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
Spacer(Modifier.size(4.dp))
Icon(
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
generalGetString(R.string.invite_to_group_button),
modifier = Modifier.padding(start = 8.dp),
tint = HighOrLowlight
)
}
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
options.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
selectedRole.value = selectionOption
expanded = false
}
) {
Text(
selectionOption.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
fun InviteMembersButton(inviteMembers: () -> Unit, disabled: Boolean) {
val modifier = if (disabled) Modifier else Modifier.clickable { inviteMembers() }
Row(
modifier.fillMaxSize(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
val color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Text(stringResource(R.string.invite_to_group_button), color = color)
Spacer(Modifier.size(8.dp))
Icon(
Icons.Outlined.Check,
stringResource(R.string.invite_to_group_button),
tint = color
val values = GroupMemberRole.values().filter { it <= groupInfo.membership.memberRole }.map { it to it.text }
ExposedDropDownSettingRow(
generalGetString(R.string.new_member_role),
values,
selectedRole,
icon = null,
enabled = remember { mutableStateOf(true) },
onSelected = { selectedRole.value = it }
)
}
}
@Composable
fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
SettingsActionItem(
Icons.Outlined.Check,
stringResource(R.string.invite_to_group_button),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
disabled = disabled,
)
}
@Composable
fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (selectedContactsCount >= 1) Arrangement.SpaceBetween else Arrangement.End,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (selectedContactsCount >= 1) {
Text(
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
color = HighOrLowlight,
fontSize = 12.sp
)
Box(
Modifier.clickable { clearSelection() }
) {
@@ -238,12 +190,6 @@ fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit)
fontSize = 12.sp
)
}
Text(
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
color = HighOrLowlight,
fontSize = 12.sp
)
} else {
Text(
stringResource(R.string.no_contacts_selected),
@@ -264,12 +210,10 @@ fun ContactList(
) {
Column {
contacts.forEachIndexed { index, contact ->
SectionItemView {
ContactCheckRow(
contact, groupInfo, addContact, removeContact,
checked = selectedContacts.contains(contact.apiId)
)
}
ContactCheckRow(
contact, groupInfo, addContact, removeContact,
checked = selectedContacts.contains(contact.apiId)
)
if (index < contacts.lastIndex) {
SectionDivider()
}
@@ -298,30 +242,21 @@ fun ContactCheckRow(
icon = Icons.Outlined.Circle
iconColor = HighOrLowlight
}
Row(
Modifier
.fillMaxSize()
.clickable {
if (prohibitedToInviteIncognito) {
showProhibitedToInviteIncognitoAlertDialog()
} else if (!checked)
addContact(contact.apiId)
else
removeContact(contact.apiId)
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
ProfileImage(size = 36.dp, contact.image)
Text(
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified
)
}
SectionItemView(click = {
if (prohibitedToInviteIncognito) {
showProhibitedToInviteIncognitoAlertDialog()
} else if (!checked)
addContact(contact.apiId)
else
removeContact(contact.apiId)
}) {
ProfileImage(size = 36.dp, contact.image)
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Text(
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Icon(
icon,
contentDescription = stringResource(R.string.icon_descr_contact_checked),
@@ -46,46 +46,36 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
addMembers = {
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AddGroupMembersView(groupInfo, chatModel, close)
}
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, chatModel, close)
}
}
},
showMemberInfo = { member ->
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
ModalManager.shared.showCustomModal { closeCurrent ->
ModalView(
close = closeCurrent, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
}
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
}
}
},
editGroupProfile = {
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
deleteGroup = { deleteGroupDialog(chat.chatInfo, chatModel, close) },
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
changeNtfsState = { enabled ->
changeNtfsState(enabled, chat, chatModel)
},
)
}
}
fun deleteGroupDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
val alertTextKey =
if (groupInfo.membership.memberCurrent) R.string.delete_group_for_all_members_cannot_undo_warning
else R.string.delete_group_for_self_cannot_undo_warning
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_group_question),
text = generalGetString(R.string.delete_group_for_all_members_cannot_undo_warning),
text = generalGetString(alertTextKey),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
@@ -127,7 +117,6 @@ fun GroupChatInfoLayout(
deleteGroup: () -> Unit,
clearChat: () -> Unit,
leaveGroup: () -> Unit,
changeNtfsState: (Boolean) -> Unit,
) {
Column(
Modifier
@@ -145,14 +134,14 @@ fun GroupChatInfoLayout(
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
SectionItemView {
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
SectionItemView(onAddMembersClick) {
val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary
val onClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
AddMembersButton(tint, onClick)
AddMembersButton(tint)
}
SectionDivider()
}
SectionItemView(height = 50.dp) {
SectionItemView(minHeight = 50.dp) {
MemberRow(groupInfo.membership, user = true)
}
if (members.isNotEmpty()) {
@@ -162,38 +151,19 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
var ntfsEnabled by remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
SectionView(title = stringResource(R.string.settings_section_title_settings)) {
SectionItemView {
NtfsSwitch(ntfsEnabled) {
ntfsEnabled = !ntfsEnabled
changeNtfsState(ntfsEnabled)
}
}
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView {
EditGroupProfileButton(editGroupProfile)
}
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
SectionDivider()
}
SectionItemView {
ClearChatButton(clearChat)
}
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
SectionDivider()
SectionItemView {
DeleteGroupButton(deleteGroup)
}
SectionItemView(deleteGroup) { DeleteGroupButton() }
}
if (groupInfo.membership.memberCurrent) {
SectionDivider()
SectionItemView {
LeaveGroupButton(leaveGroup)
}
SectionItemView(leaveGroup) { LeaveGroupButton() }
}
}
SectionSpacer()
@@ -234,11 +204,9 @@ fun GroupChatInfoHeader(cInfo: ChatInfo) {
}
@Composable
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, addMembers: () -> Unit) {
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
Row(
Modifier
.fillMaxSize()
.clickable { addMembers() },
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -255,8 +223,8 @@ fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, addMembers: ()
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
Column {
members.forEachIndexed { index, member ->
SectionItemView(height = 50.dp) {
MemberRow(member, showMemberInfo)
SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
MemberRow(member)
}
if (index < members.lastIndex) {
SectionDivider()
@@ -266,10 +234,9 @@ fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Uni
}
@Composable
fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = null, user: Boolean = false) {
val modifier = if (showMemberInfo != null) Modifier.clickable { showMemberInfo(member) } else Modifier
fun MemberRow(member: GroupMember, user: Boolean = false) {
Row(
modifier.fillMaxSize(),
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -279,8 +246,10 @@ fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = nu
) {
ProfileImage(size = 46.dp, member.image)
Column {
Text(member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified)
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
val s = member.memberStatus.shortText
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
Text(
@@ -300,11 +269,10 @@ fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = nu
}
@Composable
fun EditGroupProfileButton(editGroupProfile: () -> Unit) {
fun EditGroupProfileButton() {
Row(
Modifier
.fillMaxSize()
.clickable { editGroupProfile() },
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -318,11 +286,9 @@ fun EditGroupProfileButton(editGroupProfile: () -> Unit) {
}
@Composable
fun LeaveGroupButton(leaveGroup: () -> Unit) {
fun LeaveGroupButton() {
Row(
Modifier
.fillMaxSize()
.clickable { leaveGroup() },
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -336,11 +302,9 @@ fun LeaveGroupButton(leaveGroup: () -> Unit) {
}
@Composable
fun DeleteGroupButton(deleteGroup: () -> Unit) {
fun DeleteGroupButton() {
Row(
Modifier
.fillMaxSize()
.clickable { deleteGroup() },
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -367,7 +331,6 @@ fun PreviewGroupChatInfoLayout() {
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {},
changeNtfsState = {},
)
}
}
@@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -26,6 +26,7 @@ import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.SimplexServers
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
@Composable
fun GroupMemberInfoView(
@@ -40,10 +41,12 @@ fun GroupMemberInfoView(
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null) {
val newRole = remember { mutableStateOf(member.memberRole) }
GroupMemberInfoLayout(
groupInfo,
member,
connStats,
newRole,
developerTools,
openDirectChat = {
withApi {
@@ -61,7 +64,24 @@ fun GroupMemberInfoView(
closeAll()
}
},
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
onRoleSelected = {
if (it == newRole.value) return@GroupMemberInfoLayout
val prevValue = newRole.value
newRole.value = it
updateMemberRoleDialog(it, member, onDismiss = {
newRole.value = prevValue
}) {
withApi {
kotlin.runCatching {
val mem = chatModel.controller.apiMemberRole(groupInfo.groupId, member.groupMemberId, it)
chatModel.upsertGroupMember(groupInfo, mem)
}.onFailure {
newRole.value = prevValue
}
}
}
}
)
}
}
@@ -88,9 +108,11 @@ fun GroupMemberInfoLayout(
groupInfo: GroupInfo,
member: GroupMember,
connStats: ConnectionStats?,
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
openDirectChat: () -> Unit,
removeMember: () -> Unit,
onRoleSelected: (GroupMemberRole) -> Unit,
) {
Column(
Modifier
@@ -107,14 +129,21 @@ fun GroupMemberInfoLayout(
SectionSpacer()
SectionView {
SectionItemView {
OpenChatButton(openDirectChat)
}
OpenChatButton(openDirectChat)
}
SectionSpacer()
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
SectionDivider()
/*val roles = remember { member.canChangeRoleTo(groupInfo) }
if (roles != null) {
SectionItemView {
RoleSelectionRow(roles, newRole, onRoleSelected)
}
} else {*/
InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
//}
val conn = member.activeConn
if (conn != null) {
SectionDivider()
@@ -145,11 +174,9 @@ fun GroupMemberInfoLayout(
}
}
if (member.canBeRemoved(groupInfo.membership)) {
if (member.canBeRemoved(groupInfo)) {
SectionView {
SectionItemView {
RemoveMemberButton(removeMember)
}
RemoveMemberButton(removeMember)
}
SectionSpacer()
}
@@ -190,42 +217,69 @@ fun GroupMemberInfoHeader(member: GroupMember) {
}
@Composable
fun RemoveMemberButton(removeMember: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { removeMember() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.button_remove_member),
tint = Color.Red
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_remove_member), color = Color.Red)
}
fun RemoveMemberButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Delete,
stringResource(R.string.button_remove_member),
click = onClick,
textColor = Color.Red,
iconColor = Color.Red,
)
}
@Composable
fun OpenChatButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Message,
stringResource(R.string.button_send_direct_message),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
)
}
@Composable
private fun RoleSelectionRow(
roles: List<GroupMemberRole>,
selectedRole: MutableState<GroupMemberRole>,
onSelected: (GroupMemberRole) -> Unit
) {
Row(
Modifier
.fillMaxSize()
.clickable { onClick() },
verticalAlignment = Alignment.CenterVertically
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(
Icons.Outlined.Message,
stringResource(R.string.button_send_direct_message),
Modifier.padding(top = 5.dp),
tint = MaterialTheme.colors.primary
val values = remember { roles.map { it to it.text } }
ExposedDropDownSettingRow(
generalGetString(R.string.change_role),
values,
selectedRole,
icon = null,
enabled = remember { mutableStateOf(true) },
onSelected = onSelected
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_send_direct_message), color = MaterialTheme.colors.primary)
}
}
private fun updateMemberRoleDialog(
newRole: GroupMemberRole,
member: GroupMember,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.change_member_role_question),
text = if (member.memberCurrent)
String.format(generalGetString(R.string.member_role_will_be_changed_with_notification), newRole.text)
else
String.format(generalGetString(R.string.member_role_will_be_changed_with_invitation), newRole.text),
confirmText = generalGetString(R.string.change_verb),
onDismiss = onDismiss,
onConfirm = onConfirm,
onDismissRequest = onDismiss
)
}
@Preview
@Composable
fun PreviewGroupMemberInfoLayout() {
@@ -234,9 +288,11 @@ fun PreviewGroupMemberInfoLayout() {
groupInfo = GroupInfo.sampleData,
member = GroupMember.sampleData,
connStats = null,
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
openDirectChat = {},
removeMember = {}
removeMember = {},
onRoleSelected = {}
)
}
}
@@ -17,14 +17,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.ProfileNameField
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.isValidDisplayName
import chat.simplex.app.views.usersettings.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@@ -78,7 +78,7 @@ fun GroupProfileLayout(
Column(
Modifier
.verticalScroll(scrollState)
.padding(bottom = 16.dp),
.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start
) {
Text(
@@ -147,6 +147,7 @@ fun GroupProfileLayout(
}
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
}
@@ -205,6 +205,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
FramedItemView(ChatInfo.Direct.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
}
}
@@ -1,4 +1,5 @@
import android.graphics.Bitmap
import android.os.Build.VERSION.SDK_INT
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
@@ -6,6 +7,7 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.ArrowDownward
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
@@ -13,19 +15,32 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import chat.simplex.app.BuildConfig
import chat.simplex.app.R
import chat.simplex.app.model.CIFile
import chat.simplex.app.model.CIFileStatus
import chat.simplex.app.views.chat.item.ImageFullScreenView
import chat.simplex.app.views.chat.item.ImageGalleryProvider
import chat.simplex.app.views.helpers.*
import coil.ImageLoader
import coil.compose.rememberAsyncImagePainter
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import java.io.File
@Composable
fun CIImageView(
image: String,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
) {
@@ -65,6 +80,13 @@ fun CIImageView(
color = Color.White,
strokeWidth = 2.dp
)
CIFileStatus.RcvInvitation ->
Icon(
Icons.Outlined.ArrowDownward,
stringResource(R.string.icon_descr_asked_to_receive),
Modifier.fillMaxSize(),
tint = Color.White
)
else -> {}
}
}
@@ -88,13 +110,55 @@ fun CIImageView(
)
}
@Composable
fun imageView(painter: Painter, onClick: () -> Unit) {
Image(
painter,
contentDescription = stringResource(R.string.image_descr),
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
// if text is short and take all available width if text is long
modifier = Modifier
.width(1000.dp)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
),
contentScale = ContentScale.FillWidth,
)
}
fun fileSizeValid(): Boolean {
if (file != null) {
return file.fileSize <= MAX_FILE_SIZE
}
return false
}
Box(contentAlignment = Alignment.TopEnd) {
val context = LocalContext.current
val imageBitmap: Bitmap? = getLoadedImage(context, file)
if (imageBitmap != null) {
imageView(imageBitmap, onClick = {
val filePath = getLoadedFilePath(context, file)
if (imageBitmap != null && filePath != null) {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
val imageLoader = ImageLoader.Builder(context)
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
val imagePainter = rememberAsyncImagePainter(
ImageRequest.Builder(context).data(data = uri).size(coil.size.Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
imageLoader = imageLoader
)
imageView(imagePainter, onClick = {
if (getLoadedFilePath(context, file) != null) {
ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) }
ModalManager.shared.showCustomModal(animated = false) { close ->
ImageFullScreenView(imageProvider, close)
}
}
})
} else {
@@ -102,7 +166,14 @@ fun CIImageView(
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
receiveFile(file.fileId)
if (fileSizeValid()) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
)
}
CIFileStatus.RcvAccepted ->
AlertManager.shared.showAlertMsg(
generalGetString(R.string.waiting_for_image),
@@ -34,13 +34,15 @@ fun ChatItemView(
composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
chatModelIncognito: Boolean,
useLinkPreviews: Boolean,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit
acceptCall: (Contact) -> Unit,
scrollToItem: (Long) -> Unit,
) {
val context = LocalContext.current
val sent = cItem.chatDir.sent
@@ -63,7 +65,7 @@ fun ChatItemView(
EmojiItemView(cItem)
} else {
val onLinkLongClick = { _: String -> showMenu.value = true }
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick)
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
DropdownMenu(
expanded = showMenu.value,
@@ -218,7 +220,8 @@ fun PreviewChatItemView() {
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
acceptCall = { _ -> }
acceptCall = { _ -> },
scrollToItem = {},
)
}
}
@@ -238,7 +241,8 @@ fun PreviewChatItemViewDeletedContent() {
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
acceptCall = { _ -> }
acceptCall = { _ -> },
scrollToItem = {},
)
}
}
@@ -34,16 +34,22 @@ val ReceivedQuoteColorLight = Color(0x25B1B0B5)
@Composable
fun FramedItemView(
user: User,
chatInfo: ChatInfo,
ci: ChatItem,
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
onLinkLongClick: (link: String) -> Unit = {}
onLinkLongClick: (link: String) -> Unit = {},
scrollToItem: (Long) -> Unit = {},
) {
val sent = ci.chatDir.sent
fun membership(): GroupMember? {
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
}
@Composable
fun ciQuotedMsgView(qi: CIQuote) {
Box(
@@ -51,7 +57,7 @@ fun FramedItemView(
contentAlignment = Alignment.TopStart
) {
MarkdownText(
qi.text, qi.formattedText, sender = qi.sender(user), senderBold = true, maxLines = 3,
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
)
}
@@ -63,6 +69,7 @@ fun FramedItemView(
Modifier
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
.fillMaxWidth()
.clickable { scrollToItem(qi.itemId?: return@clickable) }
) {
when (qi.content) {
is MsgContent.MCImage -> {
@@ -122,7 +129,7 @@ fun FramedItemView(
Column(Modifier.fillMaxWidth()) {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, showMenu, receiveFile)
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@Box, showMenu, receiveFile)
if (mc.text == "") {
metaColor = Color.White
} else {
@@ -177,7 +184,7 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
),
@@ -193,7 +200,7 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
),
@@ -209,7 +216,7 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
@@ -229,7 +236,7 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(),
Clock.System.now(),
@@ -250,7 +257,7 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(),
Clock.System.now(),
@@ -278,7 +285,7 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(),
Clock.System.now(),
@@ -306,7 +313,7 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(),
Clock.System.now(),
@@ -333,7 +340,7 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(),
Clock.System.now(),
@@ -1,55 +1,141 @@
package chat.simplex.app.views.chat.item
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.views.helpers.*
import coil.ImageLoader
import coil.compose.rememberAsyncImagePainter
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import coil.size.Size
import com.google.accompanist.pager.*
import kotlinx.coroutines.launch
interface ImageGalleryProvider {
val initialIndex: Int
val totalImagesSize: MutableState<Int>
fun getImage(index: Int): Pair<Bitmap, Uri>?
fun currentPageChanged(index: Int)
fun scrollToStart()
fun onDismiss(index: Int)
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) {
BackHandler(onBack = close)
Column(
Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = close)
) {
var scale by remember { mutableStateOf(1f) }
var translationX by remember { mutableStateOf(0f) }
var translationY by remember { mutableStateOf(0f) }
Image(
imageBitmap.asImageBitmap(),
contentDescription = stringResource(R.string.image_descr),
contentScale = ContentScale.Fit,
modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = translationX,
translationY = translationY,
)
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, gestureZoom, _ ->
scale = (scale * gestureZoom).coerceIn(1f, 20f)
if (scale > 1) {
translationX += pan.x * scale
translationY += pan.y * scale
} else {
translationX = 0f
translationY = 0f
}
}
)
fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) {
val provider = remember { imageProvider() }
val pagerState = rememberPagerState(provider.initialIndex)
val goBack = { provider.onDismiss(pagerState.currentPage); close() }
BackHandler(onBack = goBack)
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
// which makes this blank page visible for a moment. Prevent it by doing the check ourselves
LaunchedEffect(Unit) {
if (provider.getImage(provider.initialIndex - 1) == null) {
provider.scrollToStart()
pagerState.scrollToPage(0)
}
}
val scope = rememberCoroutineScope()
HorizontalPager(count = remember { provider.totalImagesSize }.value, state = pagerState) { index ->
Column(
Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = goBack)
) {
var settledCurrentPage by remember { mutableStateOf(pagerState.currentPage) }
LaunchedEffect(pagerState) {
snapshotFlow {
if (!pagerState.isScrollInProgress) pagerState.currentPage else settledCurrentPage
}.collect {
settledCurrentPage = it
}
.fillMaxSize(),
)
}
LaunchedEffect(settledCurrentPage) {
// Make this pager with infinity scrolling with only 3 pages at a time when left and right pages constructs in real time
if (settledCurrentPage != provider.initialIndex)
provider.currentPageChanged(index)
}
val image = provider.getImage(index)
if (image == null) {
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
scope.launch {
when (settledCurrentPage) {
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
index + 1 -> {
provider.scrollToStart()
pagerState.scrollToPage(0)
}
}
}
} else {
val (imageBitmap: Bitmap, uri: Uri) = image
var scale by remember { mutableStateOf(1f) }
var translationX by remember { mutableStateOf(0f) }
var translationY by remember { mutableStateOf(0f) }
LaunchedEffect(settledCurrentPage) {
scale = 1f
translationX = 0f
translationY = 0f
}
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
val imageLoader = ImageLoader.Builder(LocalContext.current)
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
Image(
rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
imageLoader = imageLoader
),
contentDescription = stringResource(R.string.image_descr),
contentScale = ContentScale.Fit,
modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = translationX,
translationY = translationY,
)
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, gestureZoom, _ ->
scale = (scale * gestureZoom).coerceIn(1f, 20f)
if (scale > 1) {
translationX += pan.x * scale
translationY += pan.y * scale
} else {
translationX = 0f
translationY = 0f
}
}
)
}
.fillMaxSize(),
)
}
}
}
}
@@ -7,11 +7,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import androidx.core.text.BidiFormatter
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.detectGesture
@@ -50,51 +51,69 @@ fun MarkdownText (
modifier: Modifier = Modifier,
onLinkLongClick: (link: String) -> Unit = {}
) {
val reserve = if (edited) " " else " "
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
append(text)
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
} else {
var hasLinks = false
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
for (ft in formattedText) {
if (ft.format == null) append(ft.text)
else {
val link = ft.link
if (link != null) {
hasLinks = true
val ftStyle = ft.format.style
withAnnotation(tag = "URL", annotation = link) {
withStyle(ftStyle) { append(ft.text) }
val textLayoutDirection = remember (text) {
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
}
val reserve = when {
textLayoutDirection != LocalLayoutDirection.current && metaText != null -> "\n"
edited -> " "
else -> " "
}
CompositionLocalProvider(
// When we draw `sender` is has issues on LTR languages set globally with RTL text language
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current && sender == null)
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
else
LocalLayoutDirection.current
) {
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
append(text)
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
} else {
var hasLinks = false
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
for (ft in formattedText) {
if (ft.format == null) append(ft.text)
else {
val link = ft.link
if (link != null) {
hasLinks = true
val ftStyle = ft.format.style
withAnnotation(tag = "URL", annotation = link) {
withStyle(ftStyle) { append(ft.text) }
}
} else {
withStyle(ft.format.style) { append(ft.text) }
}
} else {
withStyle(ft.format.style) { append(ft.text) }
}
}
// With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good
/*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl)
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
else */if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
},
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
}
)
} else {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
},
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
}
)
} else {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
}
}
@@ -22,6 +22,7 @@ import chat.simplex.app.views.chat.group.deleteGroupDialog
import chat.simplex.app.views.chat.group.leaveGroupDialog
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ContactConnectionInfoView
import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
@@ -63,7 +64,11 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
is ChatInfo.ContactConnection ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) },
click = {
ModalManager.shared.showModalCloseable(true) { close ->
ContactConnectionInfoView(chatModel, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close)
}
},
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu,
stopped
@@ -130,7 +135,7 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM
GroupMemberStatus.MemInvited -> {
JoinGroupAction(chat, groupInfo, chatModel, showMenu)
if (groupInfo.canDelete) {
DeleteGroupAction(chat, chatModel, showMenu)
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
}
}
else -> {
@@ -143,7 +148,7 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM
LeaveGroupAction(groupInfo, chatModel, showMenu)
}
if (groupInfo.canDelete) {
DeleteGroupAction(chat, chatModel, showMenu)
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
}
}
}
@@ -168,7 +173,7 @@ fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled:
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
if (ntfsEnabled) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
onClick = {
changeNtfsState(!ntfsEnabled, chat, chatModel)
changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
showMenu.value = false
}
)
@@ -177,7 +182,7 @@ fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled:
@Composable
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.clear_verb),
stringResource(R.string.clear_chat_menu_action),
Icons.Outlined.Restore,
onClick = {
clearChatDialog(chat.chatInfo, chatModel)
@@ -190,7 +195,7 @@ fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boo
@Composable
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.delete_verb),
stringResource(R.string.delete_contact_menu_action),
Icons.Outlined.Delete,
onClick = {
deleteContactDialog(chat.chatInfo, chatModel)
@@ -201,12 +206,12 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState
}
@Composable
fun DeleteGroupAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.delete_verb),
stringResource(R.string.delete_group_menu_action),
Icons.Outlined.Delete,
onClick = {
deleteGroupDialog(chat.chatInfo, chatModel)
deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
showMenu.value = false
},
color = Color.Red
@@ -264,11 +269,21 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo
@Composable
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.set_contact_name),
Icons.Outlined.Edit,
onClick = {
ModalManager.shared.showModalCloseable(true) { close ->
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
}
showMenu.value = false
},
)
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel)
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
showMenu.value = false
},
color = Color.Red
@@ -337,7 +352,7 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
) {
TextButton(onClick = {
AlertManager.shared.hideAlert()
deleteContactConnectionAlert(connection, chatModel)
deleteContactConnectionAlert(connection, chatModel) {}
}) {
Text(stringResource(R.string.delete_verb))
}
@@ -350,7 +365,7 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
)
}
fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel) {
fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.delete_pending_connection__question),
text = generalGetString(
@@ -363,6 +378,7 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel
AlertManager.shared.hideAlert()
if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) {
chatModel.removeChat(connection.id)
onSuccess()
}
}
}
@@ -424,6 +440,36 @@ fun groupInvitationAcceptedAlert() {
)
}
fun changeNtfsStatePerChat(enabled: Boolean, currentState: MutableState<Boolean>, chat: Chat, chatModel: ChatModel) {
val newChatInfo = when(chat.chatInfo) {
is ChatInfo.Direct -> with (chat.chatInfo) {
ChatInfo.Direct(contact.copy(chatSettings = contact.chatSettings.copy(enableNtfs = enabled)))
}
is ChatInfo.Group -> with(chat.chatInfo) {
ChatInfo.Group(groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(enableNtfs = enabled)))
}
else -> null
}
withApi {
val res = when (newChatInfo) {
is ChatInfo.Direct -> with(newChatInfo) {
chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings)
}
is ChatInfo.Group -> with(newChatInfo) {
chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings)
}
else -> false
}
if (res && newChatInfo != null) {
chatModel.updateChatInfo(newChatInfo)
if (!enabled) {
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
}
currentState.value = enabled
}
}
}
@Composable
fun ChatListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
@@ -432,7 +478,7 @@ fun ChatListNavLinkLayout(
showMenu: MutableState<Boolean>,
stopped: Boolean
) {
var modifier = Modifier.fillMaxWidth().height(88.dp)
var modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp)
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
Surface(modifier) {
Row(
@@ -1,5 +1,6 @@
package chat.simplex.app.views.chatlist
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -7,105 +8,176 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.Indigo
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.onboarding.MakeConnection
import chat.simplex.app.views.usersettings.SettingsView
import kotlinx.coroutines.CoroutineScope
import chat.simplex.app.views.usersettings.simplexTeamUri
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
class ScaffoldController(val scope: CoroutineScope) {
lateinit var state: BottomSheetScaffoldState
val expanded = mutableStateOf(false)
fun expand() {
expanded.value = true
scope.launch { state.bottomSheetState.expand() }
}
fun collapse() {
expanded.value = false
scope.launch { state.bottomSheetState.collapse() }
}
fun toggleSheet() {
if (state.bottomSheetState.isExpanded) collapse() else expand()
}
fun toggleDrawer() = scope.launch {
state.drawerState.apply { if (isClosed) open() else close() }
}
}
@Composable
fun scaffoldController(): ScaffoldController {
val ctrl = ScaffoldController(scope = rememberCoroutineScope())
val bottomSheetState = rememberBottomSheetState(
BottomSheetValue.Collapsed,
confirmStateChange = {
ctrl.expanded.value = it == BottomSheetValue.Expanded
true
}
)
ctrl.state = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
return ctrl
}
@Composable
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val scaffoldCtrl = scaffoldController()
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
val newChatSheetState by rememberSaveable(stateSaver = NewChatSheetState.saver()) { mutableStateOf(MutableStateFlow(NewChatSheetState.GONE)) }
val showNewChatSheet = {
newChatSheetState.value = NewChatSheetState.VISIBLE
}
BottomSheetScaffold(
topBar = { ChatListToolbar(chatModel, scaffoldCtrl, stopped) },
scaffoldState = scaffoldCtrl.state,
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
if (animated) newChatSheetState.value = NewChatSheetState.HIDING
else newChatSheetState.value = NewChatSheetState.GONE
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
}
var searchInList by rememberSaveable { mutableStateOf("") }
val scaffoldState = rememberScaffoldState()
Scaffold(
topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, stopped) { searchInList = it.trim() } },
scaffoldState = scaffoldState,
drawerContent = { SettingsView(chatModel, setPerformLA) },
sheetPeekHeight = 0.dp,
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
floatingActionButton = {
if (searchInList.isEmpty()) {
FloatingActionButton(
onClick = {
if (!stopped) {
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
}
},
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else HighOrLowlight,
contentColor = Color.White
) {
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) Icons.Default.Edit else Icons.Default.Close, stringResource(R.string.add_contact_or_create_group))
}
}
}
) {
Box {
Box(Modifier.padding(it)) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel)
ChatList(chatModel, search = searchInList)
} else {
MakeConnection(chatModel)
Box(Modifier.fillMaxSize()) {
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
OnboardingButtons(showNewChatSheet)
}
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
}
}
}
if (scaffoldCtrl.expanded.value) {
Surface(
Modifier
.fillMaxSize()
.clickable { scaffoldCtrl.collapse() },
color = Color.Black.copy(alpha = 0.12F)
) {}
}
}
}
if (searchInList.isEmpty()) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
}
@Composable
fun ChatListToolbar(chatModel: ChatModel, scaffoldCtrl: ScaffoldController, stopped: Boolean) {
private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
val uriHandler = LocalUriHandler.current
ConnectButton(generalGetString(R.string.chat_with_developers)) {
uriHandler.openUri(simplexTeamUri)
}
Spacer(Modifier.height(DEFAULT_PADDING))
ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatSheet)
val color = MaterialTheme.colors.primary
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
val trianglePath = Path().apply {
moveTo(0.dp.toPx(), 0f)
lineTo(16.dp.toPx(), 0.dp.toPx())
lineTo(8.dp.toPx(), 10.dp.toPx())
lineTo(0.dp.toPx(), 0.dp.toPx())
}
drawPath(
color = color,
path = trianglePath
)
})
Spacer(Modifier.height(62.dp))
}
}
@Composable
private fun ConnectButton(text: String, onClick: () -> Unit) {
Button(
onClick,
shape = RoundedCornerShape(21.dp),
colors = ButtonDefaults.textButtonColors(
backgroundColor = MaterialTheme.colors.primary
),
elevation = null,
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
modifier = Modifier.height(42.dp)
) {
Text(text, color = Color.White)
}
}
@Composable
private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
var showSearch by rememberSaveable { mutableStateOf(false) }
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
if (showSearch) {
BackHandler(onBack = hideSearchOnBack)
}
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (chatModel.chats.size >= 8) {
barButtons.add {
IconButton({ showSearch = true }) {
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
}
}
}
if (stopped) {
barButtons.add {
IconButton(onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.chat_is_stopped_indication),
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)
)
}) {
Icon(
Icons.Filled.Report,
generalGetString(R.string.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
val scope = rememberCoroutineScope()
DefaultTopAppBar(
navigationButton = { NavigationButtonMenu { scaffoldCtrl.toggleDrawer() } },
navigationButton = {
if (showSearch)
NavigationButtonBack(hideSearchOnBack)
else
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -124,40 +196,23 @@ fun ChatListToolbar(chatModel: ChatModel, scaffoldCtrl: ScaffoldController, stop
}
},
onTitleClick = null,
showSearch = false,
onSearchValueChanged = {},
buttons = listOf{
if (!stopped) {
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.AddCircle,
stringResource(R.string.add_contact),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp).size(26.dp)
)
}
} else {
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication),
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
Icon(
Icons.Filled.Report,
generalGetString(R.string.chat_is_stopped_indication),
tint = Color.Red,
modifier = Modifier.padding(10.dp)
)
}
}
}
showSearch = showSearch,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
)
Divider()
Divider(Modifier.padding(top = AppBarHeight))
}
@Composable
fun ChatList(chatModel: ChatModel) {
private fun ChatList(chatModel: ChatModel, search: String) {
val filter: (Chat) -> Boolean = { chat: Chat ->
chat.chatInfo.chatViewName.lowercase().contains(search.lowercase())
}
val chats by remember(search) { derivedStateOf { if (search.isEmpty()) chatModel.chats else chatModel.chats.filter(filter) } }
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(chatModel.chats) { chat ->
items(chats) { chat ->
ChatListNavLinkView(chat, chatModel)
}
}
@@ -17,8 +17,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
@@ -83,11 +82,14 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
MarkdownText(
ci.text, ci.formattedText, ci.memberDisplayName,
metaText = ci.timestampText,
ci.text,
ci.formattedText,
sender = null,
metaText = null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
modifier = Modifier.fillMaxWidth(),
)
} else {
when (cInfo) {
@@ -4,16 +4,14 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddLink
import androidx.compose.material.icons.outlined.Link
import androidx.compose.runtime.Composable
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.PendingContactConnection
import chat.simplex.app.model.getTimestampText
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ProfileImage
@@ -0,0 +1,66 @@
package chat.simplex.app.views.chatlist
import SectionItemView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.Indigo
import chat.simplex.app.views.helpers.ProfileImage
@Composable
fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) {
val stopped = chatModel.chatRunning.value == false
when (chat.chatInfo) {
is ChatInfo.Direct ->
ShareListNavLinkLayout(
chatLinkPreview = { SharePreviewView(chat) },
click = { directChatAction(chat.chatInfo, chatModel) },
stopped
)
is ChatInfo.Group ->
ShareListNavLinkLayout(
chatLinkPreview = { SharePreviewView(chat) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
stopped
)
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection -> {}
}
}
@Composable
private fun ShareListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
click: () -> Unit,
stopped: Boolean
) {
SectionItemView(minHeight = 50.dp, click = click, disabled = stopped) {
chatLinkPreview()
}
Divider(Modifier.padding(horizontal = 8.dp))
}
@Composable
private fun SharePreviewView(chat: Chat) {
Row(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
ProfileImage(size = 46.dp, chat.chatInfo.image)
Text(
chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (chat.chatInfo.incognito) Indigo else Color.Unspecified
)
}
}
}
@@ -0,0 +1,138 @@
package chat.simplex.app.views.chatlist
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.Indigo
import chat.simplex.app.views.helpers.*
@Composable
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
Scaffold(
topBar = { Column { ShareListToolbar(chatModel, stopped) { searchInList = it.trim() } } },
) {
Box(Modifier.padding(it)) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
if (chatModel.chats.isNotEmpty()) {
ShareList(chatModel, search = searchInList)
} else {
EmptyList()
}
}
}
}
}
@Composable
private fun EmptyList() {
Box {
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
}
}
@Composable
private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
var showSearch by rememberSaveable { mutableStateOf(false) }
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
if (showSearch) {
BackHandler(onBack = hideSearchOnBack)
}
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (chatModel.chats.size >= 8) {
barButtons.add {
IconButton({ showSearch = true }) {
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
}
}
}
if (stopped) {
barButtons.add {
IconButton(onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.chat_is_stopped_indication),
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)
)
}) {
Icon(
Icons.Filled.Report,
generalGetString(R.string.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
DefaultTopAppBar(
navigationButton = { if (showSearch) NavigationButtonBack(hideSearchOnBack) else NavigationButtonBack { chatModel.sharedContent.value = null } },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
when (chatModel.sharedContent.value) {
is SharedContent.Text -> stringResource(R.string.share_message)
is SharedContent.Image -> stringResource(R.string.share_image)
is SharedContent.File -> stringResource(R.string.share_file)
else -> stringResource(R.string.share_message)
},
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
if (chatModel.incognito.value) {
Icon(
Icons.Filled.TheaterComedy,
stringResource(R.string.incognito),
tint = Indigo,
modifier = Modifier.padding(10.dp).size(26.dp)
)
}
}
},
onTitleClick = null,
showSearch = showSearch,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
)
Divider()
}
@Composable
private fun ShareList(chatModel: ChatModel, search: String) {
val filter: (Chat) -> Boolean = { chat: Chat ->
chat.chatInfo.chatViewName.lowercase().contains(search.lowercase())
}
val chats by remember(search) {
derivedStateOf {
if (search.isEmpty()) chatModel.chats.filter { it.chatInfo.ready } else chatModel.chats.filter { it.chatInfo.ready }.filter(filter)
}
}
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(chats) { chat ->
ShareListNavLinkView(chat, chatModel)
}
}
}
@@ -59,25 +59,22 @@ fun ChatArchiveLayout(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
Text(
title,
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
AppBarTitle(title)
SectionView(stringResource(R.string.chat_archive_section)) {
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.save_archive),
saveArchive,
textColor = MaterialTheme.colors.primary
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
)
SectionDivider()
SettingsActionItem(
Icons.Outlined.Delete,
stringResource(R.string.delete_archive),
deleteArchiveAlert,
textColor = Color.Red
textColor = Color.Red,
iconColor = Color.Red,
)
}
val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
@@ -137,7 +134,7 @@ private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
fun PreviewChatArchiveLayout() {
SimpleXTheme {
ChatArchiveLayout(
title = "New database archive",
"New database archive",
archiveTime = Clock.System.now(),
saveArchive = {},
deleteArchiveAlert = {}
@@ -0,0 +1,507 @@
package chat.simplex.app.views.database
import SectionItemView
import SectionItemViewSpaceBetween
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.foundation.text.*
import androidx.compose.material.*
import androidx.compose.material.TextFieldDefaults.indicatorLine
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
import kotlin.math.log2
@Composable
fun DatabaseEncryptionView(m: ChatModel) {
val progressIndicator = remember { mutableStateOf(false) }
val prefs = m.controller.appPrefs
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
val storedKey = remember { val key = DatabaseUtils.getDatabaseKey(); mutableStateOf(key != null && key != "") }
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.getDatabaseKey() ?: "" else "") }
val newKey = rememberSaveable { mutableStateOf("") }
val confirmNewKey = rememberSaveable { mutableStateOf("") }
Box(
Modifier.fillMaxSize(),
) {
DatabaseEncryptionLayout(
useKeychain,
prefs,
m.chatDbEncrypted.value,
currentKey,
newKey,
confirmNewKey,
storedKey,
initialRandomDBPassphrase,
progressIndicator,
onConfirmEncrypt = {
progressIndicator.value = true
withApi {
try {
prefs.encryptionStartedAt.set(Clock.System.now())
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
prefs.encryptionStartedAt.set(null)
val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
when {
sqliteError is SQLiteError.ErrorNotADatabase -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.wrong_passphrase_title),
generalGetString(R.string.enter_correct_current_passphrase)
)
}
}
error != null -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database),
"failed to set storage encryption: ${error.responseType} ${error.details}"
)
}
}
else -> {
prefs.initialRandomDBPassphrase.set(false)
initialRandomDBPassphrase.value = false
if (useKeychain.value) {
DatabaseUtils.setDatabaseKey(newKey.value)
}
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_encrypted))
}
}
}
} catch (e: Exception) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database), e.stackTraceToString())
}
}
}
}
)
if (progressIndicator.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
}
@Composable
fun DatabaseEncryptionLayout(
useKeychain: MutableState<Boolean>,
prefs: AppPreferences,
chatDbEncrypted: Boolean?,
currentKey: MutableState<String>,
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>,
onConfirmEncrypt: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.database_passphrase))
SectionView(null) {
SavePassphraseSetting(useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, progressIndicator.value) { checked ->
if (checked) {
setUseKeychain(true, useKeychain, prefs)
} else if (storedKey.value) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.remove_passphrase_from_keychain),
text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
confirmText = generalGetString(R.string.remove_passphrase),
onConfirm = {
DatabaseUtils.removeDatabaseKey()
setUseKeychain(false, useKeychain, prefs)
storedKey.value = false
},
destructive = true,
)
} else {
setUseKeychain(false, useKeychain, prefs)
}
}
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
DatabaseKeyField(
currentKey,
generalGetString(R.string.current_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
}
DatabaseKeyField(
newKey,
generalGetString(R.string.new_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
showStrength = true,
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
val onClickUpdate = {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
if (!progressIndicator.value) {
if (currentKey.value == "") {
if (useKeychain.value)
encryptDatabaseSavedAlert(onConfirmEncrypt)
else
encryptDatabaseAlert(onConfirmEncrypt)
} else {
if (useKeychain.value)
changeDatabaseKeySavedAlert(onConfirmEncrypt)
else
changeDatabaseKeyAlert(onConfirmEncrypt)
}
}
}
val disabled = currentKey.value == newKey.value ||
newKey.value != confirmNewKey.value ||
newKey.value.isEmpty() ||
!validKey(currentKey.value) ||
!validKey(newKey.value) ||
progressIndicator.value
DatabaseKeyField(
confirmNewKey,
generalGetString(R.string.confirm_new_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = {
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
}),
)
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled) {
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
Column {
if (chatDbEncrypted == false) {
SectionTextFooter(generalGetString(R.string.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(R.string.keychain_is_storing_securely))
if (initialRandomDBPassphrase.value) {
SectionTextFooter(generalGetString(R.string.encrypted_with_random_passphrase))
} else {
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
}
} else {
SectionTextFooter(generalGetString(R.string.keychain_allows_to_receive_ntfs))
}
} else {
SectionTextFooter(generalGetString(R.string.you_have_to_enter_passphrase_every_time))
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
}
}
}
}
fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.encrypt_database_question),
text = generalGetString(R.string.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(R.string.encrypt_database),
onConfirm = onConfirm,
destructive = false,
)
}
fun encryptDatabaseAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.encrypt_database_question),
text = generalGetString(R.string.database_will_be_encrypted) +"\n" + storeSecurelyDanger(),
confirmText = generalGetString(R.string.encrypt_database),
onConfirm = onConfirm,
destructive = true,
)
}
fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.change_database_passphrase_question),
text = generalGetString(R.string.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(R.string.update_database),
onConfirm = onConfirm,
destructive = false,
)
}
fun changeDatabaseKeyAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.change_database_passphrase_question),
text = generalGetString(R.string.database_passphrase_will_be_updated) + "\n" + storeSecurelyDanger(),
confirmText = generalGetString(R.string.update_database),
onConfirm = onConfirm,
destructive = true,
)
}
@Composable
fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff,
stringResource(R.string.save_passphrase_in_keychain),
tint = if (storedKey) SimplexGreen else HighOrLowlight
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(R.string.save_passphrase_in_keychain),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
enabled = !initialRandomDBPassphrase && !progressIndicator
)
}
}
}
fun resetFormAfterEncryption(
m: ChatModel,
initialRandomDBPassphrase: MutableState<Boolean>,
currentKey: MutableState<String>,
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
storedKey: MutableState<Boolean>,
stored: Boolean = false,
) {
m.chatDbEncrypted.value = true
initialRandomDBPassphrase.value = false
m.controller.appPrefs.initialRandomDBPassphrase.set(false)
currentKey.value = ""
newKey.value = ""
confirmNewKey.value = ""
storedKey.value = stored
}
fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, prefs: AppPreferences) {
useKeychain.value = value
prefs.storeDBPassphrase.set(value)
}
fun storeSecurelySaved() = generalGetString(R.string.store_passphrase_securely)
fun storeSecurelyDanger() = generalGetString(R.string.store_passphrase_securely_without_recover)
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
m.chatDbChanged.value = true
progressIndicator.value = false
alert.invoke()
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DatabaseKeyField(
key: MutableState<String>,
placeholder: String,
modifier: Modifier = Modifier,
showStrength: Boolean = false,
isValid: (String) -> Boolean,
keyboardActions: KeyboardActions = KeyboardActions(),
) {
var valid by remember { mutableStateOf(validKey(key.value)) }
var showKey by remember { mutableStateOf(false) }
val icon = if (valid) {
if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
} else Icons.Outlined.Error
val iconColor = if (valid) {
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else HighOrLowlight
} else Color.Red
val keyboard = LocalSoftwareKeyboardController.current
val keyboardOptions = KeyboardOptions(
imeAction = if (keyboardActions.onNext != null) ImeAction.Next else ImeAction.Done,
autoCorrect = false,
keyboardType = KeyboardType.Password
)
val state = remember {
mutableStateOf(TextFieldValue(key.value))
}
val enabled = true
val colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Unspecified,
textColor = MaterialTheme.colors.onBackground,
focusedIndicatorColor = Color.Unspecified,
unfocusedIndicatorColor = Color.Unspecified,
)
val color = MaterialTheme.colors.onBackground
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
val interactionSource = remember { MutableInteractionSource() }
BasicTextField(
value = state.value,
modifier = modifier
.fillMaxWidth()
.background(colors.backgroundColor(enabled).value, shape)
.indicatorLine(enabled, false, interactionSource, colors)
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
),
onValueChange = {
state.value = it
key.value = it.text
valid = isValid(it.text)
},
cursorBrush = SolidColor(colors.cursorColor(false).value),
visualTransformation = if (showKey)
VisualTransformation.None
else
VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) },
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(onDone = {
keyboard?.hide()
keyboardActions.onDone?.invoke(this)
}),
singleLine = true,
textStyle = TextStyle.Default.copy(
color = color,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
interactionSource = interactionSource,
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = state.value.text,
innerTextField = innerTextField,
placeholder = { Text(placeholder, color = HighOrLowlight) },
singleLine = true,
enabled = enabled,
isError = !valid,
trailingIcon = {
IconButton({ showKey = !showKey }) {
Icon(icon, null, tint = iconColor)
}
},
interactionSource = interactionSource,
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
visualTransformation = VisualTransformation.None,
colors = colors
)
}
)
}
// based on https://generatepasswords.org/how-to-calculate-entropy/
private fun passphraseEntropy(s: String): Double {
var hasDigits = false
var hasUppercase = false
var hasLowercase = false
var hasSymbols = false
for (c in s) {
if (c.isDigit()) {
hasDigits = true
} else if (c.isLetter()) {
if (c.isUpperCase()) {
hasUppercase = true
} else {
hasLowercase = true
}
} else if (c.isASCII()) {
hasSymbols = true
}
}
val poolSize = (if (hasDigits) 10 else 0) + (if (hasUppercase) 26 else 0) + (if (hasLowercase) 26 else 0) + (if (hasSymbols) 32 else 0)
return s.length * log2(poolSize.toDouble())
}
private enum class PassphraseStrength(val color: Color) {
VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
companion object {
fun check(s: String) = with(passphraseEntropy(s)) {
when {
this > 100 -> STRONG
this > 70 -> REASONABLE
this > 40 -> WEAK
else -> VERY_WEAK
}
}
}
}
fun validKey(s: String): Boolean {
for (c in s) {
if (c.isWhitespace() || !c.isASCII()) {
return false
}
}
return true
}
private fun Char.isASCII() = code in 32..126
@Preview
@Composable
fun PreviewDatabaseEncryptionLayout() {
SimpleXTheme {
DatabaseEncryptionLayout(
useKeychain = remember { mutableStateOf(true) },
prefs = AppPreferences(SimplexApp.context),
chatDbEncrypted = true,
currentKey = remember { mutableStateOf("") },
newKey = remember { mutableStateOf("") },
confirmNewKey = remember { mutableStateOf("") },
storedKey = remember { mutableStateOf(true) },
initialRandomDBPassphrase = remember { mutableStateOf(true) },
progressIndicator = remember { mutableStateOf(false) },
onConfirmEncrypt = {},
)
}
}
@@ -0,0 +1,250 @@
package chat.simplex.app.views.database
import SectionSpacer
import SectionView
import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import kotlin.io.path.Path
@Composable
fun DatabaseErrorView(
chatDbStatus: State<DBMigrationResult?>,
appPreferences: AppPreferences,
) {
val progressIndicator = remember { mutableStateOf(false) }
val dbKey = remember { mutableStateOf("") }
var storedDBKey by remember { mutableStateOf(DatabaseUtils.getDatabaseKey()) }
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
val context = LocalContext.current
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
val saveAndRunChatOnClick: () -> Unit = {
DatabaseUtils.setDatabaseKey(dbKey.value)
storedDBKey = dbKey.value
appPreferences.storeDBPassphrase.set(true)
useKeychain = true
appPreferences.initialRandomDBPassphrase.set(false)
runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
}
val title = when (chatDbStatus.value) {
is DBMigrationResult.OK -> ""
is DBMigrationResult.ErrorNotADatabase -> if (useKeychain && !storedDBKey.isNullOrEmpty())
generalGetString(R.string.wrong_passphrase)
else
generalGetString(R.string.encrypted_database)
is DBMigrationResult.Error -> generalGetString(R.string.database_error)
is DBMigrationResult.ErrorKeychain -> generalGetString(R.string.keychain_error)
is DBMigrationResult.Unknown -> generalGetString(R.string.database_error)
null -> "" // should never be here
}
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Center,
) {
Text(
title,
Modifier.padding(start = 16.dp, top = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
when (val status = chatDbStatus.value) {
is DBMigrationResult.ErrorNotADatabase -> {
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
Text(generalGetString(R.string.passphrase_is_different))
DatabaseKeyField(dbKey, buttonEnabled) {
saveAndRunChatOnClick()
}
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
SectionSpacer()
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
} else {
Text(generalGetString(R.string.database_passphrase_is_required))
DatabaseKeyField(dbKey, buttonEnabled) {
if (useKeychain) saveAndRunChatOnClick() else runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
}
if (useKeychain) {
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
} else {
OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) }
}
}
}
is DBMigrationResult.Error -> {
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
Text(String.format(generalGetString(R.string.error_with_info), status.migrationError))
}
is DBMigrationResult.ErrorKeychain -> {
Text(generalGetString(R.string.cannot_access_keychain))
}
is DBMigrationResult.Unknown -> {
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
}
is DBMigrationResult.OK -> {
}
null -> {
}
}
if (restoreDbFromBackup.value) {
SectionSpacer()
Text(generalGetString(R.string.database_backup_can_be_restored))
Spacer(Modifier.size(16.dp))
RestoreDbButton {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.restore_database_alert_title),
text = generalGetString(R.string.restore_database_alert_desc),
confirmText = generalGetString(R.string.restore_database_alert_confirm),
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
destructive = true,
)
}
}
}
}
if (progressIndicator.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
private fun runChat(
dbKey: String,
chatDbStatus: State<DBMigrationResult?>,
progressIndicator: MutableState<Boolean>,
prefs: AppPreferences
) = CoroutineScope(Dispatchers.Default).launch {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
if (progressIndicator.value) return@launch
progressIndicator.value = true
try {
SimplexApp.context.initChatController(dbKey)
} catch (e: Exception) {
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
}
progressIndicator.value = false
when (val status = chatDbStatus.value) {
is DBMigrationResult.OK -> {
SimplexService.cancelPassphraseNotification()
when (prefs.notificationsMode.get()) {
NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) }
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
}
}
is DBMigrationResult.ErrorNotADatabase -> {
AlertManager.shared.showAlertMsg(generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
}
is DBMigrationResult.Error -> {
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationError)
}
is DBMigrationResult.ErrorKeychain -> {
AlertManager.shared.showAlertMsg(generalGetString(R.string.keychain_error))
}
is DBMigrationResult.Unknown -> {
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), status.json)
}
null -> {}
}
}
private fun shouldShowRestoreDbButton(prefs: AppPreferences, context: Context): Boolean {
val startedAt = prefs.encryptionStartedAt.get() ?: return false
/** Just in case there is any small difference between reported Java's [Clock.System.now] and Linux's time on a file */
val safeDiffInTime = 10_000L
val filesChat = File(context.dataDir.absolutePath + File.separator + "files_chat.db.bak")
val filesAgent = File(context.dataDir.absolutePath + File.separator + "files_agent.db.bak")
return filesChat.exists() &&
filesAgent.exists() &&
startedAt.toEpochMilliseconds() - safeDiffInTime <= filesChat.lastModified() &&
startedAt.toEpochMilliseconds() - safeDiffInTime <= filesAgent.lastModified()
}
private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPreferences, context: Context) {
val filesChatBase = context.dataDir.absolutePath + File.separator + "files_chat.db"
val filesAgentBase = context.dataDir.absolutePath + File.separator + "files_agent.db"
try {
Files.copy(Path("$filesChatBase.bak"), Path(filesChatBase), StandardCopyOption.REPLACE_EXISTING)
Files.copy(Path("$filesAgentBase.bak"), Path(filesAgentBase), StandardCopyOption.REPLACE_EXISTING)
restoreDbFromBackup.value = false
prefs.encryptionStartedAt.set(null)
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_restore_error), e.stackTraceToString())
}
}
@Composable
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
DatabaseKeyField(
text,
generalGetString(R.string.enter_passphrase),
isValid = ::validKey,
keyboardActions = KeyboardActions(onDone = if (enabled) {
{ onClick?.invoke() }
} else null
)
)
}
@Composable
private fun ColumnScope.SaveAndOpenButton(enabled: Boolean, onClick: () -> Unit) {
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
Text(generalGetString(R.string.save_passphrase_and_open_chat))
}
}
@Composable
private fun ColumnScope.OpenChatButton(enabled: Boolean, onClick: () -> Unit) {
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
Text(generalGetString(R.string.open_chat))
}
}
@Composable
private fun ColumnScope.RestoreDbButton(onClick: () -> Unit) {
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally)) {
Text(generalGetString(R.string.restore_database), color = MaterialTheme.colors.error)
}
}
@Preview
@Composable
fun PreviewChatInfoLayout() {
SimpleXTheme {
DatabaseErrorView(
remember { mutableStateOf(DBMigrationResult.ErrorNotADatabase("simplex_v1_chat.db")) },
AppPreferences(SimplexApp.context)
)
}
}
@@ -15,10 +15,11 @@ import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -28,17 +29,19 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
import kotlinx.coroutines.*
import kotlinx.datetime.*
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
@Composable
fun DatabaseView(
@@ -49,6 +52,7 @@ fun DatabaseView(
val progressIndicator = remember { mutableStateOf(false) }
val runChat = remember { mutableStateOf(m.chatRunning.value ?: true) }
val prefs = m.controller.appPrefs
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) }
val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) }
@@ -59,9 +63,12 @@ fun DatabaseView(
importArchiveAlert(m, context, uri, progressIndicator)
}
}
val chatDbDeleted = remember { m.chatDbDeleted }
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory(context))) }
LaunchedEffect(m.chatRunning) {
runChat.value = m.chatRunning.value ?: true
}
val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) }
Box(
Modifier.fillMaxSize(),
) {
@@ -69,14 +76,30 @@ fun DatabaseView(
progressIndicator.value,
runChat.value,
m.chatDbChanged.value,
useKeychain.value,
m.chatDbEncrypted.value,
m.controller.appPrefs.initialRandomDBPassphrase,
importArchiveLauncher,
chatArchiveName,
chatArchiveTime,
chatLastStart,
startChat = { startChat(m, runChat, chatLastStart, context) },
chatDbDeleted.value,
appFilesCountAndSize,
chatItemTTL,
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
stopChatAlert = { stopChatAlert(m, runChat, context) },
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(context, appFilesCountAndSize) },
onChatItemTTLSelected = {
val oldValue = chatItemTTL.value
chatItemTTL.value = it
if (it < oldValue) {
setChatItemTTLAlert(m, chatItemTTL, progressIndicator, appFilesCountAndSize, context)
} else if (it != oldValue) {
setCiTTL(m, chatItemTTL, progressIndicator, appFilesCountAndSize, context)
}
},
showSettingsModal
)
if (progressIndicator.value) {
@@ -101,40 +124,59 @@ fun DatabaseLayout(
progressIndicator: Boolean,
runChat: Boolean,
chatDbChanged: Boolean,
useKeyChain: Boolean,
chatDbEncrypted: Boolean?,
initialRandomDBPassphrase: Preference<Boolean>,
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
chatDbDeleted: Boolean,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
chatItemTTL: MutableState<ChatItemTTL>,
startChat: () -> Unit,
stopChatAlert: () -> Unit,
exportArchive: () -> Unit,
deleteChatAlert: () -> Unit,
deleteAppFilesAndMedia: () -> Unit,
onChatItemTTLSelected: (ChatItemTTL) -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val stopped = !runChat
val operationsDisabled = !stopped || progressIndicator || chatDbChanged
val operationsDisabled = !stopped || progressIndicator
Column(
Modifier.fillMaxWidth(),
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
Text(
stringResource(R.string.your_chat_database),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
AppBarTitle(stringResource(R.string.your_chat_database))
SectionView(stringResource(R.string.run_chat_section)) {
RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert)
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
}
SectionSpacer()
SectionView(stringResource(R.string.chat_database_section)) {
val unencrypted = chatDbEncrypted == false
SettingsActionItem(
if (unencrypted) Icons.Outlined.LockOpen else if (useKeyChain) Icons.Filled.VpnKey else Icons.Outlined.Lock,
stringResource(R.string.database_passphrase),
click = showSettingsModal() { DatabaseEncryptionView(it) },
iconColor = if (unencrypted) WarningOrange else HighOrLowlight,
disabled = operationsDisabled
)
SectionDivider()
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.export_database),
exportArchive,
click = {
if (initialRandomDBPassphrase.get()) {
exportProhibitedAlert()
} else {
exportArchive()
}
},
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
disabled = operationsDisabled
)
SectionDivider()
@@ -143,6 +185,7 @@ fun DatabaseLayout(
stringResource(R.string.import_database),
{ importArchiveLauncher.launch("application/zip") },
textColor = Color.Red,
iconColor = Color.Red,
disabled = operationsDisabled
)
SectionDivider()
@@ -164,28 +207,91 @@ fun DatabaseLayout(
stringResource(R.string.delete_database),
deleteChatAlert,
textColor = Color.Red,
iconColor = Color.Red,
disabled = operationsDisabled
)
}
SectionTextFooter(
if (chatDbChanged) {
stringResource(R.string.restart_the_app_to_use_new_chat_database)
if (stopped) {
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
} else {
if (stopped) {
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
} else {
stringResource(R.string.stop_chat_to_enable_database_actions)
}
stringResource(R.string.stop_chat_to_enable_database_actions)
}
)
SectionSpacer()
SectionView(stringResource(R.string.data_section)) {
SectionItemView { TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!progressIndicator && !chatDbChanged), onChatItemTTLSelected) }
SectionDivider()
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
SectionItemView(
deleteAppFilesAndMedia,
disabled = deleteFilesDisabled
) {
Text(
stringResource(R.string.delete_files_and_media),
color = if (deleteFilesDisabled) HighOrLowlight else Color.Red
)
}
}
val (count, size) = appFilesCountAndSize.value
SectionTextFooter(
if (count == 0) {
stringResource(R.string.no_received_app_files)
} else {
String.format(stringResource(R.string.total_files_count_and_size), count, formatBytes(size))
}
)
}
}
private fun setChatItemTTLAlert(
m: ChatModel, selectedChatItemTTL: MutableState<ChatItemTTL>,
progressIndicator: MutableState<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
context: Context
) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.enable_automatic_deletion_question),
text = generalGetString(R.string.enable_automatic_deletion_message),
confirmText = generalGetString(R.string.delete_messages),
onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize, context) },
onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value }
)
}
@Composable
private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onSelected: (ChatItemTTL) -> Unit) {
val values = remember {
val all: ArrayList<ChatItemTTL> = arrayListOf(ChatItemTTL.None, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day)
if (current.value is ChatItemTTL.Seconds) {
all.add(current.value)
}
all.map {
when (it) {
is ChatItemTTL.None -> it to generalGetString(R.string.chat_item_ttl_none)
is ChatItemTTL.Day -> it to generalGetString(R.string.chat_item_ttl_day)
is ChatItemTTL.Week -> it to generalGetString(R.string.chat_item_ttl_week)
is ChatItemTTL.Month -> it to generalGetString(R.string.chat_item_ttl_month)
is ChatItemTTL.Seconds -> it to String.format(generalGetString(R.string.chat_item_ttl_seconds), it.secs)
}
}
}
ExposedDropDownSettingRow(
generalGetString(R.string.delete_messages_after),
values,
current,
icon = null,
enabled = enabled,
onSelected = onSelected
)
}
@Composable
fun RunChatSetting(
runChat: Boolean,
stopped: Boolean,
chatDbChanged: Boolean,
chatDbDeleted: Boolean,
startChat: () -> Unit,
stopChatAlert: () -> Unit
) {
@@ -195,16 +301,16 @@ fun RunChatSetting(
Icon(
if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow,
chatRunningText,
tint = if (chatDbChanged) HighOrLowlight else if (stopped) Color.Red else MaterialTheme.colors.primary
tint = if (stopped) Color.Red else MaterialTheme.colors.primary
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
chatRunningText,
Modifier.padding(end = 24.dp),
color = if (chatDbChanged) HighOrLowlight else Color.Unspecified
Modifier.padding(end = 24.dp)
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
enabled = !chatDbDeleted,
checked = runChat,
onCheckedChange = { runChatSwitch ->
if (runChatSwitch) {
@@ -217,7 +323,6 @@ fun RunChatSetting(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
enabled = !chatDbChanged
)
}
}
@@ -228,16 +333,28 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
}
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, context: Context) {
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
withApi {
try {
if (chatDbChanged.value) {
SimplexApp.context.initChatController()
chatDbChanged.value = false
}
if (m.chatDbStatus.value !is DBMigrationResult.OK) {
/** Hide current view and show [DatabaseErrorView] */
ModalManager.shared.closeModals()
return@withApi
}
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
val ts = Clock.System.now()
m.controller.appPrefs.chatLastStart.set(ts)
chatLastStart.value = ts
SimplexService.start(context)
when (m.controller.appPrefs.notificationsMode.get()) {
NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) }
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
}
} catch (e: Error) {
runChat.value = false
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
@@ -250,11 +367,42 @@ private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>, context:
title = generalGetString(R.string.stop_chat_question),
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
confirmText = generalGetString(R.string.stop_chat_confirmation),
onConfirm = { stopChat(m, runChat, context) },
onConfirm = { authStopChat(m, runChat, context) },
onDismiss = { runChat.value = true }
)
}
private fun exportProhibitedAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.set_password_to_export),
text = generalGetString(R.string.set_password_to_export_desc),
)
}
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
if (m.controller.appPrefs.performLA.get()) {
authenticate(
generalGetString(R.string.auth_stop_chat),
generalGetString(R.string.auth_log_in_using_credential),
context as FragmentActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success, LAResult.Unavailable -> {
stopChat(m, runChat, context)
}
is LAResult.Error -> {
}
LAResult.Failed -> {
runChat.value = true
}
}
}
)
} else {
stopChat(m, runChat, context)
}
}
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
withApi {
try {
@@ -262,6 +410,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Cont
runChat.value = false
m.chatRunning.value = false
SimplexService.stop(context)
MessagesFetcherWorker.cancelAll()
} catch (e: Error) {
runChat.value = true
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
@@ -377,6 +526,7 @@ private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Ur
try {
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
m.controller.apiImportArchive(config)
DatabaseUtils.removeDatabaseKey()
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
}
@@ -429,6 +579,9 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
withApi {
try {
m.controller.apiDeleteStorage()
m.chatDbDeleted.value = true
DatabaseUtils.removeDatabaseKey()
m.controller.appPrefs.storeDBPassphrase.set(true)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
}
@@ -440,6 +593,63 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
}
}
private fun setCiTTL(
m: ChatModel,
chatItemTTL: MutableState<ChatItemTTL>,
progressIndicator: MutableState<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
context: Context
) {
Log.d(TAG, "DatabaseView setChatItemTTL ${chatItemTTL.value.seconds ?: -1}")
progressIndicator.value = true
withApi {
try {
m.controller.setChatItemTTL(chatItemTTL.value)
// Update model on success
m.chatItemTTL.value = chatItemTTL.value
afterSetCiTTL(m, progressIndicator, appFilesCountAndSize, context)
} catch (e: Exception) {
// Rollback to model's value
chatItemTTL.value = m.chatItemTTL.value
afterSetCiTTL(m, progressIndicator, appFilesCountAndSize, context)
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_changing_message_deletion), e.stackTraceToString())
}
}
}
private fun afterSetCiTTL(
m: ChatModel,
progressIndicator: MutableState<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
context: Context
) {
progressIndicator.value = false
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
withApi {
try {
val chats = m.controller.apiGetChats()
m.updateChats(chats)
} catch (e: Exception) {
Log.e(TAG, "apiGetChats error: ${e.message}")
}
}
}
private fun deleteFilesAndMediaAlert(context: Context, appFilesCountAndSize: MutableState<Pair<Int, Long>>) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.delete_files_and_media_question),
text = generalGetString(R.string.delete_files_and_media_desc),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = { deleteFiles(appFilesCountAndSize, context) },
destructive = true
)
}
private fun deleteFiles(appFilesCountAndSize: MutableState<Pair<Int, Long>>, context: Context) {
deleteAppFiles(context)
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
}
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
m.chatDbChanged.value = true
progressIndicator.value = false
@@ -459,15 +669,23 @@ fun PreviewDatabaseLayout() {
progressIndicator = false,
runChat = true,
chatDbChanged = false,
useKeyChain = false,
chatDbEncrypted = false,
initialRandomDBPassphrase = Preference({ true }, {}),
importArchiveLauncher = rememberGetContentLauncher {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
chatDbDeleted = false,
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
startChat = {},
stopChatAlert = {},
exportArchive = {},
deleteChatAlert = {},
showSettingsModal = { {} }
deleteAppFilesAndMedia = {},
showSettingsModal = { {} },
onChatItemTTLSelected = {},
)
}
}
@@ -47,12 +47,13 @@ class AlertManager {
onConfirm: (() -> Unit)? = null,
dismissText: String = generalGetString(R.string.cancel_verb),
onDismiss: (() -> Unit)? = null,
onDismissRequest: (() -> Unit)? = null,
destructive: Boolean = false
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = this::hideAlert,
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
title = { Text(title) },
text = alertText,
confirmButton = {
@@ -91,6 +92,13 @@ class AlertManager {
}
}
fun showAlertMsg(
title: Int,
text: Int? = null,
confirmText: Int = R.string.ok,
onConfirm: (() -> Unit)? = null
) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText), onConfirm)
@Composable
fun showInView() {
if (presentAlert.value) alertView.value?.invoke()
@@ -0,0 +1,5 @@
package chat.simplex.app.views.helpers
import androidx.compose.animation.core.*
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
@@ -3,37 +3,46 @@ package chat.simplex.app.views.helpers
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.*
@Composable
fun CloseSheetBar(close: () -> Unit) {
Row (
Column(
Modifier
.fillMaxWidth()
.height(60.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
.heightIn(min = AppBarHeight)
.padding(horizontal = AppBarHorizontalPadding),
) {
IconButton(onClick = close) {
Icon(
Icons.Outlined.Close,
stringResource(R.string.icon_descr_close_button),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
Row(
Modifier
.width(TitleInsetWithIcon - AppBarHorizontalPadding)
.padding(top = 4.dp), // Like in DefaultAppBar
content = { NavigationButtonBack(close) }
)
}
}
@Composable
fun AppBarTitle(title: String, withPadding: Boolean = true) {
val padding = if (withPadding)
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING )
else
PaddingValues(bottom = DEFAULT_PADDING)
Text(
title,
Modifier
.fillMaxWidth()
.padding(padding),
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h1
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -0,0 +1,12 @@
package chat.simplex.app.views.helpers
interface ValueTitle <T> {
val value: T
val title: String
}
data class ValueTitleDesc <T> (
override val value: T,
override val title: String,
val description: String
): ValueTitle<T>
@@ -0,0 +1,73 @@
package chat.simplex.app.views.helpers
import android.util.Log
import chat.simplex.app.*
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.views.usersettings.Cryptor
import kotlinx.serialization.*
import java.io.File
import java.security.SecureRandom
object DatabaseUtils {
private val cryptor = Cryptor()
private val appPreferences: AppPreferences by lazy {
AppPreferences(SimplexApp.context)
}
private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
private fun hasDatabase(rootDir: String): Boolean =
File(rootDir + File.separator + "files_chat.db").exists() && File(rootDir + File.separator + "files_agent.db").exists()
fun getDatabaseKey(): String? {
return cryptor.decryptData(
appPreferences.encryptedDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
appPreferences.initializationVectorDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
DATABASE_PASSWORD_ALIAS,
)
}
fun setDatabaseKey(key: String) {
val data = cryptor.encryptText(key, DATABASE_PASSWORD_ALIAS)
appPreferences.encryptedDBPassphrase.set(data.first.toBase64String())
appPreferences.initializationVectorDBPassphrase.set(data.second.toBase64String())
}
fun removeDatabaseKey() {
cryptor.deleteKey(DATABASE_PASSWORD_ALIAS)
appPreferences.encryptedDBPassphrase.set(null)
appPreferences.initializationVectorDBPassphrase.set(null)
}
fun useDatabaseKey(): String {
Log.d(TAG, "useDatabaseKey ${appPreferences.storeDBPassphrase.get()}")
var dbKey = ""
val useKeychain = appPreferences.storeDBPassphrase.get()
if (useKeychain) {
if (!hasDatabase(SimplexApp.context.dataDir.absolutePath)) {
dbKey = randomDatabasePassword()
setDatabaseKey(dbKey)
appPreferences.initialRandomDBPassphrase.set(true)
} else {
dbKey = getDatabaseKey() ?: ""
}
}
return dbKey
}
private fun randomDatabasePassword(): String {
val s = ByteArray(32)
SecureRandom().nextBytes(s)
return s.toBase64String().replace("\n", "")
}
}
@Serializable
sealed class DBMigrationResult {
@Serializable @SerialName("ok") object OK: DBMigrationResult()
@Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult()
@Serializable @SerialName("error") class Error(val dbFile: String, val migrationError: String): DBMigrationResult()
@Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult()
@Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult()
}
@@ -28,6 +28,7 @@ fun DefaultBasicTextField(
modifier: Modifier,
initialValue: String,
placeholder: (@Composable () -> Unit)? = null,
leadingIcon: (@Composable () -> Unit)? = null,
focus: Boolean = false,
color: Color = MaterialTheme.colors.onBackground,
textStyle: TextStyle = TextStyle.Default,
@@ -44,8 +45,8 @@ fun DefaultBasicTextField(
LaunchedEffect(Unit) {
if (!focus) return@LaunchedEffect
delay(300)
focusRequester.requestFocus()
delay(200)
keyboard?.show()
}
val enabled = true
@@ -100,6 +101,7 @@ fun DefaultBasicTextField(
placeholder = placeholder,
singleLine = true,
enabled = enabled,
leadingIcon = leadingIcon,
interactionSource = interactionSource,
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
visualTransformation = VisualTransformation.None,
@@ -5,8 +5,7 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBackIos
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -18,7 +17,7 @@ import chat.simplex.app.ui.theme.*
@Composable
fun DefaultTopAppBar(
navigationButton: @Composable RowScope.() -> Unit,
title: @Composable () -> Unit,
title: (@Composable () -> Unit)?,
onTitleClick: (() -> Unit)? = null,
showSearch: Boolean,
onSearchValueChanged: (String) -> Unit,
@@ -33,7 +32,7 @@ fun DefaultTopAppBar(
modifier = modifier,
title = {
if (!showSearch) {
title()
title?.invoke()
} else {
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
}
@@ -41,7 +40,7 @@ fun DefaultTopAppBar(
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
navigationIcon = navigationButton,
buttons = if (!showSearch) buttons else emptyList(),
centered = !showSearch
centered = !showSearch,
)
}
@@ -91,7 +90,6 @@ private fun TopAppBar(
content = navigationIcon
)
}
Row(
Modifier
.fillMaxHeight()
@@ -118,6 +116,6 @@ private fun TopAppBar(
}
val AppBarHeight = 56.dp
private val AppBarHorizontalPadding = 4.dp
private val TitleInsetWithoutIcon = 16.dp - AppBarHorizontalPadding
private val TitleInsetWithIcon = 72.dp
val AppBarHorizontalPadding = 4.dp
private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding
val TitleInsetWithIcon = 72.dp
@@ -0,0 +1,38 @@
package chat.simplex.app.views.helpers
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.runtime.saveable.Saver
import kotlinx.coroutines.flow.MutableStateFlow
sealed class SharedContent {
data class Text(val text: String): SharedContent()
data class Image(val uri: Uri): SharedContent()
data class File(val uri: Uri): SharedContent()
}
enum class NewChatSheetState {
VISIBLE, HIDING, GONE;
fun isVisible(): Boolean {
return this == VISIBLE
}
fun isHiding(): Boolean {
return this == HIDING
}
fun isGone(): Boolean {
return this == GONE
}
companion object {
fun saver(): Saver<MutableStateFlow<NewChatSheetState>, *> = Saver(
save = { it.value.toString() },
restore = {
MutableStateFlow(valueOf(it))
}
)
}
}
sealed class UploadContent {
data class SimpleImage(val bitmap: Bitmap): UploadContent()
data class AnimatedImage(val uri: Uri): UploadContent()
}
@@ -0,0 +1,95 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.HighOrLowlight
@Composable
fun <T> ExposedDropDownSettingRow(
title: String,
values: List<Pair<T, String>>,
selection: State<T>,
label: String? = null,
icon: ImageVector? = null,
iconTint: Color = HighOrLowlight,
enabled: State<Boolean> = mutableStateOf(true),
onSelected: (T) -> Unit
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
var expanded by remember { mutableStateOf(false) }
if (icon != null) {
Icon(
icon,
"",
Modifier.padding(end = 8.dp),
tint = iconTint
)
}
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
Spacer(Modifier.fillMaxWidth().weight(1f))
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded && enabled.value
}
) {
Row(
Modifier.padding(start = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
Spacer(Modifier.size(12.dp))
Icon(
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
generalGetString(R.string.icon_descr_more_button),
tint = HighOrLowlight
)
}
ExposedDropdownMenu(
modifier = Modifier.widthIn(min = 200.dp),
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
values.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
onSelected(selectionOption.first)
expanded = false
}
) {
Text(
selectionOption.second + (if (label != null) " $label" else ""),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
@@ -19,24 +19,13 @@ package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.interaction.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.consumeDownChange
import androidx.compose.ui.input.pointer.isOutOfBounds
import androidx.compose.ui.input.pointer.positionChangeConsumed
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastAll
@@ -45,6 +34,8 @@ import androidx.compose.ui.util.fastForEach
import chat.simplex.app.TAG
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlin.math.PI
import kotlin.math.abs
/**
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
@@ -86,11 +77,7 @@ suspend fun PointerInputScope.detectGesture(
} else {
if (shouldConsume)
upOrCancel.consumeDownChange()
// If onLongPress event is needed, cancel short press event
if (onLongPress != null)
pressScope.cancel()
else
pressScope.release()
pressScope.release()
}
} catch (_: PointerEventTimeoutCancellationException) {
onLongPress?.invoke(down.position)
@@ -176,7 +163,7 @@ private class PressGestureScopeImpl(
if (!isReleased && !isCanceled) {
mutex.lock()
}
return isCanceled
return isReleased && !isCanceled
}
}
@@ -225,3 +212,65 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
}
return interactionSource
}
suspend fun PointerInputScope.detectTransformGestures(
panZoomLock: Boolean = false,
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
) {
forEachGesture {
awaitPointerEventScope {
var rotation = 0f
var zoom = 1f
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var lockedToPanZoom = false
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val canceled = event.changes.fastAny { it.isConsumed }
if (!canceled) {
val zoomChange = event.calculateZoom()
val rotationChange = event.calculateRotation()
val panChange = event.calculatePan()
if (!pastTouchSlop) {
zoom *= zoomChange
rotation += rotationChange
pan += panChange
val centroidSize = event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f)
val panMotion = pan.getDistance()
if (zoomMotion > touchSlop ||
rotationMotion > touchSlop ||
panMotion > touchSlop
) {
pastTouchSlop = true
lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
}
}
if (pastTouchSlop) {
val centroid = event.calculateCentroid(useCurrent = false)
val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
if (effectiveRotation != 0f ||
zoomChange != 1f ||
panChange != Offset.Zero
) {
onGesture(centroid, panChange, zoomChange, effectiveRotation)
}
event.changes.fastForEach {
if (it.positionChanged() && zoomChange != 1f) {
it.consume()
}
}
}
}
} while (!canceled && event.changes.fastAny { it.pressed })
}
}
}
@@ -157,6 +157,10 @@ fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLaun
fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
@Composable
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
@Composable
fun GetImageBottomSheet(
imageBitmap: MutableState<Bitmap?>,
@@ -0,0 +1,90 @@
package chat.simplex.app.views.helpers
import android.content.Context
import android.util.Log
import androidx.work.*
import chat.simplex.app.*
import chat.simplex.app.SimplexService.Companion.showPassphraseNotification
import kotlinx.coroutines.*
import java.util.Date
import java.util.concurrent.TimeUnit
object MessagesFetcherWorker {
private const val UNIQUE_WORK_TAG = BuildConfig.APPLICATION_ID + ".UNIQUE_MESSAGES_FETCHER"
fun scheduleWork(intervalSec: Int = 600, durationSec: Int = 60) {
val initialDelaySec = intervalSec.toLong()
Log.d(TAG, "Worker: scheduling work to run at ${Date(System.currentTimeMillis() + initialDelaySec * 1000)} for $durationSec sec")
val periodicWorkRequest = OneTimeWorkRequest.Builder(MessagesFetcherWork::class.java)
.setInitialDelay(initialDelaySec, TimeUnit.SECONDS)
.setInputData(
Data.Builder()
.putInt(MessagesFetcherWork.INPUT_DATA_INTERVAL, intervalSec)
.putInt(MessagesFetcherWork.INPUT_DATA_DURATION, durationSec)
.build()
)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(SimplexApp.context).enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest)
}
fun cancelAll() {
Log.d(TAG, "Worker: canceled all tasks")
WorkManager.getInstance(SimplexApp.context).cancelUniqueWork(UNIQUE_WORK_TAG)
}
}
class MessagesFetcherWork(
context: Context,
workerParams: WorkerParameters
): CoroutineWorker(context, workerParams) {
companion object {
const val INPUT_DATA_INTERVAL = "interval"
const val INPUT_DATA_DURATION = "duration"
private const val WAIT_AFTER_LAST_MESSAGE: Long = 10_000
}
override suspend fun doWork(): Result {
// Skip when Simplex service is currently working
if (SimplexService.getServiceState(SimplexApp.context) == SimplexService.ServiceState.STARTED) {
reschedule()
return Result.success()
}
val durationSeconds = inputData.getInt(INPUT_DATA_DURATION, 60)
var shouldReschedule = true
try {
withTimeout(durationSeconds * 1000L) {
val chatController = (applicationContext as SimplexApp).chatController
val chatDbStatus = chatController.chatModel.chatDbStatus.value
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(TAG, "Worker: problem with the database: $chatDbStatus")
showPassphraseNotification(chatDbStatus)
shouldReschedule = false
return@withTimeout
}
Log.w(TAG, "Worker: starting work")
// Give some time to start receiving messages
delay(10_000)
while (!isStopped) {
if (chatController.lastMsgReceivedTimestamp + WAIT_AFTER_LAST_MESSAGE < System.currentTimeMillis()) {
Log.d(TAG, "Worker: work is done")
break
}
delay(5000)
}
}
} catch (_: TimeoutCancellationException) { // When timeout happens
Log.d(TAG, "Worker: work is done (took $durationSeconds sec)")
} catch (_: CancellationException) { // When user opens the app while the worker is still working
Log.d(TAG, "Worker: interrupted")
} catch (e: Exception) {
Log.d(TAG, "Worker: unexpected exception: ${e.stackTraceToString()}")
}
if (shouldReschedule) reschedule()
return Result.success()
}
private fun reschedule() = MessagesFetcherWorker.scheduleWork()
}
@@ -2,22 +2,24 @@ package chat.simplex.app.views.helpers
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import chat.simplex.app.TAG
import chat.simplex.app.ui.theme.SettingsBackgroundLight
import chat.simplex.app.ui.theme.isInDarkTheme
import java.util.concurrent.atomic.AtomicBoolean
@Composable
fun ModalView(
close: () -> Unit,
background: Color = MaterialTheme.colors.background,
modifier: Modifier = Modifier.padding(horizontal = 16.dp),
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
BackHandler(onBack = close)
@@ -30,35 +32,106 @@ fun ModalView(
}
class ModalManager {
private val modalViews = arrayListOf<(@Composable (close: () -> Unit) -> Unit)?>()
private val modalViews = arrayListOf<Pair<Boolean, (@Composable (close: () -> Unit) -> Unit)>>()
private val modalCount = mutableStateOf(0)
private val toRemove = mutableSetOf<Int>()
private var oldViewChanging = AtomicBoolean(false)
fun showModal(content: @Composable () -> Unit) {
showCustomModal { close -> ModalView(close, content = content) }
fun showModal(settings: Boolean = false, content: @Composable () -> Unit) {
showCustomModal { close ->
ModalView(close, if (!settings || isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight, content = content)
}
}
fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
fun showModalCloseable(settings: Boolean = false, content: @Composable (close: () -> Unit) -> Unit) {
showCustomModal { close ->
ModalView(close, if (!settings || isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight, content = { content(close) })
}
}
fun showCustomModal(animated: Boolean = true, modal: @Composable (close: () -> Unit) -> Unit) {
Log.d(TAG, "ModalManager.showModal")
modalViews.add(modal)
modalCount.value = modalViews.count()
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
// This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view
if (toRemove.isNotEmpty()) {
runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } }
}
modalViews.add(animated to modal)
modalCount.value = modalViews.size - toRemove.size
}
fun closeModal() {
if (modalViews.isNotEmpty()) {
modalViews.removeAt(modalViews.count() - 1)
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
else runAtomically { toRemove.add(modalViews.lastIndex - toRemove.size) }
}
modalCount.value = modalViews.count()
modalCount.value = modalViews.size - toRemove.size
}
fun closeModals() {
while (modalViews.isNotEmpty()) closeModal()
while (modalCount.value > 0) closeModal()
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun showInView() {
if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal)
// Without animation
if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) {
modalViews.lastOrNull()?.second?.invoke(::closeModal)
return
}
AnimatedContent(targetState = modalCount.value,
transitionSpec = {
if (targetState > initialState) {
fromEndToStartTransition()
} else {
fromStartToEndTransition()
}.using(SizeTransform(clip = false))
}
) {
modalViews.getOrNull(it - 1)?.second?.invoke(::closeModal)
// This is needed because if we delete from modalViews immediately on request, animation will be bad
if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) {
runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } }
}
}
}
/**
* Allows to modify a list without getting [ConcurrentModificationException]
* */
private fun runAtomically(atomicBoolean: AtomicBoolean = oldViewChanging, block: () -> Unit) {
while (!atomicBoolean.compareAndSet(false, true)) {
Thread.sleep(10)
}
block()
atomicBoolean.set(false)
}
@OptIn(ExperimentalAnimationApi::class)
private fun fromStartToEndTransition() =
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth },
animationSpec = animationSpec()
) with slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = animationSpec()
)
@OptIn(ExperimentalAnimationApi::class)
private fun fromEndToStartTransition() =
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = animationSpec()
) with slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth },
animationSpec = animationSpec()
)
private fun <T> animationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
// private fun <T> animationSpecFromStart() = tween<T>(durationMillis = 150, easing = FastOutLinearInEasing)
// private fun <T> animationSpecFromEnd() = tween<T>(durationMillis = 100, easing = FastOutSlowInEasing)
companion object {
val shared = ModalManager()
}
@@ -11,6 +11,7 @@ import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -20,8 +21,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.input.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
@@ -30,7 +30,7 @@ import kotlinx.coroutines.delay
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) {
var searchText by remember { mutableStateOf("") }
var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
@@ -61,7 +61,7 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str
),
onValueChange = {
searchText = it
onValueChange(it)
onValueChange(it.text)
},
cursorBrush = SolidColor(colors.cursorColor(false).value),
visualTransformation = VisualTransformation.None,
@@ -75,13 +75,13 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str
interactionSource = interactionSource,
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = searchText,
value = searchText.text,
innerTextField = innerTextField,
placeholder = {
Text(placeholder)
},
trailingIcon = if (searchText.isNotEmpty()) {{
IconButton({ searchText = ""; onValueChange("") }) {
trailingIcon = if (searchText.text.isNotEmpty()) {{
IconButton({ searchText = TextFieldValue(""); onValueChange("") }) {
Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
}
}} else null,
@@ -1,37 +1,67 @@
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ValueTitleDesc
import chat.simplex.app.views.helpers.ValueTitle
@Composable
fun SectionView(title: String? = null, content: (@Composable () -> Unit)) {
fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(), content: (@Composable ColumnScope.() -> Unit)) {
Column {
if (title != null) {
Text(
title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), fontSize = 12.sp
)
}
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
Column(Modifier.padding(horizontal = 6.dp).fillMaxWidth()) { content() }
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
}
}
}
@Composable
fun SectionItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) {
fun <T> SectionViewSelectable(
title: String?,
currentValue: State<T>,
values: List<ValueTitleDesc<T>>,
onSelected: (T) -> Unit,
) {
SectionView(title) {
LazyColumn {
items(values.size) { index ->
val item = values[index]
SectionItemViewSpaceBetween({ onSelected(item.value) }) {
Text(item.title)
if (currentValue.value == item.value) {
Icon(Icons.Outlined.Check, item.title, tint = MaterialTheme.colors.primary)
}
}
Spacer(Modifier.padding(horizontal = 4.dp))
}
}
}
SectionTextFooter(values.first { it.value == currentValue.value }.description)
}
@Composable
fun SectionItemView(click: (() -> Unit)? = null, minHeight: Dp = 46.dp, disabled: Boolean = false, content: (@Composable RowScope.() -> Unit)) {
val modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth()
.height(height)
.sizeIn(minHeight = minHeight)
Row(
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
if (click == null || disabled) modifier.padding(horizontal = DEFAULT_PADDING) else modifier.clickable(onClick = click).padding(horizontal = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically
) {
content()
@@ -41,17 +71,16 @@ fun SectionItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: B
@Composable
fun SectionItemViewSpaceBetween(
click: (() -> Unit)? = null,
height: Dp = 46.dp,
padding: PaddingValues = PaddingValues(horizontal = 8.dp),
minHeight: Dp = 46.dp,
padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING),
disabled: Boolean = false,
content: (@Composable () -> Unit)
content: (@Composable RowScope.() -> Unit)
) {
val modifier = Modifier
.padding(padding)
.fillMaxWidth()
.height(height)
.sizeIn(minHeight = minHeight)
Row(
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
if (click == null || disabled) modifier.padding(padding) else modifier.clickable(onClick = click).padding(padding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -59,18 +88,58 @@ fun SectionItemViewSpaceBetween(
}
}
@Composable
fun <T> SectionItemWithValue(
title: String,
currentValue: State<T>,
values: List<ValueTitle<T>>,
label: String? = null,
icon: ImageVector? = null,
iconTint: Color = HighOrLowlight,
enabled: State<Boolean> = mutableStateOf(true),
onSelected: () -> Unit
) {
SectionItemView(click = if (enabled.value) onSelected else null) {
if (icon != null) {
Icon(
icon,
title,
Modifier.padding(end = 8.dp),
tint = iconTint
)
}
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
Spacer(Modifier.fillMaxWidth().weight(1f))
Row(
Modifier.padding(start = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
values.first { it.value == currentValue.value }.title + (if (label != null) " $label" else ""),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
}
}
}
@Composable
fun SectionTextFooter(text: String) {
Text(
text,
Modifier.padding(horizontal = 16.dp).padding(top = 5.dp).fillMaxWidth(0.9F),
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
color = HighOrLowlight,
fontSize = 12.sp
lineHeight = 18.sp,
fontSize = 14.sp
)
}
@Composable
fun SectionCustomFooter(padding: PaddingValues = PaddingValues(start = 16.dp, end = 16.dp, top = 5.dp), content: (@Composable () -> Unit)) {
fun SectionCustomFooter(padding: PaddingValues = PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = 5.dp), content: (@Composable () -> Unit)) {
Row(
Modifier.padding(padding)
) {
@@ -90,37 +159,25 @@ fun SectionSpacer() {
@Composable
fun InfoRow(title: String, value: String) {
SectionItemView {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(title)
Text(value, color = HighOrLowlight)
}
SectionItemViewSpaceBetween {
Text(title)
Text(value, color = HighOrLowlight)
}
}
@Composable
fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) {
SectionItemView {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val configuration = LocalConfiguration.current
Text(title)
Text(value,
Modifier
.padding(start = 10.dp)
.widthIn(max = (configuration.screenWidthDp / 2).dp)
.clickable(onClick = onClick),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
}
SectionItemViewSpaceBetween(onClick) {
val configuration = LocalConfiguration.current
Text(title)
Text(
value,
Modifier
.padding(start = 10.dp)
.widthIn(max = (configuration.screenWidthDp / 2).dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
}
}
@@ -57,8 +57,17 @@ fun saveImage(cxt: Context, ciFile: CIFile?) {
val fileName = ciFile?.fileName
if (filePath != null && fileName != null) {
val values = ContentValues()
val lowercaseName = fileName.lowercase()
val mimeType = when {
lowercaseName.endsWith(".png") -> "image/png"
lowercaseName.endsWith(".gif") -> "image/gif"
lowercaseName.endsWith(".webp") -> "image/webp"
lowercaseName.endsWith(".avif") -> "image/avif"
lowercaseName.endsWith(".svg") -> "image/svg+xml"
else -> "image/jpeg"
}
values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
values.put(MediaStore.Images.Media.MIME_TYPE, mimeType)
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
values.put(MediaStore.MediaColumns.TITLE, fileName)
val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
@@ -1,5 +1,7 @@
package chat.simplex.app.views.helpers
import android.R.attr.factor
import android.R.color
import android.content.Context
import android.content.res.Resources
import android.graphics.*
@@ -10,6 +12,7 @@ import android.provider.OpenableColumns
import android.text.Spanned
import android.text.SpannedString
import android.text.style.*
import android.util.Base64
import android.util.Log
import android.view.ViewTreeObserver
import androidx.annotation.StringRes
@@ -23,8 +26,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.*
import androidx.core.content.FileProvider
import androidx.core.text.HtmlCompat
import chat.simplex.app.BuildConfig
import chat.simplex.app.SimplexApp
import chat.simplex.app.*
import chat.simplex.app.model.CIFile
import kotlinx.coroutines.*
import java.io.*
@@ -212,6 +214,7 @@ private fun spannableStringToAnnotatedString(
// maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE: Long = 236700
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_FILE_SIZE: Long = 8000000
fun getFilesDirectory(context: Context): String {
@@ -320,6 +323,32 @@ fun saveImage(context: Context, image: Bitmap): String? {
}
}
fun saveAnimImage(context: Context, uri: Uri): String? {
return try {
val filename = getFileName(context, uri)?.lowercase()
var ext = when {
// remove everything but extension
filename?.contains(".") == true -> filename.replaceBeforeLast('.', "").replace(".", "")
else -> "gif"
}
// Just in case the image has a strange extension
if (ext.length < 3 || ext.length > 4) ext = "gif"
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext")
val file = File(getAppFilePath(context, fileToSave))
val output = FileOutputStream(file)
context.contentResolver.openInputStream(uri)!!.use { input ->
output.use { output ->
input.copyTo(output)
}
}
fileToSave
} catch (e: Exception) {
Log.e(chat.simplex.app.TAG, "Util.kt saveAnimImage error: ${e.message}")
null
}
}
fun saveFileFromUri(context: Context, uri: Uri): String? {
return try {
val inputStream = context.contentResolver.openInputStream(uri)
@@ -376,3 +405,35 @@ fun removeFile(context: Context, fileName: String): Boolean {
}
return fileDeleted
}
fun deleteAppFiles(context: Context) {
val dir = File(getAppFilesDirectory(context))
try {
dir.list()?.forEach {
removeFile(context, it)
}
} catch (e: java.lang.Exception) {
Log.e(TAG, "Util deleteAppFiles error: ${e.stackTraceToString()}")
}
}
fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in bytes
var fileCount = 0
var bytes = 0L
try {
File(dir).listFiles()?.forEach {
fileCount++
bytes += it.length()
}
} catch (e: java.lang.Exception) {
Log.e(TAG, "Util directoryFileCountAndSize error: ${e.stackTraceToString()}")
}
return fileCount to bytes
}
fun Color.darker(factor: Float = 0.1f): Color =
Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha)
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
@@ -2,17 +2,18 @@ package chat.simplex.app.views.newchat
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -20,61 +21,69 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.views.helpers.shareText
import chat.simplex.app.views.helpers.*
@Composable
fun AddContactView(chatModel: ChatModel) {
val connReq = chatModel.connReqInvitation
if (connReq != null) {
val cxt = LocalContext.current
AddContactLayout(
chatModelIncognito = chatModel.incognito.value,
connReq = connReq,
share = { shareText(cxt, connReq) }
)
fun AddContactView(chatModel: ChatModel, connReqInvitation: String, connIncognito: Boolean) {
val cxt = LocalContext.current
LaunchedEffect(connReqInvitation) {
if (connReqInvitation.isNotEmpty()) {
chatModel.connReqInv.value = connReqInvitation
}
}
DisposableEffect(Unit) {
onDispose { chatModel.connReqInv.value = null }
}
AddContactLayout(
connReq = connReqInvitation,
connIncognito = connIncognito,
share = { shareText(cxt, connReqInvitation) }
)
}
@Composable
fun AddContactLayout(chatModelIncognito: Boolean, connReq: String, share: () -> Unit) {
fun AddContactLayout(connReq: String, connIncognito: Boolean, share: () -> Unit) {
BoxWithConstraints {
val screenHeight = maxHeight
Column(
Modifier.padding(bottom = 16.dp),
Modifier
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.add_contact),
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
modifier = Modifier
.padding(vertical = 5.dp)
.padding(horizontal = 8.dp)
)
AppBarTitle(stringResource(R.string.add_contact), false)
Text(
stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
modifier = Modifier.padding(horizontal = 8.dp)
)
Row(Modifier.padding(horizontal = 8.dp)) {
Row {
InfoAboutIncognito(
chatModelIncognito,
connIncognito,
true,
generalGetString(R.string.incognito_random_profile_description),
generalGetString(R.string.your_profile_will_be_sent)
)
}
QRCode(
connReq, Modifier
.weight(1f, fill = false)
.aspectRatio(1f)
.padding(vertical = 3.dp)
)
if (connReq.isNotEmpty()) {
QRCode(
connReq, Modifier
.aspectRatio(1f)
.padding(vertical = 3.dp)
)
} else {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp)
.align(Alignment.CenterHorizontally),
color = HighOrLowlight,
strokeWidth = 3.dp
)
}
Text(
stringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
annotatedStringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
lineHeight = 22.sp,
modifier = Modifier
.padding(horizontal = 8.dp)
.padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp)
.padding(bottom = if (screenHeight > 600.dp) 8.dp else 0.dp)
)
Row(
Modifier.fillMaxWidth(),
@@ -131,8 +140,8 @@ fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean
fun PreviewAddContactView() {
SimpleXTheme {
AddContactLayout(
chatModelIncognito = false,
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
connIncognito = false,
share = {}
)
}
@@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -25,11 +24,11 @@ import chat.simplex.app.views.chat.group.AddGroupMembersView
import chat.simplex.app.views.chatlist.setGroupMembers
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.isValidDisplayName
import chat.simplex.app.views.onboarding.ReadableText
import chat.simplex.app.views.usersettings.DeleteImageButton
import chat.simplex.app.views.usersettings.EditImageButton
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@@ -45,13 +44,8 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
chatModel.chatId.value = groupInfo.id
setGroupMembers(groupInfo, chatModel)
close.invoke()
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AddGroupMembersView(groupInfo, chatModel, close)
}
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, chatModel, close)
}
}
}
@@ -85,71 +79,67 @@ fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> U
sheetState = bottomSheetModalState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
ModalView(close) {
Surface(Modifier.background(MaterialTheme.colors.onBackground).fillMaxSize()) {
Column(
ModalView(close = close) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.create_secret_group_title), false)
Text(stringResource(R.string.group_is_decentralized))
InfoAboutIncognito(
chatModelIncognito,
false,
generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
generalGetString(R.string.group_main_profile_sent)
)
Box(
Modifier
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
.fillMaxWidth()
.padding(bottom = 24.dp),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.create_secret_group_title),
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
modifier = Modifier.padding(vertical = 5.dp)
)
Text(stringResource(R.string.group_is_decentralized))
InfoAboutIncognito(
chatModelIncognito,
false,
generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
generalGetString(R.string.group_main_profile_sent)
)
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
contentAlignment = Alignment.Center
) {
Box(contentAlignment = Alignment.TopEnd) {
Box(contentAlignment = Alignment.Center) {
ProfileImage(size = 192.dp, image = profileImage.value)
EditImageButton { scope.launch { bottomSheetModalState.show() } }
}
if (profileImage.value != null) {
DeleteImageButton { profileImage.value = null }
}
Box(contentAlignment = Alignment.TopEnd) {
Box(contentAlignment = Alignment.Center) {
ProfileImage(size = 192.dp, image = profileImage.value)
EditImageButton { scope.launch { bottomSheetModalState.show() } }
}
if (profileImage.value != null) {
DeleteImageButton { profileImage.value = null }
}
}
Text(
stringResource(R.string.group_display_name_field),
Modifier.padding(bottom = 3.dp)
)
ProfileNameField(displayName, focusRequester)
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
Text(
errorText,
fontSize = 15.sp,
color = MaterialTheme.colors.error
)
Spacer(Modifier.height(3.dp))
Text(
stringResource(R.string.group_full_name_field),
Modifier.padding(bottom = 5.dp)
)
ProfileNameField(fullName)
}
Text(
stringResource(R.string.group_display_name_field),
Modifier.padding(bottom = 3.dp)
)
ProfileNameField(displayName, focusRequester)
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
Text(
errorText,
fontSize = 15.sp,
color = MaterialTheme.colors.error
)
Spacer(Modifier.height(3.dp))
Text(
stringResource(R.string.group_full_name_field),
Modifier.padding(bottom = 5.dp)
)
ProfileNameField(fullName)
Spacer(Modifier.height(8.dp))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
if (enabled) {
CreateGroupButton(MaterialTheme.colors.primary, Modifier
.clickable { createGroup(GroupProfile(displayName.value, fullName.value, profileImage.value)) }
.padding(8.dp))
} else {
CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp))
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Spacer(Modifier.height(8.dp))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
if (enabled) {
CreateGroupButton(MaterialTheme.colors.primary, Modifier
.clickable { createGroup(GroupProfile(displayName.value, fullName.value, profileImage.value)) }
.padding(8.dp))
} else {
CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp))
}
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
}
}
@@ -0,0 +1,71 @@
package chat.simplex.app.views.newchat
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.HighOrLowlight
enum class ConnectViaLinkTab {
SCAN, PASTE
}
@Composable
fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) {
val selection = remember {
mutableStateOf(
runCatching { ConnectViaLinkTab.valueOf(m.controller.appPrefs.connectViaLinkTab.get()!!) }.getOrDefault(ConnectViaLinkTab.SCAN)
)
}
val tabTitles = ConnectViaLinkTab.values().map {
when (it) {
ConnectViaLinkTab.SCAN -> stringResource(R.string.scan_QR_code)
ConnectViaLinkTab.PASTE -> stringResource(R.string.paste_the_link_you_received)
}
}
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
) {
Column(Modifier.weight(1f)) {
when (selection.value) {
ConnectViaLinkTab.SCAN -> {
ScanToConnectView(m, close)
}
ConnectViaLinkTab.PASTE -> {
PasteToConnectView(m, close)
}
}
}
TabRow(
selectedTabIndex = selection.value.ordinal,
backgroundColor = MaterialTheme.colors.background,
contentColor = MaterialTheme.colors.primary,
) {
tabTitles.forEachIndexed { index, it ->
Tab(
selected = selection.value.ordinal == index,
onClick = {
selection.value = ConnectViaLinkTab.values()[index]
m.controller.appPrefs.connectViaLinkTab.set(selection.value .name)
},
text = { Text(it, fontSize = 13.sp) },
icon = {
Icon(
if (ConnectViaLinkTab.SCAN.ordinal == index) Icons.Outlined.QrCode else Icons.Outlined.Article,
it
)
},
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = HighOrLowlight,
)
}
}
}
}
@@ -0,0 +1,163 @@
package chat.simplex.app.views.newchat
import SectionDivider
import SectionView
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.LocalAliasEditor
import chat.simplex.app.views.chatlist.deleteContactConnectionAlert
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
@Composable
fun ContactConnectionInfoView(
chatModel: ChatModel,
connReqInvitation: String?,
contactConnection: PendingContactConnection,
focusAlias: Boolean,
close: () -> Unit
) {
/** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. It will be managed by [AddContactView] itself
* Otherwise it will be called here AFTER [AddContactView] is launched and will clear the value too soon */
val allowDispose = remember { mutableStateOf(true) }
LaunchedEffect(connReqInvitation) {
allowDispose.value = true
chatModel.connReqInv.value = connReqInvitation
}
DisposableEffect(Unit) {
onDispose {
if (allowDispose.value) {
chatModel.connReqInv.value = null
}
}
}
ContactConnectionInfoLayout(
connReq = connReqInvitation,
contactConnection.localAlias,
contactConnection.initiated,
contactConnection.viaContactUri,
contactConnection.incognito,
focusAlias,
deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) },
onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) },
showQr = {
allowDispose.value = false
ModalManager.shared.showModal {
Column(
Modifier
.fillMaxHeight()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.SpaceBetween
) {
AddContactView(chatModel, connReqInvitation ?: return@showModal, contactConnection.incognito)
}
}
}
)
}
@Composable
private fun ContactConnectionInfoLayout(
connReq: String?,
localAlias: String,
connectionInitiated: Boolean,
connectionViaContactUri: Boolean,
connectionIncognito: Boolean,
focusAlias: Boolean,
deleteConnection: () -> Unit,
onLocalAliasChanged: (String) -> Unit,
showQr: () -> Unit,
) {
Column(
Modifier
.verticalScroll(rememberScrollState()),
) {
AppBarTitle(
stringResource(
if (connectionInitiated) R.string.you_invited_your_contact
else R.string.you_accepted_connection
)
)
Row(Modifier.padding(bottom = DEFAULT_PADDING)) {
LocalAliasEditor(localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged)
}
Text(
stringResource(
if (connectionViaContactUri) R.string.you_will_be_connected_when_your_connection_request_is_accepted
else R.string.you_will_be_connected_when_your_contacts_device_is_online
),
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
)
SectionView {
if (!connReq.isNullOrEmpty() && connectionInitiated) {
ShowQrButton(connectionIncognito, showQr)
SectionDivider()
}
DeleteButton(deleteConnection)
}
}
}
@Composable
fun ShowQrButton(incognito: Boolean, onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.QrCode,
stringResource(R.string.show_QR_code),
click = onClick,
textColor = if (incognito) Indigo else MaterialTheme.colors.primary,
iconColor = if (incognito) Indigo else MaterialTheme.colors.primary,
)
}
@Composable
fun DeleteButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Delete,
stringResource(R.string.delete_verb),
click = onClick,
textColor = Color.Red,
iconColor = Color.Red,
)
}
private fun setContactAlias(contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withApi {
chatModel.controller.apiSetConnectionAlias(contactConnection.pccConnId, localAlias)?.let {
chatModel.updateContactConnection(it)
}
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
private fun PreviewContactConnectionInfoView() {
SimpleXTheme {
ContactConnectionInfoLayout(
localAlias = "",
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
connectionInitiated = true,
connectionViaContactUri = true,
connectionIncognito = false,
focusAlias = false,
deleteConnection = {},
onLocalAliasChanged = {},
showQr = {},
)
}
}
@@ -0,0 +1,92 @@
package chat.simplex.app.views.newchat
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.usersettings.UserAddressView
enum class CreateLinkTab {
ONE_TIME, LONG_TERM
}
@Composable
fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
val selection = remember { mutableStateOf(initialSelection) }
val connReqInvitation = remember { mutableStateOf("") }
val creatingConnReq = remember { mutableStateOf(false) }
LaunchedEffect(selection.value) {
if (selection.value == CreateLinkTab.ONE_TIME && connReqInvitation.value.isEmpty() && !creatingConnReq.value) {
createInvitation(m, creatingConnReq, connReqInvitation)
}
}
val tabTitles = CreateLinkTab.values().map {
when {
it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isEmpty() -> stringResource(R.string.create_one_time_link)
it == CreateLinkTab.ONE_TIME -> stringResource(R.string.one_time_link)
it == CreateLinkTab.LONG_TERM -> stringResource(R.string.your_contact_address)
else -> ""
}
}
Column(
Modifier
.fillMaxHeight()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.SpaceBetween
) {
Column(Modifier.weight(1f)) {
when (selection.value) {
CreateLinkTab.ONE_TIME -> {
AddContactView(m, connReqInvitation.value, m.incognito.value)
}
CreateLinkTab.LONG_TERM -> {
UserAddressView(m)
}
}
}
TabRow(
selectedTabIndex = selection.value.ordinal,
backgroundColor = MaterialTheme.colors.background,
contentColor = MaterialTheme.colors.primary,
) {
tabTitles.forEachIndexed { index, it ->
Tab(
selected = selection.value.ordinal == index,
onClick = {
selection.value = CreateLinkTab.values()[index]
},
text = { Text(it, fontSize = 13.sp) },
icon = {
Icon(
if (CreateLinkTab.ONE_TIME.ordinal == index) Icons.Outlined.RepeatOne else Icons.Outlined.AllInclusive,
it
)
},
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = HighOrLowlight,
)
}
}
}
}
private fun createInvitation(m: ChatModel, creatingConnReq: MutableState<Boolean>, connReqInvitation: MutableState<String>) {
creatingConnReq.value = true
withApi {
val connReq = m.controller.apiAddContact()
if (connReq != null) {
connReqInvitation.value = connReq
} else {
creatingConnReq.value = false
}
}
}
@@ -1,145 +1,171 @@
package chat.simplex.app.views.newchat
import android.Manifest
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.core.graphics.ColorUtils
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chatlist.ScaffoldController
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.rememberPermissionState
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<NewChatSheetState>, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) {
if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) }
NewChatSheetLayout(
newChatSheetState,
stopped,
addContact = {
withApi {
// show spinner
chatModel.connReqInvitation = chatModel.controller.apiAddContact()
// hide spinner
if (chatModel.connReqInvitation != null) {
newChatCtrl.collapse()
ModalManager.shared.showModal { AddContactView(chatModel) }
}
}
closeNewChatSheet(false)
ModalManager.shared.showModal { CreateLinkView(chatModel, CreateLinkTab.ONE_TIME) }
},
scanCode = {
newChatCtrl.collapse()
ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) }
cameraPermissionState.launchPermissionRequest()
},
pasteLink = {
newChatCtrl.collapse()
ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
connectViaLink = {
closeNewChatSheet(false)
ModalManager.shared.showModalCloseable { close -> ConnectViaLinkView(chatModel, close) }
},
createGroup = {
newChatCtrl.collapse()
closeNewChatSheet(false)
ModalManager.shared.showCustomModal { close -> AddGroupView(chatModel, close) }
}
},
closeNewChatSheet,
)
}
private val titles = listOf(R.string.share_one_time_link, R.string.connect_via_link_or_qr, R.string.create_group)
private val icons = listOf(Icons.Outlined.AddLink, Icons.Outlined.QrCode, Icons.Outlined.Group)
@Composable
fun NewChatSheetLayout(
private fun NewChatSheetLayout(
newChatSheetState: StateFlow<NewChatSheetState>,
stopped: Boolean,
addContact: () -> Unit,
scanCode: () -> Unit,
pasteLink: () -> Unit,
createGroup: () -> Unit
connectViaLink: () -> Unit,
createGroup: () -> Unit,
closeNewChatSheet: (animated: Boolean) -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.add_contact_or_create_group),
modifier = Modifier.padding(horizontal = 4.dp).padding(top = 20.dp, bottom = 20.dp),
style = MaterialTheme.typography.body2
var newChat by remember { mutableStateOf(newChatSheetState.value) }
val resultingColor = if (isInDarkTheme()) Color.Black.copy(0.64f) else DrawerDefaults.scrimColor
val animatedColor = remember {
Animatable(
if (newChat.isVisible()) Color.Transparent else resultingColor,
Color.VectorConverter(resultingColor.colorSpace)
)
val boxModifier = Modifier.fillMaxWidth().height(80.dp).padding(horizontal = 0.dp)
Divider(Modifier.padding(horizontal = 8.dp))
Box(boxModifier) {
ActionRowButton(
stringResource(R.string.create_one_time_link),
stringResource(R.string.to_share_with_your_contact),
Icons.Outlined.AddLink,
click = addContact
)
}
Divider(Modifier.padding(horizontal = 8.dp))
Box(boxModifier) {
ActionRowButton(
stringResource(R.string.paste_received_link),
stringResource(R.string.paste_received_link_from_clipboard),
Icons.Outlined.Article,
click = pasteLink
)
}
Divider(Modifier.padding(horizontal = 8.dp))
Box(boxModifier) {
ActionRowButton(
stringResource(R.string.scan_QR_code),
stringResource(R.string.in_person_or_in_video_call__bracketed),
Icons.Outlined.QrCode,
click = scanCode
)
}
Divider(Modifier.padding(horizontal = 8.dp))
Box(boxModifier) {
ActionRowButton(
stringResource(R.string.create_group),
stringResource(R.string.only_stored_on_members_devices),
icon = Icons.Outlined.Group,
click = createGroup
)
}
}
}
@Composable
fun ActionRowButton(
text: String, comment: String? = null, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}
) {
Surface(Modifier.fillMaxSize()) {
Row(
Modifier.clickable(onClick = click).size(48.dp).padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Icon(icon, text, tint = tint, modifier = Modifier.size(48.dp).padding(start = 4.dp, end = 16.dp))
Column {
Text(
text,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
color = tint
)
if (comment != null) {
Text(
comment,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2
)
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
LaunchedEffect(Unit) {
launch {
newChatSheetState.collect {
newChat = it
launch {
animatedColor.animateTo(if (newChat.isVisible()) resultingColor else Color.Transparent, newChatSheetAnimSpec())
}
launch {
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
if (newChat.isHiding()) closeNewChatSheet(false)
}
}
}
}
val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density }
Column(
Modifier
.fillMaxSize()
.offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else 0, 0) }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { closeNewChatSheet(true) }
.drawBehind { drawRect(animatedColor.value) },
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End
) {
val actions = remember { listOf(addContact, connectViaLink, createGroup) }
val backgroundColor = if (isInDarkTheme())
Color(ColorUtils.blendARGB(MaterialTheme.colors.primary.toArgb(), Color.Black.toArgb(), 0.7F))
else
MaterialTheme.colors.background
LazyColumn(Modifier
.graphicsLayer {
alpha = animatedFloat.value
translationY = (1 - animatedFloat.value) * 20.dp.toPx()
}) {
items(actions.size) { index ->
Row {
Spacer(Modifier.weight(1f))
Box(contentAlignment = Alignment.CenterEnd) {
Button(
actions[index],
shape = RoundedCornerShape(21.dp),
colors = ButtonDefaults.textButtonColors(backgroundColor = backgroundColor),
elevation = null,
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF),
modifier = Modifier.height(42.dp)
) {
Text(
stringResource(titles[index]),
Modifier.padding(start = DEFAULT_PADDING_HALF),
color = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary,
fontWeight = FontWeight.Medium,
)
Icon(
icons[index],
stringResource(titles[index]),
Modifier.size(42.dp),
tint = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary
)
}
}
Spacer(Modifier.width(DEFAULT_PADDING))
}
Spacer(Modifier.height(DEFAULT_PADDING))
}
}
FloatingActionButton(
onClick = { if (!stopped) closeNewChatSheet(true) },
Modifier.padding(end = 16.dp, bottom = 16.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else HighOrLowlight,
contentColor = Color.White
) {
Icon(
Icons.Default.Edit, stringResource(R.string.add_contact_or_create_group),
Modifier.graphicsLayer { alpha = 1 - animatedFloat.value }
)
Icon(
Icons.Default.Close, stringResource(R.string.add_contact_or_create_group),
Modifier.graphicsLayer { alpha = animatedFloat.value }
)
}
}
}
@Composable
@@ -158,11 +184,13 @@ fun ActionButton(
horizontalAlignment = Alignment.CenterHorizontally
) {
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Icon(icon, text,
Icon(
icon, text,
tint = tint,
modifier = Modifier
.size(40.dp)
.padding(bottom = 8.dp))
.padding(bottom = 8.dp)
)
if (text != null) {
Text(
text,
@@ -185,13 +213,15 @@ fun ActionButton(
@Preview
@Composable
fun PreviewNewChatSheet() {
private fun PreviewNewChatSheet() {
SimpleXTheme {
NewChatSheetLayout(
MutableStateFlow(NewChatSheetState.VISIBLE),
stopped = false,
addContact = {},
scanCode = {},
pasteLink = {},
createGroup = {}
connectViaLink = {},
createGroup = {},
closeNewChatSheet = {},
)
}
}
@@ -3,44 +3,42 @@ package chat.simplex.app.views.newchat
import android.content.ClipboardManager
import android.content.res.Configuration
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.getSystemService
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
val connectionLink = remember { mutableStateOf("")}
val connectionLink = remember { mutableStateOf("") }
val context = LocalContext.current
val clipboard = getSystemService(context, ClipboardManager::class.java)
BackHandler(onBack = close)
PasteToConnectLayout(
chatModel.incognito.value,
connectionLink = connectionLink,
pasteFromClipboard = {
connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as String
connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as? String ?: return@PasteToConnectLayout
},
connectViaLink = { connReqUri ->
try {
val uri = Uri.parse(connReqUri)
withUriAction(uri) { action ->
connectViaUri(chatModel, action, uri)
if (connectViaUri(chatModel, action, uri)) {
close()
}
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
@@ -48,9 +46,7 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
text = generalGetString(R.string.this_string_is_not_a_connection_link)
)
}
close()
},
close = close
)
}
@@ -60,52 +56,45 @@ fun PasteToConnectLayout(
connectionLink: MutableState<String>,
pasteFromClipboard: () -> Unit,
connectViaLink: (String) -> Unit,
close: () -> Unit
) {
ModalView(close) {
Column(
Modifier.padding(bottom = 16.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.connect_via_link),
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
modifier = Modifier.padding(vertical = 5.dp)
)
Text(stringResource(R.string.paste_connection_link_below_to_connect))
Column(
Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.SpaceBetween,
) {
AppBarTitle(stringResource(R.string.connect_via_link), false)
Text(stringResource(R.string.paste_connection_link_below_to_connect))
InfoAboutIncognito(
chatModelIncognito,
true,
generalGetString(R.string.incognito_random_profile_from_contact_description),
generalGetString(R.string.profile_will_be_sent_to_contact_sending_link)
)
InfoAboutIncognito(
chatModelIncognito,
true,
generalGetString(R.string.incognito_random_profile_from_contact_description),
generalGetString(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))
Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) {
TextEditor(Modifier.height(180.dp), text = connectionLink)
}
Row(
Modifier.fillMaxWidth().padding(bottom = 6.dp),
horizontalArrangement = Arrangement.Start,
) {
if (connectionLink.value == "") {
SimpleButton(text = stringResource(R.string.paste_button), icon = Icons.Outlined.ContentPaste) {
pasteFromClipboard()
}
} else {
SimpleButton(text = stringResource(R.string.clear_verb), icon = Icons.Outlined.Clear) {
connectionLink.value = ""
}
}
Spacer(Modifier.weight(1f).fillMaxWidth())
SimpleButton(text = stringResource(R.string.connect_button), icon = Icons.Outlined.Link) {
connectViaLink(connectionLink.value)
}
}
Text(annotatedStringResource(R.string.you_can_also_connect_by_clicking_the_link))
}
}
@@ -130,7 +119,6 @@ fun PreviewPasteToConnectTextbox() {
e.printStackTrace()
}
},
close = {}
)
}
}
@@ -1,25 +1,32 @@
package chat.simplex.app.views.newchat
import android.Manifest
import android.content.res.Configuration
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberPermissionState
@Composable
fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
ConnectContactLayout(
chatModelIncognito = chatModel.incognito.value,
qrCodeScanner = {
@@ -27,7 +34,9 @@ fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
try {
val uri = Uri.parse(connReqUri)
withUriAction(uri) { action ->
connectViaUri(chatModel, action, uri)
if (connectViaUri(chatModel, action, uri)) {
close()
}
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
@@ -35,10 +44,8 @@ fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
text = generalGetString(R.string.this_QR_code_is_not_a_link)
)
}
close()
}
},
close = close
)
}
@@ -54,7 +61,7 @@ fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
}
}
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri): Boolean {
val r = chatModel.controller.apiConnect(uri.toString())
if (r) {
AlertManager.shared.showAlertMsg(
@@ -64,36 +71,31 @@ suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
else generalGetString(R.string.you_will_be_connected_when_your_contacts_device_is_online)
)
}
return r
}
@Composable
fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
ModalView(close) {
Column(
Modifier.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
generalGetString(R.string.scan_QR_code),
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
modifier = Modifier.padding(vertical = 5.dp)
)
InfoAboutIncognito(
chatModelIncognito,
true,
generalGetString(R.string.incognito_random_profile_description),
generalGetString(R.string.your_profile_will_be_sent)
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) { qrCodeScanner() }
Text(
annotatedStringResource(R.string.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link),
lineHeight = 22.sp
)
}
fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable () -> Unit) {
Column(
Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
AppBarTitle(stringResource(R.string.scan_QR_code), false)
InfoAboutIncognito(
chatModelIncognito,
true,
generalGetString(R.string.incognito_random_profile_description),
generalGetString(R.string.your_profile_will_be_sent)
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) { qrCodeScanner() }
Text(
annotatedStringResource(R.string.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link),
lineHeight = 22.sp
)
}
}
@@ -109,7 +111,6 @@ fun PreviewConnectContactLayout() {
ConnectContactLayout(
chatModelIncognito = false,
qrCodeScanner = { Surface {} },
close = {},
)
}
}
@@ -4,7 +4,6 @@ import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
@@ -17,14 +16,18 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.annotatedStringResource
import chat.simplex.app.views.helpers.*
@Composable
fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = null) {
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
Text(stringResource(R.string.how_simplex_works), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = 8.dp))
Column(Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start
) {
AppBarTitle(stringResource(R.string.how_simplex_works), false)
ReadableText(R.string.many_people_asked_how_can_it_deliver)
ReadableText(R.string.to_protect_privacy_simplex_has_ids_for_queues)
ReadableText(R.string.you_control_servers_to_receive_your_contacts_to_send)
@@ -1,172 +0,0 @@
package chat.simplex.app.views.onboarding
import android.Manifest
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.usersettings.simplexTeamUri
import com.google.accompanist.permissions.rememberPermissionState
@Composable
fun MakeConnection(chatModel: ChatModel) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
MakeConnectionLayout(
chatModel.currentUser.value,
createLink = {
withApi {
// show spinner
chatModel.connReqInvitation = chatModel.controller.apiAddContact()
// hide spinner
if (chatModel.connReqInvitation != null) {
ModalManager.shared.showModal { AddContactView(chatModel) }
}
}
},
pasteLink = {
ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
},
scanCode = {
ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) }
cameraPermissionState.launchPermissionRequest()
},
about = {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
}
)
}
@Composable
fun MakeConnectionLayout(
user: User?,
createLink: () -> Unit,
pasteLink: () -> Unit,
scanCode: () -> Unit,
about: () -> Unit
) {
Surface {
Column(
Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
.padding(20.dp)
) {
Text(
if (user == null) stringResource(R.string.make_private_connection)
else String.format(stringResource(R.string.personal_welcome), user.profile.displayName),
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
annotatedStringResource(R.string.to_make_your_first_private_connection_choose),
modifier = Modifier.padding(bottom = 16.dp)
)
ActionRow(
Icons.Outlined.AddLink,
R.string.create_1_time_link_qr_code,
R.string.it_s_secure_to_share__only_one_contact_can_use_it,
createLink
)
ActionRow(
Icons.Outlined.Article,
R.string.paste_the_link_you_received,
R.string.or_open_the_link_in_the_browser_and_tap_open_in_mobile,
pasteLink
)
ActionRow(
Icons.Outlined.QrCode,
R.string.scan_contact_s_qr_code,
R.string.in_person_or_via_a_video_call__the_most_secure_way_to_connect,
scanCode
)
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.or))
}
val uriHandler = LocalUriHandler.current
ActionRow(
Icons.Outlined.Tag,
R.string.connect_with_the_developers,
R.string.to_ask_any_questions_and_to_receive_simplex_chat_updates,
{ uriHandler.openUri(simplexTeamUri) }
)
Spacer(Modifier.fillMaxHeight().weight(1f))
SimpleButton(
text = stringResource(R.string.about_simplex),
icon = Icons.Outlined.ArrowBackIosNew,
click = about
)
}
}
}
@Composable
private fun ActionRow(icon: ImageVector, @StringRes titleId: Int, @StringRes textId: Int, action: () -> Unit) {
Row(
Modifier
.clickable { action() }
.padding(bottom = 16.dp)
) {
Icon(icon, stringResource(titleId), tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(end = 10.dp).size(40.dp))
Column {
Text(stringResource(titleId), color = MaterialTheme.colors.primary)
Text(annotatedStringResource(textId))
}
}
// Button(action: action, label: {
// HStack(alignment: .top, spacing: 20) {
// Image(systemName: icon)
// .resizable()
// .scaledToFit()
// .frame(width: 30, height: 30)
// .padding(.leading, 4)
// .padding(.top, 6)
// VStack(alignment: .leading) {
// Group {
// Text(title).font(.headline)
// Text(text).foregroundColor(.primary)
// }
// .multilineTextAlignment(.leading)
// }
// }
// })
// .padding(.bottom)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewMakeConnection() {
SimpleXTheme {
MakeConnectionLayout(
user = User.sampleData,
createLink = {},
pasteLink = {},
scanCode = {},
about = {}
)
}
}
@@ -2,7 +2,7 @@ package chat.simplex.app.views.onboarding
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -12,9 +12,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -23,6 +25,7 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
@@ -39,34 +42,36 @@ fun SimpleXInfoLayout(
onboardingStage: MutableState<OnboardingStage?>?,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
) {
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
SimpleXLogo()
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = DEFAULT_PADDING),
) {
Box(Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 8.dp), contentAlignment = Alignment.Center) {
SimpleXLogo()
}
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp))
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 24.dp), textAlign = TextAlign.Center)
InfoRow("🎭", R.string.privacy_redefined, R.string.first_platform_without_user_ids)
InfoRow("📭", R.string.immune_to_spam_and_abuse, R.string.people_can_connect_only_via_links_you_share)
InfoRow("🤝", R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers)
InfoRow(painterResource(R.drawable.privacy), R.string.privacy_redefined, R.string.first_platform_without_user_ids)
InfoRow(painterResource(R.drawable.shield), R.string.immune_to_spam_and_abuse, R.string.people_can_connect_only_via_links_you_share)
InfoRow(painterResource(R.drawable.decentralized), R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers)
Spacer(
Modifier
.fillMaxHeight()
.weight(1f))
Spacer(Modifier.fillMaxHeight().weight(1f))
if (onboardingStage != null) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
OnboardingActionButton(user, onboardingStage)
}
Spacer(
Modifier
.fillMaxHeight()
.weight(1f))
Spacer(Modifier.fillMaxHeight().weight(1f))
}
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
.padding(bottom = 16.dp), contentAlignment = Alignment.Center
) {
SimpleButton(text = stringResource(R.string.how_it_works), icon = Icons.Outlined.Info,
click = showModal { HowItWorks(user, onboardingStage) })
}
@@ -79,17 +84,17 @@ fun SimpleXLogo() {
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
contentDescription = stringResource(R.string.image_descr_simplex_logo),
modifier = Modifier
.padding(vertical = 20.dp)
.padding(vertical = DEFAULT_PADDING)
.fillMaxWidth(0.80f)
)
}
@Composable
private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) {
private fun InfoRow(icon: Painter, @StringRes titleId: Int, @StringRes textId: Int) {
Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) {
Text(emoji, fontSize = 36.sp, modifier = Modifier
Image(icon, contentDescription = null, modifier = Modifier
.width(60.dp)
.padding(end = 16.dp))
.padding(top = 8.dp, end = 16.dp))
Column(horizontalAlignment = Alignment.Start) {
Text(stringResource(titleId), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h3, lineHeight = 24.sp)
Text(stringResource(textId), lineHeight = 24.sp, style = MaterialTheme.typography.caption)
@@ -97,7 +102,6 @@ private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: I
}
}
@Composable
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
if (user == null) {
@@ -138,7 +142,7 @@ fun PreviewSimpleXInfo() {
SimpleXInfoLayout(
user = null,
onboardingStage = null,
showModal = {{}}
showModal = { {} }
)
}
}
@@ -147,13 +147,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
Text(
stringResource(R.string.network_settings_title),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionSpacer()
AppBarTitle(stringResource(R.string.network_settings_title))
SectionView {
SectionItemView {
ResetToDefaultsButton(reset, disabled = resetDisabled)
@@ -1,6 +1,7 @@
package chat.simplex.app.views.usersettings
import SectionCustomFooter
import SectionDivider
import SectionItemViewSpaceBetween
import SectionSpacer
import SectionView
@@ -23,13 +24,14 @@ import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import com.godaddy.android.colorpicker.*
@@ -40,9 +42,7 @@ enum class AppIcon(val resId: Int) {
}
@Composable
fun AppearanceView(
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
) {
fun AppearanceView() {
val appIcon = remember { mutableStateOf(findEnabledIcon()) }
fun setAppIcon(newIcon: AppIcon) {
@@ -65,19 +65,15 @@ fun AppearanceView(
AppearanceLayout(
appIcon,
changeIcon = ::setAppIcon,
showThemeSelector = showCustomModal { _, close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) colors.background else SettingsBackgroundLight
) { ThemeSelectorView() }
showThemeSelector = {
ModalManager.shared.showModal(true) {
ThemeSelectorView()
}
},
editPrimaryColor = { primary ->
showCustomModal { _, close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) colors.background else SettingsBackgroundLight
) { ColorEditor(primary, close) }
}()
ModalManager.shared.showModalCloseable { close ->
ColorEditor(primary, close)
}
},
)
}
@@ -92,12 +88,8 @@ fun AppearanceView(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
Text(
stringResource(R.string.appearance_settings),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView(stringResource(R.string.settings_section_title_icon)) {
AppBarTitle(stringResource(R.string.appearance_settings))
SectionView(stringResource(R.string.settings_section_title_icon), padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
LazyRow {
items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index ->
val item = AppIcon.values()[index]
@@ -123,19 +115,14 @@ fun AppearanceView(
SectionSpacer()
val currentTheme by CurrentColors.collectAsState()
SectionView(stringResource(R.string.settings_section_title_themes)) {
Column(
Modifier.padding(horizontal = 8.dp)
) {
SectionItemViewSpaceBetween(showThemeSelector, padding = PaddingValues()) {
Text(generalGetString(R.string.theme))
}
Spacer(Modifier.padding(horizontal = 4.dp))
SectionItemViewSpaceBetween({ editPrimaryColor(currentTheme.first.primary) }, padding = PaddingValues()) {
val title = generalGetString(R.string.color_primary)
Text(title)
Icon(Icons.Filled.Circle, title, tint = colors.primary)
}
SectionItemViewSpaceBetween(showThemeSelector) {
Text(generalGetString(R.string.theme))
}
SectionDivider()
SectionItemViewSpaceBetween({ editPrimaryColor(currentTheme.first.primary) }) {
val title = generalGetString(R.string.color_primary)
Text(title)
Icon(Icons.Filled.Circle, title, tint = colors.primary)
}
}
if (currentTheme.first.primary != LightColorPalette.primary) {
@@ -161,6 +148,7 @@ fun ColorEditor(
Modifier
.fillMaxWidth()
) {
AppBarTitle(stringResource(R.string.color_primary))
var currentColor by remember { mutableStateOf(initialColor) }
ColorPicker(initialColor) {
currentColor = it
@@ -6,8 +6,6 @@ import SectionView
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.Info
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -17,12 +15,16 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.*
@Composable
fun CallSettingsView(m: ChatModel) {
fun CallSettingsView(m: ChatModel,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
) {
CallSettingsLayout(
webrtcPolicyRelay = m.controller.appPrefs.webrtcPolicyRelay,
callOnLockScreen = m.controller.appPrefs.callOnLockScreen
callOnLockScreen = m.controller.appPrefs.callOnLockScreen,
editIceServers = showModal { RTCServersView(m) }
)
}
@@ -30,36 +32,48 @@ fun CallSettingsView(m: ChatModel) {
fun CallSettingsLayout(
webrtcPolicyRelay: Preference<Boolean>,
callOnLockScreen: Preference<CallOnLockScreen>,
editIceServers: () -> Unit,
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
AppBarTitle(stringResource(R.string.your_calls))
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
Text(
stringResource(R.string.your_calls),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView(stringResource(R.string.settings_section_title_settings)) {
SectionItemView() {
SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay)
}
SectionDivider()
Column(Modifier.padding(start = 10.dp, top = 12.dp)) {
Text(stringResource(R.string.call_on_lock_screen))
Row {
SharedPreferenceRadioButton(stringResource(R.string.no_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.DISABLE)
Spacer(Modifier.fillMaxWidth().weight(1f))
SharedPreferenceRadioButton(stringResource(R.string.show_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.SHOW)
Spacer(Modifier.fillMaxWidth().weight(1f))
SharedPreferenceRadioButton(stringResource(R.string.accept_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.ACCEPT)
}
val enabled = remember { mutableStateOf(true) }
SectionItemView { LockscreenOpts(lockCallState, enabled, onSelected = { callOnLockScreen.set(it); lockCallState.value = it }) }
SectionDivider()
SectionItemView(editIceServers) { Text(stringResource(R.string.webrtc_ice_servers)) }
}
}
}
@Composable
private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: State<Boolean>, onSelected: (CallOnLockScreen) -> Unit) {
val values = remember {
CallOnLockScreen.values().map {
when (it) {
CallOnLockScreen.DISABLE -> it to generalGetString(R.string.no_call_on_lock_screen)
CallOnLockScreen.SHOW -> it to generalGetString(R.string.show_call_on_lock_screen)
CallOnLockScreen.ACCEPT -> it to generalGetString(R.string.accept_call_on_lock_screen)
}
}
}
ExposedDropDownSettingRow(
generalGetString(R.string.call_on_lock_screen),
values,
lockscreenOpts,
icon = null,
enabled = enabled,
onSelected = onSelected
)
}
@Composable
@@ -0,0 +1,53 @@
package chat.simplex.app.views.usersettings
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.*
import javax.crypto.spec.GCMParameterSpec
@SuppressLint("ObsoleteSdkInt")
internal class Cryptor {
private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String {
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(alias), spec)
return String(cipher.doFinal(data))
}
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, createSecretKey(alias))
return Pair(cipher.doFinal(text.toByteArray(charset("UTF-8"))), cipher.iv)
}
fun deleteKey(alias: String) {
if (!keyStore.containsAlias(alias)) return
keyStore.deleteEntry(alias)
}
private fun createSecretKey(alias: String): SecretKey {
if (keyStore.containsAlias(alias)) return getSecretKey(alias)
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, "AndroidKeyStore")
keyGenerator.init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
return keyGenerator.generateKey()
}
private fun getSecretKey(alias: String): SecretKey {
return (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey
}
companion object {
private val KEY_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val TRANSFORMATION = "AES/GCM/NoPadding"
}
}
@@ -1,44 +1,35 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chatlist.ChatHelpView
import chat.simplex.app.views.helpers.AppBarTitle
@Composable
fun HelpView(chatModel: ChatModel) {
val user = chatModel.currentUser.value
if (user != null) {
HelpLayout(displayName = user.profile.displayName)
}
fun HelpView(userDisplayName: String) {
HelpLayout(userDisplayName)
}
@Composable
fun HelpLayout(displayName: String) {
fun HelpLayout(userDisplayName: String) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start
){
Text(
String.format(stringResource(R.string.personal_welcome), displayName),
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
)
AppBarTitle(String.format(stringResource(R.string.personal_welcome), userDisplayName), false)
ChatHelpView()
}
}
@@ -52,6 +43,6 @@ fun HelpLayout(displayName: String) {
@Composable
fun PreviewHelpView() {
SimpleXTheme {
HelpLayout(displayName = "Alice")
HelpLayout("Alice")
}
}
@@ -4,11 +4,12 @@ import androidx.compose.foundation.*
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.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.AppBarTitle
import chat.simplex.app.views.helpers.generalGetString
@Composable
@@ -18,30 +19,18 @@ fun IncognitoView() {
@Composable
fun IncognitoLayout() {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
Text(
stringResource(R.string.settings_section_title_incognito),
Modifier.padding(start = 8.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
Column {
AppBarTitle(stringResource(R.string.settings_section_title_incognito))
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 8.dp)
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Column(
Modifier.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Text(generalGetString(R.string.incognito_info_protects))
Text(generalGetString(R.string.incognito_info_allows))
Text(generalGetString(R.string.incognito_info_share))
Text(generalGetString(R.string.incognito_info_find))
}
Text(generalGetString(R.string.incognito_info_protects))
Text(generalGetString(R.string.incognito_info_allows))
Text(generalGetString(R.string.incognito_info_share))
Text(generalGetString(R.string.incognito_info_find))
}
}
}
@@ -2,8 +2,9 @@ package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.MaterialTheme
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -14,19 +15,22 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.Format
import chat.simplex.app.model.FormatColor
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.AppBarTitle
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun MarkdownHelpView() {
Column {
Text(
stringResource(R.string.how_to_use_markdown),
style = MaterialTheme.typography.h1,
)
Text(
stringResource(R.string.you_can_use_markdown_to_format_messages__prompt),
Modifier.padding(vertical = 16.dp)
)
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.how_to_use_markdown), false)
Text(stringResource(R.string.you_can_use_markdown_to_format_messages__prompt))
Spacer(Modifier.height(DEFAULT_PADDING))
val bold = stringResource(R.string.bold)
val italic = stringResource(R.string.italic)
val strikethrough = stringResource(R.string.strikethrough)
@@ -85,7 +89,6 @@ fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: St
b.append(after)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -2,7 +2,9 @@ package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionItemWithValue
import SectionView
import SectionViewSelectable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -10,10 +12,7 @@ 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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
@@ -25,7 +24,7 @@ import chat.simplex.app.views.helpers.*
fun NetworkAndServersView(
chatModel: ChatModel,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
) {
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
val netCfg = remember { chatModel.controller.getNetCfg() }
@@ -71,9 +70,15 @@ fun NetworkAndServersView(
}
},
useOnion = {
if (onionHosts.value == it) return@NetworkAndServersLayout
val prevValue = onionHosts.value
onionHosts.value = it
updateNetworkSettingsDialog(onDismiss = {
val startsWith = when (it) {
OnionHosts.NEVER -> generalGetString(R.string.network_use_onion_hosts_no_desc_in_alert)
OnionHosts.PREFER -> generalGetString(R.string.network_use_onion_hosts_prefer_desc_in_alert)
OnionHosts.REQUIRED -> generalGetString(R.string.network_use_onion_hosts_required_desc_in_alert)
}
updateOnionHostsDialog(startsWith, onDismiss = {
onionHosts.value = prevValue
}) {
withApi {
@@ -105,26 +110,24 @@ fun NetworkAndServersView(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.network_and_servers),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView {
AppBarTitle(stringResource(R.string.network_and_servers))
SectionView(generalGetString(R.string.settings_section_title_messages)) {
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
SectionDivider()
SectionItemView {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
}
SectionDivider()
SectionItemView {
UseOnionHosts(onionHosts, networkUseSocksProxy, useOnion)
}
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
if (developerTools) {
SectionDivider()
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
}
Spacer(Modifier.height(8.dp))
SectionView(generalGetString(R.string.settings_section_title_calls)) {
SettingsActionItem(Icons.Outlined.ElectricalServices, stringResource(R.string.webrtc_ice_servers), showModal { RTCServersView(it) })
}
}
}
@@ -139,6 +142,7 @@ fun UseSocksProxySwitch(
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -161,112 +165,55 @@ fun UseSocksProxySwitch(
}
@Composable
private fun UseOnionHosts(onionHosts: MutableState<OnionHosts>, enabled: State<Boolean>, useOnion: (OnionHosts) -> Unit) {
private fun UseOnionHosts(
onionHosts: MutableState<OnionHosts>,
enabled: State<Boolean>,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
useOnion: (OnionHosts) -> Unit,
) {
val values = remember {
OnionHosts.values().map {
when (it) {
OnionHosts.NEVER -> OnionHosts.NEVER to generalGetString(R.string.network_use_onion_hosts_no)
OnionHosts.PREFER -> OnionHosts.PREFER to generalGetString(R.string.network_use_onion_hosts_prefer)
OnionHosts.REQUIRED -> OnionHosts.REQUIRED to generalGetString(R.string.network_use_onion_hosts_required)
OnionHosts.NEVER -> ValueTitleDesc(OnionHosts.NEVER, generalGetString(R.string.network_use_onion_hosts_no), generalGetString(R.string.network_use_onion_hosts_no_desc))
OnionHosts.PREFER -> ValueTitleDesc(OnionHosts.PREFER, generalGetString(R.string.network_use_onion_hosts_prefer), generalGetString(R.string.network_use_onion_hosts_prefer_desc))
OnionHosts.REQUIRED -> ValueTitleDesc(OnionHosts.REQUIRED, generalGetString(R.string.network_use_onion_hosts_required), generalGetString(R.string.network_use_onion_hosts_required_desc))
}
}
}
ExposedDropDownSettingRow(
val onSelected = showModal {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.network_use_onion_hosts))
SectionViewSelectable(null, onionHosts, values, useOnion)
}
}
SectionItemWithValue(
generalGetString(R.string.network_use_onion_hosts),
values,
onionHosts,
values,
icon = Icons.Outlined.Security,
enabled = enabled,
onSelected = useOnion
onSelected = onSelected
)
}
@Composable
fun <T> ExposedDropDownSettingRow(
title: String,
values: List<Pair<T, String>>,
selection: State<T>,
label: String? = null,
icon: ImageVector? = null,
iconTint: Color = HighOrLowlight,
enabled: State<Boolean> = mutableStateOf(true),
onSelected: (T) -> Unit
private fun updateOnionHostsDialog(
startsWith: String = "",
message: String = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
var expanded by remember { mutableStateOf(false) }
if (icon != null) {
Icon(
icon,
"",
Modifier.padding(end = 8.dp),
tint = iconTint
)
}
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
Spacer(Modifier.fillMaxWidth().weight(1f))
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded && enabled.value
}
) {
Row(
Modifier.padding(start = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
Spacer(Modifier.size(12.dp))
Icon(
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
generalGetString(R.string.icon_descr_more_button),
tint = HighOrLowlight
)
}
ExposedDropdownMenu(
modifier = Modifier.widthIn(min = 200.dp),
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
values.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
onSelected(selectionOption.first)
expanded = false
}
) {
Text(
selectionOption.second + (if (label != null) " $label" else ""),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
private fun updateNetworkSettingsDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.update_network_settings_question),
text = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
title = generalGetString(R.string.update_onion_hosts_settings_question),
text = startsWith + "\n\n" + message,
confirmText = generalGetString(R.string.update_network_settings_confirmation),
onDismiss = onDismiss,
onConfirm = onConfirm,
onDismissRequest = onDismiss
)
}
@@ -0,0 +1,213 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemViewSpaceBetween
import SectionView
import SectionViewSelectable
import android.os.Build
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.*
import kotlin.collections.ArrayList
enum class NotificationsMode(private val requiresIgnoringBatterySinceSdk: Int) {
OFF(Int.MAX_VALUE), PERIODIC(Build.VERSION_CODES.M), SERVICE(Build.VERSION_CODES.S), /*INSTANT(Int.MAX_VALUE) - for Firebase notifications */;
val requiresIgnoringBattery
get() = requiresIgnoringBatterySinceSdk <= Build.VERSION.SDK_INT
companion object {
val default: NotificationsMode = SERVICE
}
}
enum class NotificationPreviewMode {
MESSAGE, CONTACT, HIDDEN;
companion object {
val default: NotificationPreviewMode = MESSAGE
}
}
@Composable
fun NotificationsSettingsView(
chatModel: ChatModel,
) {
val onNotificationsModeSelected = { mode: NotificationsMode ->
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
}
chatModel.notificationsMode.value = mode
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
CoroutineScope(Dispatchers.Default).launch {
if (mode == NotificationsMode.SERVICE)
SimplexService.start(SimplexApp.context)
else
SimplexService.stop(SimplexApp.context)
}
if (mode != NotificationsMode.PERIODIC) {
MessagesFetcherWorker.cancelAll()
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
}
val onNotificationPreviewModeSelected = { mode: NotificationPreviewMode ->
chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name)
chatModel.notificationPreviewMode.value = mode
}
NotificationsSettingsLayout(
notificationsMode = chatModel.notificationsMode,
notificationPreviewMode = chatModel.notificationPreviewMode,
showPage = { page ->
ModalManager.shared.showModalCloseable(true) {
when (page) {
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode, onNotificationsModeSelected)
CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected)
}
}
},
)
}
enum class CurrentPage {
NOTIFICATIONS_MODE, NOTIFICATION_PREVIEW_MODE
}
@Composable
fun NotificationsSettingsLayout(
notificationsMode: State<NotificationsMode>,
notificationPreviewMode: State<NotificationPreviewMode>,
showPage: (CurrentPage) -> Unit,
) {
val modes = remember { notificationModes() }
val previewModes = remember { notificationPreviewModes() }
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.notifications))
SectionView(null) {
SectionItemViewSpaceBetween({ showPage(CurrentPage.NOTIFICATIONS_MODE) }) {
Text(stringResource(R.string.settings_notifications_mode_title))
Spacer(Modifier.padding(horizontal = 10.dp))
Text(
modes.first { it.value == notificationsMode.value }.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
}
SectionDivider()
SectionItemViewSpaceBetween({ showPage(CurrentPage.NOTIFICATION_PREVIEW_MODE) }) {
Text(stringResource(R.string.settings_notification_preview_mode_title))
Spacer(Modifier.padding(horizontal = 10.dp))
Text(
previewModes.first { it.value == notificationPreviewMode.value }.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
}
}
}
}
@Composable
fun NotificationsModeView(
notificationsMode: State<NotificationsMode>,
onNotificationsModeSelected: (NotificationsMode) -> Unit,
) {
val modes = remember { notificationModes() }
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.settings_notifications_mode_title).lowercase().capitalize(Locale.current))
SectionViewSelectable(null, notificationsMode, modes, onNotificationsModeSelected)
}
}
@Composable
fun NotificationPreviewView(
notificationPreviewMode: State<NotificationPreviewMode>,
onNotificationPreviewModeSelected: (NotificationPreviewMode) -> Unit,
) {
val previewModes = remember { notificationPreviewModes() }
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.settings_notification_preview_title))
SectionViewSelectable(null, notificationPreviewMode, previewModes, onNotificationPreviewModeSelected)
}
}
// mode, name, description
fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
val res = ArrayList<ValueTitleDesc<NotificationsMode>>()
res.add(
ValueTitleDesc(
NotificationsMode.OFF,
generalGetString(R.string.notifications_mode_off),
generalGetString(R.string.notifications_mode_off_desc),
)
)
res.add(
ValueTitleDesc(
NotificationsMode.PERIODIC,
generalGetString(R.string.notifications_mode_periodic),
generalGetString(R.string.notifications_mode_periodic_desc),
)
)
res.add(
ValueTitleDesc(
NotificationsMode.SERVICE,
generalGetString(R.string.notifications_mode_service),
generalGetString(R.string.notifications_mode_service_desc),
)
)
return res
}
// preview mode, name, description
fun notificationPreviewModes(): List<ValueTitleDesc<NotificationPreviewMode>> {
val res = ArrayList<ValueTitleDesc<NotificationPreviewMode>>()
res.add(
ValueTitleDesc(
NotificationPreviewMode.MESSAGE,
generalGetString(R.string.notification_preview_mode_message),
generalGetString(R.string.notification_preview_mode_message_desc),
)
)
res.add(
ValueTitleDesc(
NotificationPreviewMode.CONTACT,
generalGetString(R.string.notification_preview_mode_contact),
generalGetString(R.string.notification_preview_mode_contact_desc),
)
)
res.add(
ValueTitleDesc(
NotificationPreviewMode.HIDDEN,
generalGetString(R.string.notification_preview_mode_hidden),
generalGetString(R.string.notification_display_mode_hidden_desc),
)
)
return res
}
@@ -15,6 +15,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.AppBarTitle
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
@@ -22,11 +24,7 @@ fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Text(
stringResource(R.string.your_privacy),
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
)
AppBarTitle(stringResource(R.string.your_privacy))
SectionView(stringResource(R.string.settings_section_title_device)) {
ChatLockItem(chatModel.performLA, setPerformLA)
}
@@ -0,0 +1,209 @@
package chat.simplex.app.views.usersettings
import SectionItemViewSpaceBetween
import androidx.compose.runtime.Composable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.parseRTCIceServers
import chat.simplex.app.views.helpers.*
@Composable
fun RTCServersView(
chatModel: ChatModel
) {
var userRTCServers by remember {
mutableStateOf(chatModel.controller.appPrefs.webrtcIceServers.get()?.split("\n") ?: listOf())
}
var isUserRTCServers by remember { mutableStateOf(userRTCServers.isNotEmpty()) }
var editRTCServers by remember { mutableStateOf(!isUserRTCServers) }
val userRTCServersStr = remember { mutableStateOf(if (isUserRTCServers) userRTCServers.joinToString(separator = "\n") else "") }
fun saveUserRTCServers() {
val srvs = userRTCServersStr.value.split("\n")
if (srvs.isNotEmpty() && srvs.toSet().size == srvs.size && parseRTCIceServers(srvs) != null) {
userRTCServers = srvs
chatModel.controller.appPrefs.webrtcIceServers.set(srvs.joinToString(separator = "\n"))
editRTCServers = false
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.error_saving_ICE_servers),
generalGetString(R.string.ensure_ICE_server_address_are_correct_format_and_unique)
)
}
}
fun resetRTCServers() {
isUserRTCServers = false
userRTCServers = listOf()
chatModel.controller.appPrefs.webrtcIceServers.set(null)
}
RTCServersLayout(
isUserRTCServers = isUserRTCServers,
editRTCServers = editRTCServers,
userRTCServersStr = userRTCServersStr,
isUserRTCServersOnOff = { switch ->
if (switch) {
isUserRTCServers = true
} else if (userRTCServers.isNotEmpty()) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.use_simplex_chat_servers__question),
text = generalGetString(R.string.saved_ICE_servers_will_be_removed),
confirmText = generalGetString(R.string.confirm_verb),
onConfirm = {
resetRTCServers()
isUserRTCServers = false
userRTCServersStr.value = ""
}
)
} else {
isUserRTCServers = false
userRTCServersStr.value = ""
}
},
cancelEdit = {
isUserRTCServers = userRTCServers.isNotEmpty()
editRTCServers = !isUserRTCServers
userRTCServersStr.value = if (isUserRTCServers) userRTCServers.joinToString(separator = "\n") else ""
},
saveRTCServers = ::saveUserRTCServers,
editOn = { editRTCServers = true },
)
}
@Composable
fun RTCServersLayout(
isUserRTCServers: Boolean,
editRTCServers: Boolean,
userRTCServersStr: MutableState<String>,
isUserRTCServersOnOff: (Boolean) -> Unit,
cancelEdit: () -> Unit,
saveRTCServers: () -> Unit,
editOn: () -> Unit,
) {
Column {
AppBarTitle(stringResource(R.string.your_ICE_servers))
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
SectionItemViewSpaceBetween(padding = PaddingValues()) {
Text(stringResource(R.string.configure_ICE_servers), Modifier.padding(end = 24.dp))
Switch(
checked = isUserRTCServers,
onCheckedChange = isUserRTCServersOnOff,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
if (!isUserRTCServers) {
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
} else {
Text(stringResource(R.string.enter_one_ICE_server_per_line))
if (editRTCServers) {
TextEditor(Modifier.height(160.dp), text = userRTCServersStr)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Row {
Text(
stringResource(R.string.cancel_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = cancelEdit)
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
stringResource(R.string.save_servers_button),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable(onClick = {
saveRTCServers()
})
)
}
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
} else {
Surface(
modifier = Modifier
.height(160.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
SelectionContainer(
Modifier.verticalScroll(rememberScrollState())
) {
Text(
userRTCServersStr.value,
Modifier
.padding(vertical = 5.dp, horizontal = 7.dp),
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
)
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Text(
stringResource(R.string.edit_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editOn)
)
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
}
}
}
}
}
@Composable
private fun howToButton() {
val uriHandler = LocalUriHandler.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/WEBRTC.md#configure-mobile-apps") }
) {
Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary)
Icon(
Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(horizontal = 5.dp)
)
}
}
@@ -1,5 +1,6 @@
package chat.simplex.app.views.usersettings
import SectionItemViewSpaceBetween
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -19,8 +20,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
@@ -58,7 +58,7 @@ fun SMPServersView(chatModel: ChatModel) {
if (userSMPServers.isNotEmpty()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.use_simplex_chat_servers__question),
text = generalGetString(R.string.saved_SMP_servers_will_br_removed),
text = generalGetString(R.string.saved_SMP_servers_will_be_removed),
confirmText = generalGetString(R.string.confirm_verb),
onConfirm = {
saveSMPServers(listOf())
@@ -97,99 +97,96 @@ fun SMPServersLayout(
saveSMPServers: (List<String>) -> Unit,
editOn: () -> Unit,
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.your_SMP_servers),
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1
)
Row(
verticalAlignment = Alignment.CenterVertically
Column {
AppBarTitle(stringResource(R.string.your_SMP_servers))
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
Switch(
checked = isUserSMPServers,
onCheckedChange = isUserSMPServersOnOff,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
SectionItemViewSpaceBetween(padding = PaddingValues()) {
Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
Switch(
checked = isUserSMPServers,
onCheckedChange = isUserSMPServersOnOff,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
if (!isUserSMPServers) {
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
} else {
Text(stringResource(R.string.enter_one_SMP_server_per_line))
if (editSMPServers) {
TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
if (!isUserSMPServers) {
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
} else {
Text(stringResource(R.string.enter_one_SMP_server_per_line))
if (editSMPServers) {
TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Row {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Row {
Text(
stringResource(R.string.cancel_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = cancelEdit)
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
stringResource(R.string.save_servers_button),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable(onClick = {
val servers = userSMPServersStr.value.split("\n")
saveSMPServers(servers)
})
)
}
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
} else {
Surface(
modifier = Modifier
.height(160.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
SelectionContainer(
Modifier.verticalScroll(rememberScrollState())
) {
Text(
stringResource(R.string.cancel_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = cancelEdit)
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
stringResource(R.string.save_servers_button),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable(onClick = {
val servers = userSMPServersStr.value.split("\n")
saveSMPServers(servers)
})
userSMPServersStr.value,
Modifier
.padding(vertical = 5.dp, horizontal = 7.dp),
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
)
}
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
} else {
Surface(
modifier = Modifier
.height(160.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
SelectionContainer(
Modifier.verticalScroll(rememberScrollState())
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
userSMPServersStr.value,
Modifier
.padding(vertical = 5.dp, horizontal = 7.dp),
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
)
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Text(
stringResource(R.string.edit_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editOn)
)
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
Column(horizontalAlignment = Alignment.Start) {
Text(
stringResource(R.string.edit_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editOn)
)
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
}
}
@@ -198,7 +195,7 @@ fun SMPServersLayout(
}
@Composable
fun howToButton() {
private fun howToButton() {
val uriHandler = LocalUriHandler.current
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -21,6 +21,7 @@ import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import chat.simplex.app.*
@@ -30,6 +31,8 @@ import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.TerminalView
import chat.simplex.app.views.database.DatabaseView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.CreateLinkTab
import chat.simplex.app.views.newchat.CreateLinkView
import chat.simplex.app.views.onboarding.SimpleXInfo
@Composable
@@ -39,33 +42,18 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
MaintainIncognitoState(chatModel)
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
SimplexService.StartReceiver.toggleReceiver(on)
}
if (user != null) {
SettingsLayout(
profile = user.profile,
stopped,
chatModel.chatDbEncrypted.value == true,
chatModel.incognito,
chatModel.controller.appPrefs.incognito,
runServiceInBackground = chatModel.runServiceInBackground,
developerTools = chatModel.controller.appPrefs.developerTools,
setRunServiceInBackground = ::setRunServiceInBackground,
user.displayName,
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close ->
ModalView(close = close, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
modalView(chatModel)
}
} } },
showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { 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) } },
@@ -90,11 +78,11 @@ val simplexTeamUri =
fun SettingsLayout(
profile: LocalProfile,
stopped: Boolean,
encrypted: Boolean,
incognito: MutableState<Boolean>,
incognitoPref: Preference<Boolean>,
runServiceInBackground: MutableState<Boolean>,
developerTools: Preference<Boolean>,
setRunServiceInBackground: (Boolean) -> Unit,
userDisplayName: String,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
@@ -108,43 +96,45 @@ fun SettingsLayout(
Modifier
.fillMaxSize()
.background(if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
.padding(top = 16.dp)
.padding(top = DEFAULT_PADDING)
) {
Text(
stringResource(R.string.your_settings),
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 16.dp)
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
overflow = TextOverflow.Ellipsis,
)
SectionSpacer()
Spacer(Modifier.height(30.dp))
SectionView(stringResource(R.string.settings_section_title_you)) {
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
SectionDivider()
SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { onClickIncognitoInfo(showModal) }
SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() }
SectionDivider()
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped)
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
SectionDivider()
DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_settings)) {
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped)
SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped)
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView(showCustomModal) }, disabled = stopped)
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView() }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_help)) {
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }, disabled = stopped)
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
SectionDivider()
@@ -157,7 +147,7 @@ fun SettingsLayout(
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_develop)) {
ChatConsoleItem(showTerminal, stopped)
ChatConsoleItem(showTerminal)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools)
SectionDivider()
@@ -189,10 +179,6 @@ fun SettingsIncognitoActionItem(
)
}
private val onClickIncognitoInfo: ((@Composable (ChatModel) -> Unit) -> (() -> Unit)) -> Unit = { showModal ->
showModal { IncognitoView() }()
}
@Composable
fun MaintainIncognitoState(chatModel: ChatModel) {
// Cache previous value and once it changes in background, update it via API
@@ -212,7 +198,7 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) {
@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) {
SectionItemView(openDatabaseView) {
Row(
Modifier.fillMaxWidth(),
@@ -220,12 +206,12 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
) {
Row {
Icon(
Icons.Outlined.Archive,
contentDescription = stringResource(R.string.database_export_and_import),
tint = HighOrLowlight,
Icons.Outlined.FolderOpen,
contentDescription = stringResource(R.string.database_passphrase_and_export),
tint = if (encrypted) HighOrLowlight else WarningOrange,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(stringResource(R.string.database_export_and_import))
Text(stringResource(R.string.database_passphrase_and_export))
}
if (stopped) {
Icon(
@@ -239,41 +225,6 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable private fun PrivateNotificationsItem(
runServiceInBackground: MutableState<Boolean>,
setRunServiceInBackground: (Boolean) -> Unit,
stopped: Boolean
) {
SectionItemView(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) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -301,18 +252,15 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) {
SectionItemView(showTerminal, disabled = stopped) {
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) {
SectionItemView(showTerminal) {
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
)
Text(stringResource(R.string.chat_console))
}
}
@@ -342,19 +290,23 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
profileOf.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = if (stopped) HighOrLowlight else Color.Unspecified
color = if (stopped) HighOrLowlight else Color.Unspecified,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
profileOf.fullName,
color = if (stopped) HighOrLowlight else Color.Unspecified
color = if (stopped) HighOrLowlight else Color.Unspecified,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) {
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, iconColor: Color = HighOrLowlight, disabled: Boolean = false) {
SectionItemView(click, disabled = disabled) {
Icon(icon, text, tint = HighOrLowlight)
Icon(icon, text, tint = if (disabled) HighOrLowlight else iconColor)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text, color = if (disabled) HighOrLowlight else textColor)
}
@@ -381,8 +333,8 @@ fun SettingsPreferenceItemWithInfo(
pref: Preference<Boolean>,
prefState: MutableState<Boolean>? = null
) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { onClickInfo() }) {
SectionItemView(onClickInfo) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, text, tint = if (stopped) HighOrLowlight else iconTint)
Spacer(Modifier.padding(horizontal = 4.dp))
SharedPreferenceToggleWithIcon(text, Icons.Outlined.Info, stopped, onClickInfo, pref, prefState)
@@ -402,15 +354,15 @@ fun PreviewSettingsLayout() {
SettingsLayout(
profile = LocalProfile.sampleData,
stopped = false,
encrypted = false,
incognito = remember { mutableStateOf(false) },
incognitoPref = Preference({ false}, {}),
runServiceInBackground = remember { mutableStateOf(true) },
incognitoPref = Preference({ false }, {}),
developerTools = Preference({ false }, {}),
setRunServiceInBackground = {},
userDisplayName = "Alice",
setPerformLA = {},
showModal = { {} },
showSettingsModal = { {} },
showCustomModal = { {} },
showCustomModal = { {}},
showTerminal = {},
// showVideoChatPrototype = {}
)
@@ -1,69 +1,44 @@
package chat.simplex.app.views.usersettings
import SectionItemViewSpaceBetween
import SectionView
import SectionViewSelectable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun ThemeSelectorView() {
val darkTheme = isSystemInDarkTheme()
val allThemes by remember { mutableStateOf(ThemeManager.allThemes(darkTheme)) }
val allThemes by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { ValueTitleDesc(it.second, it.third, "") }) }
ThemeSelectorLayout(
allThemes,
onSelectTheme = {
ThemeManager.applyTheme(it, darkTheme)
ThemeManager.applyTheme(it.name, darkTheme)
},
)
}
@Composable fun ThemeSelectorLayout(
allThemes: List<Triple<Colors, DefaultTheme, String>>,
onSelectTheme: (String) -> Unit,
@Composable
private fun ThemeSelectorLayout(
allThemes: List<ValueTitleDesc<DefaultTheme>>,
onSelectTheme: (DefaultTheme) -> Unit,
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
Text(
stringResource(R.string.settings_section_title_themes).lowercase().capitalize(Locale.current),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
AppBarTitle(stringResource(R.string.settings_section_title_themes).lowercase().capitalize(Locale.current))
val currentTheme by CurrentColors.collectAsState()
SectionView(null) {
LazyColumn(
Modifier.padding(horizontal = 8.dp)
) {
items(allThemes.size) { index ->
val item = allThemes[index]
val onClick = {
onSelectTheme(item.second.name)
}
SectionItemViewSpaceBetween(onClick, padding = PaddingValues()) {
Text(item.third)
if (currentTheme.second == item.second) {
Icon(Icons.Outlined.Check, item.third, tint = HighOrLowlight)
}
}
Spacer(Modifier.padding(horizontal = 4.dp))
}
}
}
val state = remember { derivedStateOf { currentTheme.second } }
SectionViewSelectable(null, state, allThemes, onSelectTheme)
}
}
@@ -2,7 +2,6 @@ package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
@@ -60,11 +59,7 @@ fun UserAddressLayout(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top
) {
Text(
stringResource(R.string.your_chat_address),
Modifier.padding(bottom = 16.dp),
style = MaterialTheme.typography.h1,
)
AppBarTitle(stringResource(R.string.your_contact_address), false)
Text(
stringResource(R.string.you_can_share_your_address_anybody_will_be_able_to_connect),
Modifier.padding(bottom = 12.dp),
@@ -77,12 +72,17 @@ fun UserAddressLayout(
) {
if (userAddress == null) {
Text(
stringResource(R.string.if_you_delete_address_you_wont_lose_contacts),
stringResource(R.string.if_you_later_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 {
Text(
stringResource(R.string.if_you_delete_address_you_wont_lose_contacts),
Modifier.padding(bottom = 12.dp),
lineHeight = 22.sp
)
QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f))
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -23,8 +23,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.isValidDisplayName
import com.google.accompanist.insets.ProvideWindowInsets
@@ -38,9 +37,9 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
val editProfile = remember { mutableStateOf(false) }
var profile by remember { mutableStateOf(user.profile.toProfile()) }
UserProfileLayout(
close = close,
editProfile = editProfile,
profile = profile,
close,
saveProfile = { displayName, fullName, image ->
withApi {
val p = Profile(displayName, fullName, image)
@@ -60,9 +59,9 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
@Composable
fun UserProfileLayout(
close: () -> Unit,
editProfile: MutableState<Boolean>,
profile: Profile,
close: () -> Unit,
saveProfile: (String, String, String?) -> Unit,
) {
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
@@ -74,7 +73,6 @@ fun UserProfileLayout(
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
@@ -94,15 +92,10 @@ fun UserProfileLayout(
Column(
Modifier
.verticalScroll(scrollState)
.padding(bottom = 16.dp),
.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start
) {
Text(
stringResource(R.string.your_chat_profile),
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
AppBarTitle(stringResource(R.string.your_chat_profile), false)
Text(
stringResource(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it),
Modifier.padding(bottom = 24.dp),
@@ -279,8 +272,8 @@ fun DeleteImageButton(click: () -> Unit) {
fun PreviewUserProfileLayoutEditOff() {
SimpleXTheme {
UserProfileLayout(
close = {},
profile = Profile.sampleData,
close = {},
editProfile = remember { mutableStateOf(false) },
saveProfile = { _, _, _ -> }
)
@@ -297,8 +290,8 @@ fun PreviewUserProfileLayoutEditOff() {
fun PreviewUserProfileLayoutEditOn() {
SimpleXTheme {
UserProfileLayout(
close = {},
profile = Profile.sampleData,
close = {},
editProfile = remember { mutableStateOf(true) },
saveProfile = { _, _, _ -> }
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

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