Compare commits

..

151 Commits

Author SHA1 Message Date
Evgeny Poberezkin
1e6e9ad5e2 mobile: update version ios v4.3 (96), android v4.3-beta.2 (73) 2022-11-28 08:39:50 +00:00
JRoberts
e6c5ad5833 ios: fix image context item background color (#1448) 2022-11-28 11:23:40 +04:00
Evgeny Poberezkin
8af0229f52 terminal: set voice message preferences (#1447)
* terminal: set voice message preferences

* enable all tests
2022-11-27 13:54:34 +00:00
Evgeny Poberezkin
7f0355ec67 core: only send voice messages without acceptance (#1444)
* core: only send voice messages without acceptance

* remove some unnecessary changes

* update

* refactor receiveInlineMode
2022-11-26 22:39:56 +00:00
JRoberts
ade8c97b16 ios: version 4.3 (95) 2022-11-26 18:54:54 +04:00
JRoberts
ee18bce964 mobile: change link to servers 2022-11-26 18:52:08 +04:00
JRoberts
7e204127b8 ios: hide voice message button in chat console (#1442) 2022-11-26 18:43:49 +04:00
JRoberts
5619152810 android: version 4.3-beta.1 (72) 2022-11-26 14:30:52 +04:00
JRoberts
6f463c16a5 ios: version 4.3 (94) 2022-11-26 14:10:55 +04:00
JRoberts
33b3557950 mobile: suppress fileAlreadyReceiving error (#1441) 2022-11-26 13:55:36 +04:00
Evgeny Poberezkin
e5912e58f5 mobile: show "transfer faster" and "switch address" without dev tools (#1440) 2022-11-26 09:45:10 +00:00
JRoberts
7a0d2add17 android: add missing translation 2022-11-26 13:22:26 +04:00
JRoberts
ac30602a50 4.3.0 2022-11-26 13:09:09 +04:00
JRoberts
6cc4e2e801 android: version 4.3 (71) 2022-11-26 13:07:34 +04:00
JRoberts
5650898c2c ios: version 4.3 (93) 2022-11-26 13:00:43 +04:00
JRoberts
336a170b3a mobile: hide full delete preferences (#1438) 2022-11-26 12:51:56 +04:00
JRoberts
9c06acd4bc ios: voice message repeat receive ui workaround (#1437) 2022-11-26 12:43:26 +04:00
mlanp
1d819a4af3 mobile: German translations for 4.3 (#1432)
* android/iOS: added and fixed german translations for v4.3

* import localization

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-26 11:56:10 +04:00
Evgeny Poberezkin
5263698e64 ios: show notifications alert only once, each time after notifications are disabled (#1428) 2022-11-25 21:43:10 +00:00
Evgeny Poberezkin
5d0d9a1c18 android: add context menu to voice message (aka play button) (#1427) 2022-11-25 20:53:43 +00:00
Evgeny Poberezkin
7407884223 android: protect screen (#1425)
* android: protect screen

* hide screen, translations
2022-11-25 19:33:29 +00:00
Evgeny Poberezkin
07acbfe743 mobile: remove BETA from strings 2022-11-25 17:56:56 +00:00
JRoberts
5e2c868612 core: silence repeat file receive error (#1426) 2022-11-25 21:20:55 +04:00
JRoberts
f6ed099f17 ios: voice messages - improve hold to record button logic, alert if not allowed (#1424) 2022-11-25 21:05:14 +04:00
Stanislav Dmitrenko
098cbf33b6 android: Group chat items (#1423)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-25 16:42:53 +00:00
Stanislav Dmitrenko
f8cf35879f android: Voice messages UI changes (#1422)
* android: Voice messages UI changes

* Alert for groups

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-25 16:31:04 +00:00
Evgeny Poberezkin
60fedbf5d2 core: only create feature items in used contacts (#1421)
* core: only create feature items in used contacts

* fix, test
2022-11-25 15:37:36 +00:00
Evgeny Poberezkin
87d306383c ios: protect screen (#1420)
* ios: protect screen

* AppSheet

* translations

* correction

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

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-25 14:31:37 +00:00
Evgeny Poberezkin
18b772a80b ios: translations (#1411) 2022-11-25 13:50:26 +00:00
Stanislav Dmitrenko
789c54bd5f android: Voice messages playing logic overhaul (#1418)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-25 13:06:56 +00:00
Stanislav Dmitrenko
f8214b0604 android: Chat items for preferences (#1409)
* android: Chat items for preferences

* fix filled

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-25 12:50:22 +00:00
Stanislav Dmitrenko
2eec81c35e android: Duration formatter (#1419) 2022-11-25 12:23:30 +00:00
JRoberts
eb099c526a core: reuse mergedPreferences/fullGroupPreferences for determining prohibited features and creating chat items instead of re-calculating (#1417) 2022-11-25 15:16:55 +04:00
JRoberts
e18bb74bfd ios: show voice message button based on preference (#1416) 2022-11-25 15:16:37 +04:00
Evgeny Poberezkin
9225f437e9 android: simplex link mode setting, ios: untrusted simplex links (#1412)
* android: simplex link mode setting, ios: untrusted simplex 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-11-25 09:55:51 +00:00
JRoberts
de7548a9a8 ios: alert on error when recording voice message (#1415) 2022-11-25 12:30:24 +04:00
JRoberts
a58a0fae29 android: preferences - fix reset & save buttons being enabled when displayed as disabled, fix info rows being interactive (#1414) 2022-11-25 11:53:29 +04:00
JRoberts
e32b24ef70 android: move preferences files to respective packages; dividers (#1413) 2022-11-25 11:27:22 +04:00
Evgeny Poberezkin
5d73b364d8 android: fix type name 2022-11-24 17:34:01 +00:00
Stanislav Dmitrenko
fa2f303547 android: Global preferences and per contact (#1290)
* android: Global preferences and per contact

* Fixes

* Changes in UI

* Fix

* Strings and more descriptions

* Spelling error

* No dots at the end

* Adapting changes from core

* Adapting changes from core

* Change

* Simplified user's choice with toggle

* Changes after merge

* Updated preferences to the latest changes in core

* Strings

* Changes

* Small changes

* Contact will be updated in UI too

* bigger icons in section headers

* Icons and colors

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-24 17:30:58 +00:00
JRoberts
a6e4e68bc5 ios: voice messages (#1389)
* experiments

* audio recording in swiftui

* recording encapsulated

* permission + playback

* stopAudioPlayback on cancel

* method names

* check permission in recording start

* run timer on main thread

* remove obsolete view

* don't call playback timer callback unless player is playing

* compose + send view + preview + send

* animation + improve state + quality

* fix recording not stopping in time

* animate to end

* remove recorder delegate, fix cancelling during recording

* replace print with log

* recording start error constructor

* CIVoiceView file

* chat item wip

* chat item wip

* refactor settings

* layout

* send correct duration

* item previews

* more background, animation

* more layout

* more layout, send button conditions

* context, preview, quote, notification texts

* chat item actions

* use isEmpty

* remove comment

* uncomment file.loaded

* more layout, hold to record

* more layout

* preview player stop on disappear

* more layout

* comment

* only one player or recording

* remove voice message on chat close

* fix state bug

* remove commented code

* length 30

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-24 21:18:28 +04:00
Evgeny Poberezkin
4485d46307 mobile: simplex links in UI, core: trusted uri for simplex links (#1410) 2022-11-24 17:14:56 +00:00
Evgeny Poberezkin
a7345ee4d9 core: markdown for simplex invitation links (#1408)
* core: markdown for simplex invitation links

* update markdown for simplex links

* update markdown

* update

* stabilize test
2022-11-24 13:13:26 +00:00
Evgeny Poberezkin
388aaec80b core: config to send inline files (#1406)
* core: config to send inline files

* update config

* add/update tests

* fix tests
2022-11-23 16:08:33 +00:00
Stanislav Dmitrenko
21722b3417 android: Advanced server config (#1403)
* android: Advanced server config

* Update apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ScanSMPServer.kt

* Camera permission, dropping tested value, different font

* For review

* Partial redraw of the view in testing stage

* Comment

* Icon

* Icon

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-23 11:13:24 +00:00
Evgeny Poberezkin
e6e5faeb9c core: chat items for group preferences (#1402)
* core: chat items for group preferences

* chat items for group preference changes and sent item for contact/user prerences changes

* prohibited features, tests

* enable all tests

* fix
2022-11-23 11:04:08 +00:00
Evgeny Poberezkin
67d78e14be Merge branch 'stable' 2022-11-22 14:12:18 +00:00
Evgeny Poberezkin
0d1a70af34 android: v4.2.2 (70) 2022-11-22 14:11:23 +00:00
Evgeny Poberezkin
2b09fb425d core: chat items showing preference changes (#1399) 2022-11-22 12:50:56 +00:00
Evgeny Poberezkin
ab91c54080 ios: version 4.2.2 (92) 2022-11-22 12:44:40 +00:00
Evgeny Poberezkin
c7f70f0ed0 ios: resolved packages 2022-11-22 09:47:42 +00:00
Evgeny Poberezkin
5806a2ceb4 Merge branch 'stable' 2022-11-22 08:55:01 +00:00
Evgeny Poberezkin
6b71cc59c8 blog: updates (#1400) 2022-11-22 08:49:33 +00:00
Evgeny Poberezkin
33a866463d core: fix sql that was doubling a group in the list of chats when member joined the group twice (#1378) 2022-11-21 10:37:11 +00:00
Stanislav Dmitrenko
fb165622aa android: Wrapped code in SideEffect (#1396) 2022-11-21 10:24:18 +00:00
Evgeny Poberezkin
7e3d53b621 ios: advanced server configuration (#1388)
* ios: advanced server configuration

* UI is mostly working, QR code scan

* refactor

* error alerts

* fixes

* remove old view

* rename view

* translations

* only show valid QR code, spinner during server test

* update tested status on edit

* space wtf

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

* moar space

* translation

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

* translations

* translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-21 08:37:13 +00:00
Evgeny Poberezkin
7544d2f9e7 core: fix preset servers (#1392)
* core: fix preset servers

* simplify

* fix
2022-11-21 07:43:41 +00:00
Evgeny Poberezkin
02fa81e8aa mobile: show unknown content with attached file as file item (for partial forward compatibility with voice and video messages) (#1394)
* mobile: show unknown content with attached file as file item (for partial forward compatibility with voice and video messages)

* fix

* android: show unknown files
2022-11-21 07:42:36 +00:00
Evgeny Poberezkin
4296b6c622 android: import function to parse server address (#1391) 2022-11-21 07:39:36 +00:00
sh
b5652bce81 docker: enable sqlcipher (#1390) 2022-11-20 11:56:01 +00:00
Evgeny Poberezkin
b8298aa458 Merge branch 'stable' 2022-11-20 11:20:10 +00:00
solus-hq
c3244f1b76 Add OpenSSL for macOS description (#1370)
Co-authored-by: shark <shark@shark.work>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-18 19:29:02 +00:00
Evgeny Poberezkin
0ad74d9538 docs: CLI compilation (#1359)
* docs: CLI compilation

* update

* remove BETA

* amend CLI build steps
2022-11-18 17:54:57 +00:00
Stanislav Dmitrenko
a4be68f4bd android: Audio messages (#1070)
* Audio messages testing

* Without Vorbis

* Naming

* Voice message auto-receive, voice message composing

* Experiments with audio

* More recording features

* Unused code

* Merge master

* UI

* Stability

* Size limitation

* Tap and hold && tap and wait and click logics

* Deleted unused lib

* Voice type

* Refactoring

* Refactoring

* Adapting to the latest changes

* Mini player in preview

* Different UI for some elements

* send msg view style

* *** in translation

* Animation

* Fixes animation performance

* Smaller font for recording time

* File names

* Renaming

* No edit possible for audio messages

* Prevent adding text to edittext

* Bubble layout

* Layout

* Refactor

* Paddings

* No crash, please

* Draw progress as a ring

* Padding

* Faster status updates while listening voice

* Faster status updates while listening voice

* Quote

* backend comment

* Align

* Stability

* Review

* Strings

* Just better

* Sync of recorder and players

* Replaced Icon's with ImageButton's

* Icons size

* Error processing

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

* rename composable

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-18 21:02:24 +04:00
JRoberts
0cb8f8ad82 core: fix group preferences update (#1385) 2022-11-18 16:07:40 +04:00
sh
9d7bb06396 docker: update build (#1375) 2022-11-18 09:00:43 +00:00
Daniel Nathan Gray
a8b9200c9a Fix dead links to audit (#1374) (#1374) 2022-11-17 17:26:19 +00:00
JRoberts
a9c2a7dcaa ios: remove accent color on chat info views navigation links (#1382) 2022-11-17 20:53:02 +04:00
Evgeny Poberezkin
38b28f866c sdk: update version 0.1.1 2022-11-17 14:46:49 +00:00
Evgeny Poberezkin
bfa7ff16ff sdk: fix typescript client (#1380) 2022-11-17 14:45:48 +00:00
JRoberts
5c2b70a214 core: fix test name 2022-11-17 14:42:28 +04:00
JRoberts
7e3f91f87c core: add sanity checks in sql to include quoted items only from the same chat (#1379) 2022-11-17 14:38:14 +04:00
Evgeny Poberezkin
f54faebff3 core: fix sql that was doubling a group in the list of chats when member joined the group twice (#1378) 2022-11-17 09:58:52 +00:00
JRoberts
4e5aa3dcbc ios: adjust preferences UX; fix group profile not updating; fix servers api (#1377) 2022-11-17 12:59:13 +04:00
Evgeny Poberezkin
56f3874a93 ios: move preferences, icon (#1376)
* ios: move preferences, icon

* don't disable database settings on chat stop

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-17 10:57:27 +04:00
JRoberts
828b502431 ios: load and save preferences (#1373) 2022-11-16 20:26:43 +04:00
Evgeny Poberezkin
491fe4a9bf core, ios: advanced server config (#1371)
* ios: advanced server config

* simplify UI

* core: ServerCfg

* commit migration, update schema

* add preset servers to response

* return default servers if none saved

* fix test
2022-11-16 15:37:20 +00:00
Evgeny Poberezkin
f8302e2030 core: SMP server connection test (#1367)
* core: SMP server connection test

* fix test

* update simplexmq
2022-11-15 18:31:29 +00:00
JRoberts
fd34c39552 core: fix voice msg content text representation 2022-11-15 15:56:38 +04:00
JRoberts
b1fa1a84fe core: voice msg content type (#1368) 2022-11-15 15:24:55 +04:00
mlanp
cf23399262 android / iOS: german translations for 4.2.1 (#1366)
* android/iOS: fixed german translations for v4.2.1

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

* Update apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff

* Apply suggestions from code review

* strings

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-15 11:31:07 +04:00
JRoberts
b5a812769b core: full/merged preferences in User, Contact, GroupInfo types (#1365)
* core: preferences in User, Contact, GroupInfo types

* user and group preferences

* refactor

* linebreak

* remove synonyms

* refactor

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-15 10:31:44 +04:00
JRoberts
40e1b01baf android: version 4.3-beta.0 (69) 2022-11-14 17:01:29 +04:00
Stanislav Dmitrenko
9c925ab040 android: Animated switch between chat and chatList (#1175)
* android: Animated switch between chat and chatList

* Correct animation

* Testing idea

* Revert "Testing idea"

This reverts commit ecda083883.

* Experiments

* Experiments

* Experiments

* Revert "Experiments"

This reverts commit 4390de1e92.

* Revert "Experiments"

This reverts commit 0b3048aeef.

* Revert "Experiments"

This reverts commit b692803cea.

* Merge

* Gorgeous animation performance

* Undo optimization

* Formatting

* Sharing

* Box

* Continue

* Launch on Main thread only specific call to WebView

* Launch on Main thread only specific call to WebView

* Temporary made withApi() running on Main thread only

* Unneeded code

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

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-14 16:35:38 +04:00
Evgeny Poberezkin
faceeb6fce ios: chat preferences, UI and types (#1360) 2022-11-14 10:12:17 +00:00
JRoberts
07e8c1d76e Fixing ForegroundServiceDidNotStartInTimeException (#1349)
(cherry picked from commit a5d235e559)

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2022-11-14 14:02:04 +04:00
Evgeny Poberezkin
b1d8600215 cli: message search in CLI app (#1362)
* cli: message search in CLI app

* type synonym
2022-11-14 08:42:54 +00:00
Evgeny Poberezkin
e14ab0fed0 core: support SMP basic auth / server password (#1358) 2022-11-14 08:04:11 +00:00
Evgeny Poberezkin
cb0c499f57 core: send broadcast to direct contacts only (#1361) 2022-11-14 07:59:59 +00:00
Evgeny Poberezkin
002a081b3b readme: user groups (#1357)
* readme: user groups

* corrections

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

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-12 14:21:33 +00:00
JRoberts
c2b76a75b5 android: fix crash on restoring from backup (#1350)
* Restoring app's data from backup tools will still allow to enter passphrase instead of just crashing

(cherry picked from commit 256243dc8c)

* corrections

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2022-11-12 17:19:56 +04:00
Evgeny Poberezkin
2742fc3ca9 site: make onion location http 2022-11-12 12:08:37 +00:00
Evgeny Poberezkin
f3731799bc readme: update roadmap (#1355) 2022-11-12 12:07:11 +00:00
Evgeny Poberezkin
7a78dfd3e3 site: onion location (#1356) 2022-11-12 12:04:23 +00:00
JRoberts
5a2dd7b4bc 4.2.1 2022-11-12 16:01:27 +04:00
JRoberts
1a4d2b6de6 android: version 4.2.1 (68) 2022-11-12 15:59:27 +04:00
JRoberts
e4b46a45d3 android: restore Develop section divider 2022-11-12 15:45:04 +04:00
JRoberts
d61a7fb4d8 ios: version 4.2.1 (91) 2022-11-12 15:07:35 +04:00
JRoberts
bddb37593c mobile: 4.2.1 German translations (#1354) 2022-11-12 14:37:04 +04:00
JRoberts
8b794b2285 core: fix group link tests sporadically failing due to non deterministic events order (#1353) 2022-11-12 14:13:34 +04:00
Evgeny Poberezkin
8d0ec01a9b site: shorten anchor links 2022-11-12 09:49:07 +00:00
JRoberts
d85aa655cb mobile: fix translation 2022-11-12 11:54:28 +04:00
JRoberts
ba0cffb511 android: catch DecodeException (#1351)
* Catched drawable's DecodeException

(cherry picked from commit 90ed170f61)

* missing import

* texts

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2022-11-12 11:44:20 +04:00
JRoberts
d029ce9817 ios: version 4.2.1 (90) 2022-11-11 21:48:52 +04:00
JRoberts
678f4f5e87 android: prevent exception on concurrent attempts to add members to group (#1348)
* android: Random crashes fix

(cherry picked from commit 0f7789c411)

* Better way of disabling members adding ato a group

(cherry picked from commit 96ca7f0d85)

* check outside

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2022-11-11 21:08:02 +04:00
JRoberts
b780a41272 core: client that joins via group link to probe contacts, not host (#1343) 2022-11-11 18:34:32 +04:00
Evgeny Poberezkin
1caaca83cb blog: update group link 2022-11-11 14:26:51 +00:00
Stanislav Dmitrenko
c8b2bcb064 android: Fix of StackoverflowError (#1322)
* android: Fix of StackoverflowError

* Break

* Comment

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

* Revert "Update apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt"

This reverts commit ea8015e01d.

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-11 13:33:45 +00:00
Stanislav Dmitrenko
adfe20b54c android: Support info (#1347)
* android: Support info

* Dirrectly to Play Store

* Different icon

* text

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-11 10:50:49 +00:00
JRoberts
b9d625da18 ios: support (#1346)
* ios: update settings

* translation

* redundant item

* fix stopped chat buttons

* corrections

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

* translations

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-11 08:30:10 +00:00
JRoberts
8631cf1471 android: remove local authentication error toasts, rename Retry -> Unlock (#1339) 2022-11-10 16:58:31 +04:00
Evgeny Poberezkin
0b6b8bd327 website: update video 2022-11-10 12:41:46 +00:00
JRoberts
e83ed30a49 ios: update library 2022-11-10 11:31:13 +04:00
Evgeny Poberezkin
29f919b3d6 blog: update post 2022-11-09 20:45:37 +00:00
JRoberts
2636f2ce1c mobile: increase default tcp timeouts (#1336) 2022-11-09 21:24:07 +04:00
JRoberts
f80f56de61 core: allow repeat connection via group link if group was deleted but contact with host is present (#1335) 2022-11-09 21:11:05 +04:00
JRoberts
941660625d website: phrasing, link (#1333) 2022-11-09 15:32:45 +04:00
Evgeny Poberezkin
ad1432e0ee core: make parsing independent of the order (#1332)
* core: make parsing independent of the order

* test

* fix

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-09 14:48:24 +04:00
JRoberts
1cfbbd3115 core: update simplexmq (3.4.0) (#1328) 2022-11-09 14:13:39 +04:00
JRoberts
21ffe0ad49 core: repeated invite correctly updates role if changed (#1327) 2022-11-09 14:12:42 +04:00
JRoberts
54ad071655 website: fix typo 2022-11-09 10:25:09 +04:00
Evgeny Poberezkin
992c934fd1 add image 2022-11-08 20:42:00 +00:00
Evgeny Poberezkin
a6c6f1dbff website: update image 2022-11-08 20:29:00 +00:00
Evgeny Poberezkin
a652a14d58 site: fix date in blog index 2022-11-08 19:57:10 +00:00
Evgeny Poberezkin
9e77f05e58 website: fix images, update blog headline 2022-11-08 19:45:56 +00:00
Evgeny Poberezkin
355a3c429c site: fix share image in articles 2022-11-08 18:41:00 +00:00
Evgeny Poberezkin
a1ce3b9c69 blog: update headers 2022-11-08 17:54:42 +00:00
Evgeny Poberezkin
00af82cb19 add github button 2022-11-08 17:42:39 +00:00
Evgeny Poberezkin
68b6d9e966 site: update domain 2022-11-08 17:20:03 +00:00
Evgeny Poberezkin
75165cc70a blog: fix date 2022-11-08 17:01:40 +00:00
Evgeny Poberezkin
e5bf5092b1 readme: fix link 2022-11-08 17:00:07 +00:00
Evgeny Poberezkin
e5a4cca5e0 docs: update readme, blog 2022-11-08 16:59:14 +00:00
Evgeny Poberezkin
41f4f11155 website: fix popups and blog bullets 2022-11-08 16:43:04 +00:00
Evgeny Poberezkin
99bfd446d1 ci: remove website branch from web.yml 2022-11-08 15:36:53 +00:00
Evgeny Poberezkin
dd740e82cf a quick blog fix (#1324)
Co-authored-by: M Sarmad Qadeer <MSarmadQadeer@gmail.com>
2022-11-08 15:23:36 +00:00
Evgeny Poberezkin
5fabeff1fa docs: update readme (#1318)
* docs: update readme

* update roadmap

* typo

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

* update doc

* update readme

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-08 13:20:23 +00:00
Evgeny Poberezkin
324730f8ae blog: readme, index (#1321)
* blog: readme, index

* add blog preview to the site
2022-11-08 13:19:32 +00:00
Evgeny Poberezkin
ed1faff500 blog: security audit, the new website, v4.2 release (#1315)
* blog: security audit, the new website, v4.2 release

* update text

* correction

* link to report

* more text (#1317)

* more text

* more text

* readme

* more text

* add logo

* image

* more images

* fix image link

* image size

* more images

* update images

* correction

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

* correction

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

* corrections

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

* correction

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

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

* remove note

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-08 12:59:28 +00:00
Stanislav Dmitrenko
ce7d0ab8cf android: fix keyboard not closing between views, photo not saved on camera rotation, repeat authentication on opening chat via notification, other minor issues (#1314)
* android: User's problems fix

* Hiding keyboard, state preserving while rotating camera orientation

* Allows to select image from camera multiple times without crashing

* Images sending after orientation change

* Do not ask again about auth when switching between dialogs
2022-11-08 15:58:54 +04:00
Evgeny Poberezkin
75dccf95c4 website: typo 2022-11-08 11:45:21 +00:00
Evgeny Poberezkin
fa5a70cd19 new website (#1307)
* nav in process

* edited web.yml

* navbar issue fixed

* added theme switcher

* added privacy matters section

* added features section

* updated nav padding

* added network section

* improved sidebar dark mode colors

* added footer

* simplex private section added

* added some improvements

* nav issue fixed

* simplex unique section added

* a small fix

* added overlay & data to some sections

* added overlay to simplex unique section
added some improvements to other sections too

* added a small fix

* updated CNAME

* markdown files for why simplex is unique

* Revert "markdown files for why simplex is unique"

This reverts commit ef728218f7.

* added hero section

* added comparison and simplex explained section

* added blogs page

* added articles page

* a small fix in hero section

* added contact page

* updated contact

* created files for overlay content

* a light update

* hero animation

* working on hero

* added responsiveness for mobile

* a quick fix

* added responsiveness to tablet screen

* added responsiveness for desktop screen on hero section

* switch theme of hero

* nav color update

* set comparisons sections

* switch theme of comparisons section

* added responsiveness in simplex explained section

* add logic to simplex explained

* added theme switcher to simplex explained

* manage join simplex section

* update what makes simplex private

* a quick update

* add improvements

* a bit update

* add improvements

* texts for why privacy matters section

* update headers

* texts for "why unique" and "features" sections

* EOLs

* update swipers

* update & add transitions to simplex unique section

* updated overlays

* increase the size of cross on overlays

* add overlays to hero

* website: texts for "private" and "explained" sections (#5)

* website: texts for "private" section

* texts for simplex explained

* blog previews and images (#6)

* blog previews and images

* text for dark mode

* add link style

* add overlay to -> unlike p2p networks

* add picture with blue arrows to simplex explained

* update blog list layout

* remove extra css

* bigger navigation circles & center positions

* make bullets (dots) bigger

* make private scroll thicker

* update hero & footer mobile download btns

* fix dark mode animation files (#7)

* improved contrast for light animation in hero section (#8)

* remove old animation

* Made Hero Pixel Perfect to Desktop

* texts in hero section overlays (#10)

* texts in hero section overlays

* replace hero video

* eol

* update footer links (#11)

* update footer links

* eol

* texts, links, fix layout (#12)

* mailchimp form (#13)

* site meta tags (#14)

* site meta tags

* update blog og:url

* amend texts

* font

* update text

* contact page

* Making things Polished in Hero (#15)

* Made Video Responsive on Tablet

* Fixed the issues

* remove extra files for home & contact page

* update invitation

* refactoring

* fix nav for dark

* quick fix

* update blog list layout

* refactoring

* disable inactive nav circles

* contact page

* fix mobile

* detect platform & show btns according to it

* contact & invitation page setting

* complete contact/invitation page

* create variables for download btns

* fixes for hero - for tablet & mobile

* update hero layout

* update footer layout

* increase the size of logo in navbar

* updated nav & footer logos

* add links to join simplex section

* text for p2p networks section

* text on contact page about link

* add touchstart handler to close popup

* update APK links

* update CNAME

Co-authored-by: M Sarmad Qadeer <MSarmadQadeer@gmail.com>
Co-authored-by: Ojas Shukla <54703305+whizzbbig@users.noreply.github.com>
2022-11-08 11:04:02 +00:00
Evgeny Poberezkin
dd9e94eefd site: move APK url (#1320) 2022-11-08 10:28:40 +00:00
JRoberts
0f65a001c8 ios: fix current conversation not opening after authentication (#1319) 2022-11-08 12:39:41 +04:00
JRoberts
f3e59aa3c3 ios: dismiss re-opened contact connection view upon connection (#1316) 2022-11-07 21:05:59 +04:00
JRoberts
655041c657 ios: fix changed member role resetting in view (#1312) 2022-11-07 20:44:04 +04:00
Stanislav Dmitrenko
4ca118666a android: Connect via group link alert (#1313)
* android: Connect via group link alert

* Rename

* Replace first

* rename CRData into CReqClientData to match haskell type

* Alert

* Shorter

* Strings

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-07 20:28:11 +04:00
Stanislav Dmitrenko
365f92e958 android: Alert about deleted contact and errors in chat item (#1289) 2022-11-07 19:42:00 +04:00
Stanislav Dmitrenko
ddecd847e5 android: update available group actions on role change; connect via external link when app was closed; other fixes (#1311)
* android: Fixes for tests

* console item bottom padding

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-07 16:46:15 +04:00
JRoberts
18677cec63 mobile: repeat group invitations don't duplicate chat preview (#1310) 2022-11-07 12:08:37 +04:00
Evgeny Poberezkin
4e8dcab020 update readme 2022-11-06 20:48:58 +00:00
Evgeny Poberezkin
eb0f78bd80 blog: reserve permalink for 4.2 release 2022-11-06 15:27:59 +00:00
652 changed files with 16679 additions and 3918 deletions

View File

@@ -1,10 +1,32 @@
FROM haskell:8.10.4 AS build-stage
# if you encounter "version `GLIBC_2.28' not found" error when running
# chat client executable, build with the following base image instead:
# FROM haskell:8.10.4-stretch AS build-stage
FROM ubuntu:focal AS build
# Install curl and simplex-chat-related dependencies
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
# Install ghcup
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
chmod +x /usr/bin/ghcup
# Install ghc
RUN ghcup install ghc 8.10.7
# Install cabal
RUN ghcup install cabal
# Set both as default
RUN ghcup set ghc 8.10.7 && \
ghcup set cabal
COPY . /project
WORKDIR /project
RUN stack install
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Adjust build
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
RUN cabal install
FROM scratch AS export-stage
COPY --from=build-stage /root/.local/bin/simplex-chat /
COPY --from=build /root/.cabal/bin/simplex-chat /

View File

@@ -1,22 +1,26 @@
# SimpleX Chat Terms & Privacy Policy
SimpleX Chat is the first chat platform that is 100% private by design - not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we do not have access to your connections graph.
SimpleX Chat is the first communication platform that has no user profile IDs of any kind, not even random numbers. Not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we cannot observe your connections graph.
If you believe that some of the clauses in this document are not aligned with our mission or principles, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Privacy Policy
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
SimpleX Chat security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
### Information you provide
We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
Messages. SimpleX Chat cannot decrypt or otherwise access the content or size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are temporarily offline. Your message history is stored only on your own devices.
Messages. SimpleX Chat cannot decrypt or otherwise access the content or even size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are offline, these messages are permanently removed as soon as they are delivered. Your message history is stored only on your own devices.
Connections with other users. When you create a connection with another user, two messaging queues are created on our servers (we use separate queues for direct and response messages, that can be on two different servers), or on the servers that you configured in the app, in case it allows such configuration. At the time of updating this document only our terminal app allows configuring the servers, our mobile apps will allow such configuration in the near future. Our servers do not store information about which queues are linked to your profile on the device, and they do not have any information in common that allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of unique encryption keys, different for each queue, and separate for sender and recipient of the messages that are transmitted through the queue.
Connections with other users. When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on our servers, or on the servers that you configured in the app, in case it allows such configuration (SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default). At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. The exception to that is when you choose to use instant push notifications in our iOS app, because the design of push notifications requires storing the device token on notification server, and the server can observe how many messaging queues your device uses, and approximate how many messages are sent to each queue. It does not allow though to determine the actual addresses of these queues, as a separate address is used to subscibe to the notifications (unless notification and messaging servers exchange information), and who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers. It also does not allow to see message content or sizes, as the actual messages are not sent via the notification service, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot see it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support via chat, when it is possible.
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
### Information we may share
@@ -31,6 +35,8 @@ The cases when SimpleX Chat may need to share the data we temporarily store on t
- To detect, prevent, or otherwise address fraud, security, or technical issues.
- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law.
At the time of updating this document, we have never provided or have been requested the access to our servers or any information from our servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process.
### Updates
We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
@@ -47,7 +53,7 @@ You accept to our Terms of Service ("Terms") by installing or using any of our a
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we do cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or cyphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per users - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
**Operating our services**. For the purpose of operating our Services, you agree that your end-to-end encrypted messages are transferred via our servers in the United Kingdom, the United States and other countries where we have or use facilities and service providers or partners.
@@ -87,4 +93,4 @@ You accept to our Terms of Service ("Terms") by installing or using any of our a
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
Updated March 1, 2022
Updated November 8, 2022

101
README.md
View File

@@ -16,15 +16,15 @@
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
- 🖲 Protects your messages and metadata - who you talk to and when.
- 🔐 Double ratchet end-to-end encryption, with additional encryption layer.
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
**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).
**NEW**: Security audit by [Trail of Bits](https://www.trailofbits.com/about), the [new website](https://simplex.chat) and v4.2 released! [See the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
## Contents
@@ -42,8 +42,10 @@
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
- [For developers](#for-developers)
- [Roadmap](#roadmap)
- [Help us pay for 3rd party security audit](#help-us-pay-for-3rd-party-security-audit)
- [Disclaimer, License](#disclaimer)
- [Join a user group](#join-a-user-group)
- [Contribute](#contribute)
- [Help us with donations](#help-us-with-donations)
- [Disclaimers, Security contact, License](#disclaimers)
## Why privacy matters
@@ -83,6 +85,8 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent updates:
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.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)
@@ -149,7 +153,6 @@ 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 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
@@ -175,42 +178,69 @@ If you are considering developing with SimpleX platform please get in touch for
- ✅ Manual chat history deletion.
- ✅ End-to-end encrypted WebRTC audio and video calls via the mobile apps.
- ✅ Privacy preserving instant notifications for iOS using Apple Push Notification service.
- ✅ Chat database export and import
- ✅ Chat database export and import.
- ✅ Chat groups in mobile apps.
- ✅ Connecting to messaging servers via Tor.
- ✅ Dual server addresses to access messaging servers as v3 hidden services.
- ✅ Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (ready for announcement).
- ✅ Incognito mode to share a new random name with each contact.
- ✅ Chat database encryption.
- 🏗 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
- Automatic chat history deletion.
- ✅ Links to join groups and improve groups stability.
- 🏗 SMP queue redundancy and rotation (manual is supported).
- 🏗 Voice messages (with recipient opt-out per contact).
- 🏗 Basic authentication for SMP servers (to authorize creating new queues).
- View deleted messages, full message deletion by sender (with recipient opt-in per contact).
- Block screenshots and view in recent apps.
- Optionally avoid re-using the same TCP session for multiple connections.
- Access password/pin (with optional alternative access password).
- Ephemeral/disappearing/OTR conversations with the existing contacts.
- Media server to optimize sending large files to groups.
- Video messages.
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
- Multiple user profiles in the same chat database.
- Advanced server configuration.
- Feeds/broadcasts.
- Unconfirmed: disappearing messages (with recipient opt-in per-contact).
- Web widgets for custom interactivity in the chats.
- Message delivery confirmation.
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
- Supporting the same profile on multiple devices.
- Desktop client.
- 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
## Join a user group
I will get straight to the point: I ask you to support SimpleX Chat with donations.
You can join a general group with more than 100 members: [#SimpleX-Group](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D).
We are prioritizing users privacy and security - it would be impossible without your support we were lucky to have so far.
You can also join smaller groups by countries/languages: [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FmIorjTDPG24jdLKXwutS6o9hdQQRZwfQ%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA9N0BZaECrAw3we3S1Wq4QO7NERBuPt9447immrB50wo%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22S8aISlOgkTMytSox9gAM2Q%3D%3D%22%7D) (German), [\#SimpleX-US](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FlTWmQplLEaoJyHnEL1-B3f2PtDsikcTs%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-hMBlsQjNxK2vaVhqW_UyAVtuoYqgYTigK4B9dJ9CGc%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22G0UtRHIn0TmPoo08h_cbTA%3D%3D%22%7D) (US/English), [\#SimpleX-France](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F11r6XyjwVMj0WDIUMbmNDXO996M_EN_1%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAXDmc2Lrj9WQOjEcWa0DeQHF3HcYOp9b68s8M_BJ7gEk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22EZCeSYpeIBkaQwCcpcF00w%3D%3D%22%7D), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FZSYM278L5WoZiApx3925EAjSXcsAVNVu%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA7RJ2wfT8zdfOLyE5OtWLEAPowj-q6F2HB0ExbATw8Gk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22fsVoklNGptt7n-droqJYUQ%3D%3D%22%7D) (Russian), [#SimpleX-NL](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmP0LbswSbfxoVkkxiWE2NYnBCgZ9Snvj%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAVwZuSsw4Mf52EaBNdNI3RebsLm0jg65ZIkcmH9E5uy8%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22M9xIULUNZx51Wsa5Kdb0Sg%3D%3D%22%7D) (Netherlands/Dutch), [#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaZ_wjh6QAYHB-LjyGtp8bllkzoq880u-%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-_Wulzc3j16i7t77XJ5wgwxeW8_Ea8GxetMo7K4MgjI%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22QWmXdrFzIeMd2OoEPMFkBQ%3D%3D%22%7D) (Italian).
We are planning a 3rd party security audit for the app, and it would hugely help us if some part of this $20,000+ expense could be covered with donations.
You can join these groups either by opening these links in the app or by opening them in a desktop browser and scanning QR code.
Let us know if you'd like to add some other countries to the list.
Join via the app to share what's going on and ask any questions!
## Contribute
We would love to have you join the development! You can contribute to SimpleX Chat with:
- developing features - please connect to us via chat so we can help you get started.
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- translate UI to some language - we are currently setting up the UI to simplify it, please get in touch and let us know if you would be able to support and update the translations.
- translate website homepage - there is a lot of content we would like to share, it would help to bring the new users.
## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat!
We are prioritizing users privacy and security - it would be impossible without your support.
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
If you are already using SimpleX Chat, or plan to use it in the future when it has more features, please consider making a donation - it will help us to raise more funds. Donating any amount, even the price of the cup of coffee, would make a huge difference for us.
Your donations help us raise more funds any amount, even the price of the cup of coffee, would make a big difference for us.
It is possible to donate via:
@@ -218,6 +248,7 @@ It is possible to donate via:
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- please let us know, via GitHub issue or chat, if you want to create a donation in some other cryptocurrency - we will add the address to the list.
Thank you,
@@ -225,11 +256,27 @@ Evgeny
SimpleX Chat founder
## Disclaimer
## Disclaimers
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed and had many improvements in v1.0.0; we are currently arranging for the independent implementation audit.
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
You are likely to discover some bugs - we would really appreciate if you use it and let us know anything that needs to be fixed or improved.
The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
The default servers configured in the app are provided on the best effort basis. We are currently not guaranteeing any SLAs, although historically our servers had over 99.9% uptime each.
We have never provided or have been requested access to our servers or any information from our servers by any third parties. If we are ever requested to provide such access or information, we will be following due legal process.
We do not log IP addresses of the users and we do not perform any traffic correlation on our servers. If transport level security is critical you must use Tor or some other similar network to access messaging servers. We will be improving the client applications to reduce the opportunities for traffic correlation.
Please read more in [Terms & privacy policy](./PRIVACY.md).
## Security contact
To report a security vulnerability, please send us email to chat@simplex.chat. We will coordinate the fix and disclosure. Please do NOT report security vulnerabilities via GitHub issues.
Please treat any findings of possible traffic correlation attacks allowing to correlate two different conversations to the same user, other than covered in [the threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#threat-model), as security vulnerabilities, and follow this disclosure process.
## License
@@ -243,4 +290,4 @@ You are likely to discover some bugs - we would really appreciate if you use it
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)

View File

@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 67
versionName "4.2"
versionCode 73
versionName "4.3-beta.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {

View File

@@ -29,6 +29,7 @@ extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
@@ -76,3 +77,11 @@ Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclas
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}

View File

@@ -7,19 +7,23 @@ import android.os.Bundle
import android.os.Parcelable
import android.os.SystemClock.elapsedRealtime
import android.util.Log
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
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.material.icons.Icons
import androidx.compose.material.icons.outlined.Replay
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.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.*
import chat.simplex.app.model.ChatModel
@@ -33,10 +37,12 @@ import chat.simplex.app.views.chat.ChatView
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.newchat.*
import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class MainActivity: FragmentActivity() {
companion object {
@@ -68,6 +74,13 @@ class MainActivity: FragmentActivity() {
processIntent(intent, m)
processExternalIntent(intent, m)
}
if (m.controller.appPrefs.privacyProtectScreen.get()) {
Log.d(TAG, "onCreate: set FLAG_SECURE")
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
}
setContent {
SimpleXTheme {
Surface(
@@ -104,6 +117,16 @@ class MainActivity: FragmentActivity() {
}
}
override fun onPause() {
super.onPause()
/**
* When new activity is created after a click on notification, the old one receives onPause before
* recreation but receives onStop after recreation. So using both (onPause and onStop) to prevent
* unwanted multiple auth dialogs from [runAuthenticate]
* */
enteredBackground.value = elapsedRealtime()
}
override fun onStop() {
super.onStop()
enteredBackground.value = elapsedRealtime()
@@ -135,17 +158,10 @@ class MainActivity: FragmentActivity() {
this@MainActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success -> {
LAResult.Success ->
userAuthorized.value = true
}
is LAResult.Error -> {
is LAResult.Error, LAResult.Failed ->
laFailed.value = true
laErrorToast(applicationContext, laResult.errString)
}
LAResult.Failed -> {
laFailed.value = true
laFailedToast(applicationContext)
}
LAResult.Unavailable -> {
userAuthorized.value = true
m.performLA.value = false
@@ -181,15 +197,9 @@ class MainActivity: FragmentActivity() {
prefPerformLA.set(true)
laTurnedOnAlert()
}
is LAResult.Error -> {
is LAResult.Error, LAResult.Failed -> {
m.performLA.value = false
prefPerformLA.set(false)
laErrorToast(applicationContext, laResult.errString)
}
LAResult.Failed -> {
m.performLA.value = false
prefPerformLA.set(false)
laFailedToast(applicationContext)
}
LAResult.Unavailable -> {
m.performLA.value = false
@@ -214,15 +224,9 @@ class MainActivity: FragmentActivity() {
m.performLA.value = false
prefPerformLA.set(false)
}
is LAResult.Error -> {
is LAResult.Error, LAResult.Failed -> {
m.performLA.value = true
prefPerformLA.set(true)
laErrorToast(applicationContext, laResult.errString)
}
LAResult.Failed -> {
m.performLA.value = true
prefPerformLA.set(true)
laFailedToast(applicationContext)
}
LAResult.Unavailable -> {
m.performLA.value = false
@@ -289,14 +293,14 @@ fun MainPage(
}
@Composable
fun retryAuthView() {
fun authView() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(R.string.auth_retry),
icon = Icons.Outlined.Replay,
stringResource(R.string.auth_unlock),
icon = Icons.Outlined.Lock,
click = {
laFailed.value = false
runAuthenticate()
@@ -317,7 +321,7 @@ fun MainPage(
onboarding == null || userCreated == null -> SplashView()
!chatsAccessAuthorized -> {
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
retryAuthView()
authView()
} else {
SplashView()
}
@@ -327,14 +331,65 @@ fun MainPage(
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else {
showAdvertiseLAAlert = true
val stopped = chatModel.chatRunning.value == false
if (chatModel.chatId.value == null) {
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, setPerformLA, stopped)
else
ShareListView(chatModel, stopped)
BoxWithConstraints {
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
Box(
Modifier
.graphicsLayer {
translationX = -offset.value.dp.toPx()
}
) {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, setPerformLA, stopped)
else
ShareListView(chatModel, stopped)
}
val scope = rememberCoroutineScope()
val onComposed: () -> Unit = {
scope.launch {
offset.animateTo(
if (chatModel.chatId.value == null) 0f else maxWidth.value,
chatListAnimationSpec()
)
if (offset.value == 0f) {
currentChatId = null
}
}
}
LaunchedEffect(Unit) {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (it != null) currentChatId = it
else onComposed()
// Deletes files that were not sent but already stored in files directory.
// Currently, it's voice records only
if (it == null && chatModel.filesToDelete.isNotEmpty()) {
chatModel.filesToDelete.forEach { it.delete() }
chatModel.filesToDelete.clear()
}
}
}
launch {
snapshotFlow { chatModel.sharedContent.value }
.distinctUntilChanged()
.filter { it != null }
.collect {
chatModel.chatId.value = null
currentChatId = null
}
}
}
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
currentChatId?.let {
ChatView(it, chatModel, onComposed)
}
}
}
else ChatView(chatModel)
}
}
}
@@ -426,23 +481,23 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
// TODO open from chat list view
chatModel.appOpenUrl.value = uri
} else {
withUriAction(uri) { action ->
val title = when (action) {
"contact" -> generalGetString(R.string.connect_via_contact_link)
"invitation" -> generalGetString(R.string.connect_via_invitation_link)
else -> {
Log.e(TAG, "URI has unexpected action. Alert shown.")
action
}
withUriAction(uri) { linkType ->
val title = when (linkType) {
ConnectionLinkType.CONTACT -> generalGetString(R.string.connect_via_contact_link)
ConnectionLinkType.INVITATION -> generalGetString(R.string.connect_via_invitation_link)
ConnectionLinkType.GROUP -> generalGetString(R.string.connect_via_group_link)
}
AlertManager.shared.showAlertMsg(
title = title,
text = generalGetString(R.string.profile_will_be_sent_to_contact_sending_link),
text = if (linkType == ConnectionLinkType.GROUP)
generalGetString(R.string.you_will_join_group)
else
generalGetString(R.string.profile_will_be_sent_to_contact_sending_link),
confirmText = generalGetString(R.string.connect_via_link_verb),
onConfirm = {
withApi {
Log.d(TAG, "connectIfOpenedViaUri: connecting")
connectViaUri(chatModel, action, uri)
connectViaUri(chatModel, linkType, uri)
}
}
)

View File

@@ -32,6 +32,7 @@ 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
external fun chatParseServer(str: String): String
class SimplexApp: Application(), LifecycleEventObserver {
lateinit var chatController: ChatController

View File

@@ -10,7 +10,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -20,7 +19,6 @@ import kotlinx.coroutines.withContext
class SimplexService: Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false
private var isStartingService = false
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
@@ -48,11 +46,32 @@ class SimplexService: Service() {
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
/**
* The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
* we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
* To prevent that, we can call [stopSelf] only when the service made [startForeground] call
* */
if (stopAfterStart) {
stopForeground(true)
stopSelf()
} else {
isServiceStarted = true
}
}
override fun onDestroy() {
Log.d(TAG, "Simplex service destroyed")
stopService()
try {
wakeLock?.let {
while (it.isHeld) it.release() // release all, in case acquired more than once
}
wakeLock = null
} catch (e: Exception) {
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
}
isServiceStarted = false
stopAfterStart = false
saveServiceState(this, ServiceState.STOPPED)
// If notification service is enabled and battery optimization is disabled, restart the service
if (SimplexApp.context.allowToStartServiceAfterAppExit())
@@ -62,7 +81,7 @@ class SimplexService: Service() {
private fun startService() {
Log.d(TAG, "SimplexService startService")
if (isServiceStarted || isStartingService) return
if (wakeLock != null || isStartingService) return
val self = this
isStartingService = true
withApi {
@@ -73,10 +92,9 @@ class SimplexService: Service() {
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
showPassphraseNotification(chatDbStatus)
stopService()
safeStopService(self)
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 {
@@ -89,22 +107,6 @@ class SimplexService: Service() {
}
}
private fun stopService() {
Log.d(TAG, "Stopping foreground service")
try {
wakeLock?.let {
while (it.isHeld) it.release() // release all, in case acquired more than once
}
wakeLock = null
stopForeground(true)
stopSelf()
} catch (e: Exception) {
Log.d(TAG, "Service stopped without being started: ${e.message}")
}
isServiceStarted = false
saveServiceState(this, ServiceState.STOPPED)
}
private fun createNotificationChannel(): NotificationManager? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -235,6 +237,9 @@ class SimplexService: Service() {
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
private var isServiceStarted = false
private var stopAfterStart = false
fun scheduleStart(context: Context) {
Log.d(TAG, "Enqueuing work to start subscriber service")
val workManager = WorkManager.getInstance(context)
@@ -244,7 +249,17 @@ class SimplexService: Service() {
suspend fun start(context: Context) = serviceAction(context, Action.START)
fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java))
/**
* If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an
* exception related to foreground services lifecycle
* */
fun safeStopService(context: Context) {
if (isServiceStarted) {
context.stopService(Intent(context, SimplexService::class.java))
} else {
stopAfterStart = true
}
}
private suspend fun serviceAction(context: Context, action: Action) {
Log.d(TAG, "SimplexService serviceAction: ${action.name}")

View File

@@ -20,7 +20,12 @@ import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
import java.io.File
/*
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
* */
@Stable
class ChatModel(val controller: ChatController) {
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
val currentUser = mutableStateOf<User?>(null)
@@ -39,7 +44,10 @@ class ChatModel(val controller: ChatController) {
val terminalItems = mutableStateListOf<TerminalItem>()
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
val userSMPServers = mutableStateOf<(List<String>)?>(null)
val userSMPServers = mutableStateOf<(List<ServerCfg>)?>(null)
// Allows to temporary save servers that are being edited on multiple screens
val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null)
val presetSMPServers = mutableStateOf<(List<String>)?>(null)
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
// set when app opened from external intent
@@ -70,6 +78,9 @@ class ChatModel(val controller: ChatController) {
// working with external intents
val sharedContent = mutableStateOf(null as SharedContent?)
val filesToDelete = mutableSetOf<File>()
val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
fun updateUserProfile(profile: LocalProfile) {
val user = currentUser.value
if (user != null) {
@@ -218,6 +229,7 @@ class ChatModel(val controller: ChatController) {
if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
AudioPlayer.stop(chatItems[itemIndex])
chatItems.removeAt(itemIndex)
}
}
@@ -307,16 +319,17 @@ class ChatModel(val controller: ChatController) {
}
fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean {
// user member was updated
if (groupInfo.membership.groupMemberId == member.groupMemberId) {
updateGroup(groupInfo)
return false
}
// update current chat
return if (chatId.value == groupInfo.id) {
val memberIndex = groupMembers.indexOfFirst { it.id == member.id }
if (memberIndex >= 0) {
groupMembers[memberIndex] = member
false
} else if (groupInfo.membership.groupMemberId == member.groupMemberId) {
// Current user was updated (like his role, for example)
updateChatInfo(ChatInfo.Group(groupInfo))
true
} else {
groupMembers.add(member)
true
@@ -340,6 +353,7 @@ data class User(
val userContactId: Long,
val localDisplayName: String,
val profile: LocalProfile,
val fullPreferences: FullChatPreferences,
val activeUser: Boolean
): NamedChat {
override val displayName: String get() = profile.displayName
@@ -353,6 +367,7 @@ data class User(
userContactId = 1,
localDisplayName = "alice",
profile = LocalProfile.sampleData,
fullPreferences = FullChatPreferences.sampleData,
activeUser = true
)
}
@@ -381,7 +396,7 @@ interface SomeChat {
val updatedAt: Instant
}
@Serializable
@Serializable @Stable
data class Chat (
val chatInfo: ChatInfo,
val chatItems: List<ChatItem>,
@@ -430,7 +445,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
abstract val incognito: Boolean
@Serializable @SerialName("direct")
class Direct(val contact: Contact): ChatInfo() {
data class Direct(val contact: Contact): ChatInfo() {
override val chatType get() = ChatType.Direct
override val localDisplayName get() = contact.localDisplayName
override val id get() = contact.id
@@ -527,8 +542,8 @@ data class Contact(
val activeConn: Connection,
val viaGroup: Long? = null,
val chatSettings: ChatSettings,
// User applies his preferences for the contact here. Named user_preferences on the contact in DB
val userPreferences: ChatPreferences,
val mergedPreferences: ContactUserPreferences,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -559,7 +574,8 @@ data class Contact(
profile = LocalProfile.sampleData,
activeConn = Connection.sampleData,
chatSettings = ChatSettings(true),
userPreferences = ChatPreferences(),
userPreferences = ChatPreferences.sampleData,
mergedPreferences = ContactUserPreferences.sampleData,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
@@ -589,12 +605,11 @@ class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: In
}
@Serializable
class Profile(
data class Profile(
override val displayName: String,
override val fullName: String,
override val image: String? = null,
override val localAlias : String = "",
// Contact applies his preferences here
val preferences: ChatPreferences? = null
): NamedChat {
val profileViewName: String
@@ -602,7 +617,7 @@ class Profile(
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
}
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias)
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias, preferences)
companion object {
val sampleData = Profile(
@@ -619,18 +634,18 @@ class LocalProfile(
override val fullName: String,
override val image: String? = null,
override val localAlias: String,
// Contact applies his preferences here
val preferences: ChatPreferences? = null
): NamedChat {
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias)
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias, preferences)
companion object {
val sampleData = LocalProfile(
profileId = 1L,
displayName = "alice",
fullName = "Alice",
preferences = ChatPreferences.sampleData,
localAlias = ""
)
}
@@ -647,10 +662,10 @@ data class GroupInfo (
val groupId: Long,
override val localDisplayName: String,
val groupProfile: GroupProfile,
val fullGroupPreferences: FullGroupPreferences,
val membership: GroupMember,
val hostConnCustomUserProfileId: Long? = null,
val chatSettings: ChatSettings,
// val groupPreferences: GroupPreferences? = null,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -679,6 +694,7 @@ data class GroupInfo (
groupId = 1,
localDisplayName = "team",
groupProfile = GroupProfile.sampleData,
fullGroupPreferences = FullGroupPreferences.sampleData,
membership = GroupMember.sampleData,
hostConnCustomUserProfileId = null,
chatSettings = ChatSettings(true),
@@ -689,11 +705,12 @@ data class GroupInfo (
}
@Serializable
class GroupProfile (
data class GroupProfile (
override val displayName: String,
override val fullName: String,
override val image: String? = null,
override val localAlias: String = "",
val groupPreferences: GroupPreferences? = null
): NamedChat {
companion object {
val sampleData = GroupProfile(
@@ -1014,7 +1031,7 @@ class AChatItem (
val chatItem: ChatItem
)
@Serializable
@Serializable @Stable
data class ChatItem (
val chatDir: CIDirection,
val meta: CIMeta,
@@ -1028,6 +1045,9 @@ data class ChatItem (
val text: String get() =
when {
content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> {
(content.msgContent as MsgContent.MCVoice).toTextWithDuration(false)
}
content.text == "" && file != null -> file.fileName
else -> content.text
}
@@ -1141,6 +1161,17 @@ data class ChatItem (
quotedItem = null,
file = null
)
fun getChatFeatureSample(feature: Feature, enabled: FeatureEnabled): ChatItem {
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled)
return ChatItem(
chatDir = CIDirection.DirectRcv(),
meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
content = content,
quotedItem = null,
file = null
)
}
}
}
@@ -1237,22 +1268,32 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: Feature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: Feature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val feature: Feature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val feature: Feature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: Feature): CIContent() { override val msgContent: MsgContent? get() = null }
override val text: String get() = when(this) {
is SndMsgContent -> msgContent.text
is RcvMsgContent -> msgContent.text
is SndDeleted -> generalGetString(R.string.deleted_description)
is RcvDeleted -> generalGetString(R.string.deleted_description)
is SndCall -> status.text(duration)
is RcvCall -> status.text(duration)
is RcvIntegrityError -> msgError.text
is RcvGroupInvitation -> groupInvitation.text
is SndGroupInvitation -> groupInvitation.text
is RcvGroupEventContent -> rcvGroupEvent.text
is SndGroupEventContent -> sndGroupEvent.text
is RcvConnEventContent -> rcvConnEvent.text
is SndConnEventContent -> sndConnEvent.text
}
override val text: String get() = when (this) {
is SndMsgContent -> msgContent.text
is RcvMsgContent -> msgContent.text
is SndDeleted -> generalGetString(R.string.deleted_description)
is RcvDeleted -> generalGetString(R.string.deleted_description)
is SndCall -> status.text(duration)
is RcvCall -> status.text(duration)
is RcvIntegrityError -> msgError.text
is RcvGroupInvitation -> groupInvitation.text
is SndGroupInvitation -> groupInvitation.text
is RcvGroupEventContent -> rcvGroupEvent.text
is SndGroupEventContent -> sndGroupEvent.text
is RcvConnEventContent -> rcvConnEvent.text
is SndConnEventContent -> sndConnEvent.text
is RcvChatFeature -> "${feature.text()}: ${enabled.text}"
is SndChatFeature -> "${feature.text()}: ${enabled.text}"
is RcvGroupFeature -> "${feature.text()}: ${preference.enable.text}"
is SndGroupFeature -> "${feature.text()}: ${preference.enable.text}"
is RcvChatFeatureRejected -> "${feature.text()}: ${generalGetString(R.string.feature_received_prohibited)}"
}
}
@Serializable
@@ -1264,7 +1305,13 @@ class CIQuote (
val content: MsgContent,
val formattedText: List<FormattedText>? = null
): ItemContent {
override val text: String get() = content.text
override val text: String by lazy {
if (content is MsgContent.MCVoice && content.text.isEmpty())
content.toTextWithDuration(true)
else
content.text
}
fun sender(membership: GroupMember?): String? = when (chatDir) {
is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun)
@@ -1333,6 +1380,7 @@ sealed class MsgContent {
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
@@ -1340,11 +1388,17 @@ sealed class MsgContent {
is MCText -> "text $text"
is MCLink -> "json ${json.encodeToString(this)}"
is MCImage -> "json ${json.encodeToString(this)}"
is MCVoice-> "json ${json.encodeToString(this)}"
is MCFile -> "json ${json.encodeToString(this)}"
is MCUnknown -> "json $json"
}
}
fun MsgContent.MCVoice.toTextWithDuration(short: Boolean): String {
val time = durationToString(duration)
return if (short) time else generalGetString(R.string.voice_message) + " ($time)"
}
@Serializable
class CIGroupInvitation (
val groupId: Long,
@@ -1413,6 +1467,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
MsgContent.MCImage(text, image)
}
"voice" -> {
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
MsgContent.MCVoice(text, duration)
}
"file" -> MsgContent.MCFile(text)
else -> MsgContent.MCUnknown(t, text, json)
}
@@ -1444,6 +1502,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
put("text", value.text)
put("image", value.image)
}
is MsgContent.MCVoice ->
buildJsonObject {
put("type", "voice")
put("text", value.text)
put("duration", value.duration)
}
is MsgContent.MCFile ->
buildJsonObject {
put("type", "file")
@@ -1457,12 +1521,21 @@ object MsgContentSerializer : KSerializer<MsgContent> {
@Serializable
class FormattedText(val text: String, val format: Format? = null) {
val link: String? = when (format) {
// TODO make it dependent on simplexLinkMode preference
fun link(mode: SimplexLinkMode): String? = when (format) {
is Format.Uri -> text
is Format.SimplexLink -> if (mode == SimplexLinkMode.BROWSER) text else format.simplexUri
is Format.Email -> "mailto:$text"
is Format.Phone -> "tel:$text"
else -> null
}
// TODO make it dependent on simplexLinkMode preference
fun viewText(mode: SimplexLinkMode): String =
if (format is Format.SimplexLink && mode == SimplexLinkMode.DESCRIPTION) simplexLinkText(format.linkType, format.smpHosts) else text
fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List<String>): String =
"${linkType.description} (${String.format(generalGetString(R.string.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
}
@Serializable
@@ -1474,6 +1547,7 @@ sealed class Format {
@Serializable @SerialName("secret") class Secret: Format()
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
@Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val trustedUri: Boolean, val smpHosts: List<String>): Format()
@Serializable @SerialName("email") class Email: Format()
@Serializable @SerialName("phone") class Phone: Format()
@@ -1485,6 +1559,7 @@ sealed class Format {
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
is Colored -> SpanStyle(color = this.color.uiColor)
is Uri -> linkStyle
is SimplexLink -> linkStyle
is Email -> linkStyle
is Phone -> linkStyle
}
@@ -1494,6 +1569,19 @@ sealed class Format {
}
}
@Serializable
enum class SimplexLinkType(val linkType: String) {
contact("contact"),
invitation("invitation"),
group("group");
val description: String get() = generalGetString(when (this) {
contact -> R.string.simplex_link_contact
invitation -> R.string.simplex_link_invitation
group -> R.string.simplex_link_group
})
}
@Serializable
enum class FormatColor(val color: String) {
red("red"),
@@ -1509,7 +1597,7 @@ enum class FormatColor(val color: String) {
red -> Color.Red
green -> SimplexGreen
blue -> SimplexBlue
yellow -> Color.Yellow
yellow -> WarningYellow
cyan -> Color.Cyan
magenta -> Color.Magenta
black -> MaterialTheme.colors.onBackground
@@ -1544,11 +1632,9 @@ enum class CICallStatus {
Accepted -> generalGetString(R.string.callstatus_accepted)
Negotiated -> generalGetString(R.string.callstatus_connecting)
Progress -> generalGetString(R.string.callstatus_in_progress)
Ended -> String.format(generalGetString(R.string.callstatus_ended), duration(sec))
Ended -> String.format(generalGetString(R.string.callstatus_ended), durationToString(sec))
Error -> generalGetString(R.string.callstatus_error)
}
fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
}
@Serializable

View File

@@ -212,7 +212,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
if (cItem.content.text != "") {
cItem.content.text
} else {
cItem.file?.fileName ?: ""
if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
}
} else {
var res = ""

View File

@@ -12,8 +12,11 @@ import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.KeyboardVoice
import androidx.compose.material.icons.outlined.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -34,7 +37,7 @@ import kotlinx.datetime.Instant
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlin.concurrent.thread
import java.util.Date
typealias ChatCtrl = Long
@@ -60,6 +63,16 @@ enum class CallOnLockScreen {
}
}
enum class SimplexLinkMode {
DESCRIPTION,
FULL,
BROWSER;
companion object {
val default = SimplexLinkMode.DESCRIPTION
}
}
class AppPreferences(val context: Context) {
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
@@ -74,7 +87,7 @@ class AppPreferences(val context: Context) {
val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
val webrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true)
private val _callOnLockScreen = mkStrPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default.name)
val callOnLockScreen: Preference<CallOnLockScreen> = Preference(
val callOnLockScreen: SharedPreference<CallOnLockScreen> = SharedPreference(
get = fun(): CallOnLockScreen {
val value = _callOnLockScreen.get() ?: return CallOnLockScreen.default
return try {
@@ -88,9 +101,22 @@ 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 privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
val privacyTransferImagesInline = mkBoolPreference(SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE, false)
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name)
val simplexLinkMode: SharedPreference<SimplexLinkMode> = SharedPreference(
get = fun(): SimplexLinkMode {
val value = _simplexLinkMode.get() ?: return SimplexLinkMode.default
return try {
SimplexLinkMode.valueOf(value)
} catch (e: Error) {
SimplexLinkMode.default
}
},
set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) }
)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
@@ -119,33 +145,33 @@ class AppPreferences(val context: Context) {
val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb())
private fun mkIntPreference(prefName: String, default: Int) =
Preference(
SharedPreference(
get = fun() = sharedPreferences.getInt(prefName, default),
set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
)
private fun mkLongPreference(prefName: String, default: Long) =
Preference(
SharedPreference(
get = fun() = sharedPreferences.getLong(prefName, default),
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
)
private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): Preference<Long> {
private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): SharedPreference<Long> {
val d = if (networkUseSocksProxy.get()) proxyDefault else default
return Preference(
return SharedPreference(
get = fun() = sharedPreferences.getLong(prefName, d),
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
)
}
private fun mkBoolPreference(prefName: String, default: Boolean) =
Preference(
SharedPreference(
get = fun() = sharedPreferences.getBoolean(prefName, default),
set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply()
)
private fun mkStrPreference(prefName: String, default: String?): Preference<String?> =
Preference(
private fun mkStrPreference(prefName: String, default: String?): SharedPreference<String?> =
SharedPreference(
get = fun() = sharedPreferences.getString(prefName, default),
set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply()
)
@@ -154,8 +180,8 @@ class AppPreferences(val context: Context) {
* 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(
private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): SharedPreference<Instant?> =
SharedPreference(
get = {
val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString())
pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) }
@@ -178,9 +204,11 @@ class AppPreferences(val context: Context) {
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_PROTECT_SCREEN = "PrivacyProtectScreen"
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
@@ -235,7 +263,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
apiSetFilesFolder(getAppFilesDirectory(appContext))
apiSetIncognito(chatModel.incognito.value)
chatModel.userAddress.value = apiGetUserAddress()
chatModel.userSMPServers.value = getUserSMPServers()
val smpServers = getUserSMPServers()
chatModel.userSMPServers.value = smpServers?.first
chatModel.presetSMPServers.value = smpServers?.second
chatModel.chatItemTTL.value = getChatItemTTL()
val chats = apiGetChats()
chatModel.updateChats(chats)
@@ -244,6 +274,10 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
chatModel.appOpenUrl.value?.let {
chatModel.appOpenUrl.value = null
connectIfOpenedViaUri(it, chatModel)
}
startReceiver()
Log.d(TAG, "startChat: started")
} else {
@@ -260,8 +294,20 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
private fun startReceiver() {
Log.d(TAG, "ChatController startReceiver")
if (receiverStarted) return
thread(name="receiver") {
GlobalScope.launch { withContext(Dispatchers.IO) { recvMspLoop() } }
receiverStarted = true
CoroutineScope(Dispatchers.IO).launch {
while (true) {
/** Global [ctrl] can be null. It's needed for having the same [ChatModel] that already made in [ChatController] without the need
* to change it everywhere in code after changing a database.
* Since it can be changed in background thread, making this check to prevent NullPointerException */
val ctrl = ctrl
if (ctrl == null) {
receiverStarted = false
break
}
val msg = recvMsg(ctrl)
if (msg != null) processReceivedMsg(msg)
}
}
}
@@ -287,26 +333,18 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
private suspend fun recvMsg(ctrl: ChatCtrl): CR? {
return withContext(Dispatchers.IO) {
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
if (json == "") {
null
} else {
val r = APIResponse.decodeStr(json).resp
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
r
}
private fun recvMsg(ctrl: ChatCtrl): CR? {
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
return if (json == "") {
null
} else {
val r = APIResponse.decodeStr(json).resp
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
r
}
}
private suspend fun recvMspLoop() {
val msg = recvMsg(ctrl ?: return)
if (msg != null) processReceivedMsg(msg)
recvMspLoop()
}
suspend fun apiGetActiveUser(): User? {
val r = sendCmd(CC.ShowActiveUser())
if (r is CR.ActiveUser) return r.user
@@ -417,14 +455,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
private suspend fun getUserSMPServers(): List<String>? {
private suspend fun getUserSMPServers(): Pair<List<ServerCfg>, List<String>>? {
val r = sendCmd(CC.GetUserSMPServers())
if (r is CR.UserSMPServers) return r.smpServers
if (r is CR.UserSMPServers) return r.smpServers to r.presetSMPServers
Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun setUserSMPServers(smpServers: List<String>): Boolean {
suspend fun setUserSMPServers(smpServers: List<ServerCfg>): Boolean {
val r = sendCmd(CC.SetUserSMPServers(smpServers))
return when (r) {
is CR.CmdOk -> true
@@ -439,6 +477,17 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
suspend fun testSMPServer(smpServer: String): SMPTestFailure? {
val r = sendCmd(CC.TestSMPServer(smpServer))
return when (r) {
is CR.SmpTestResult -> r.smpTestFailure
else -> {
Log.e(TAG, "testSMPServer bad response: ${r.responseType} ${r.details}")
throw Exception("testSMPServer bad response: ${r.responseType} ${r.details}")
}
}
}
suspend fun getChatItemTTL(): ChatItemTTL {
val r = sendCmd(CC.APIGetChatItemTTL())
if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL)
@@ -614,6 +663,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? {
val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs))
if (r is CR.ContactPrefsUpdated) return r.toContact
Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiSetContactAlias(contactId: Long, localAlias: String): Contact? {
val r = sendCmd(CC.ApiSetContactAlias(contactId, localAlias))
if (r is CR.ContactAliasUpdated) return r.toContact
@@ -628,13 +684,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? {
val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs))
if (r is CR.ContactPrefsUpdated) return r.toContact
Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiCreateUserAddress(): String? {
val r = sendCmd(CC.CreateMyAddress())
return when (r) {
@@ -772,7 +821,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiReceiveFile", generalGetString(R.string.error_receiving_file), r)
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
&& r.chatError.errorType is ChatErrorType.FileAlreadyReceiving
) {
Log.d(TAG, "apiReceiveFile ignoring FileAlreadyReceiving error")
} else {
apiErrorAlert("apiReceiveFile", generalGetString(R.string.error_receiving_file), r)
}
}
null
}
@@ -1006,6 +1061,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
val file = cItem.file
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) }
} else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) } // TODO check inlineFileMode != IFMSent
}
if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(cInfo, cItem)
@@ -1031,11 +1088,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.removeChatItem(cInfo, cItem)
} else {
// currently only broadcast deletion of rcv message can be received, and only this case should happen
AudioPlayer.stop(cItem)
chatModel.upsertChatItem(cInfo, cItem)
}
}
is CR.ReceivedGroupInvitation -> {
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(r.groupInfo), chatItems = listOf()))
chatModel.updateGroup(r.groupInfo) // update so that repeat group invitations are not duplicated
// TODO NtfManager.shared.notifyGroupInvitation
}
is CR.UserAcceptedGroupSent -> {
@@ -1212,7 +1270,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.notificationsMode.value = NotificationsMode.OFF
SimplexService.StartReceiver.toggleReceiver(false)
MessagesFetcherWorker.cancelAll()
SimplexService.stop(SimplexApp.context)
SimplexService.safeStopService(SimplexApp.context)
} else {
// show battery optimization notice
showBGServiceNoticeIgnoreOptimization(mode)
@@ -1222,23 +1280,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
// service or periodic mode was chosen and battery optimization is disabled
SimplexApp.context.schedulePeriodicServiceRestartWorker()
SimplexApp.context.schedulePeriodicWakeUp()
chatModel.appOpenUrl.value?.let {
chatModel.appOpenUrl.value = null
connectIfOpenedViaUri(it, chatModel)
}
}
}
private fun showBGServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert {
val hideAlert: () -> Unit = {
AlertManager.shared.hideAlert()
chatModel.appOpenUrl.value?.let {
chatModel.appOpenUrl.value = null
connectIfOpenedViaUri(it, chatModel)
}
}
AlertDialog(
onDismissRequest = hideAlert,
onDismissRequest = AlertManager.shared::hideAlert,
title = {
Row {
Icon(
@@ -1264,7 +1311,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
},
confirmButton = {
TextButton(onClick = hideAlert) { Text(stringResource(R.string.ok)) }
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
}
)
}
@@ -1305,15 +1352,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
private fun showDisablingServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert {
val hideAlert: () -> Unit = {
AlertManager.shared.hideAlert()
chatModel.appOpenUrl.value?.let {
chatModel.appOpenUrl.value = null
connectIfOpenedViaUri(it, chatModel)
}
}
AlertDialog(
onDismissRequest = hideAlert,
onDismissRequest = AlertManager.shared::hideAlert,
title = {
Row {
Icon(
@@ -1336,7 +1376,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
},
confirmButton = {
TextButton(onClick = hideAlert) { Text(stringResource(R.string.ok)) }
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
}
)
}
@@ -1361,15 +1401,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
appPrefs.performLA.set(true)
laTurnedOnAlert()
}
is LAResult.Error -> {
is LAResult.Error, LAResult.Failed -> {
chatModel.performLA.value = false
appPrefs.performLA.set(false)
laErrorToast(appContext, laResult.errString)
}
LAResult.Failed -> {
chatModel.performLA.value = false
appPrefs.performLA.set(false)
laFailedToast(appContext)
}
LAResult.Unavailable -> {
chatModel.performLA.value = false
@@ -1446,7 +1480,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
class Preference<T>(val get: () -> T, val set: (T) -> Unit)
class SharedPreference<T>(val get: () -> T, val set: (T) -> Unit)
// ChatCommand
sealed class CC {
@@ -1478,7 +1512,8 @@ sealed class CC {
class APIDeleteGroupLink(val groupId: Long): CC()
class APIGetGroupLink(val groupId: Long): CC()
class GetUserSMPServers: CC()
class SetUserSMPServers(val smpServers: List<String>): CC()
class SetUserSMPServers(val smpServers: List<ServerCfg>): CC()
class TestSMPServer(val smpServer: String): CC()
class APISetChatItemTTL(val seconds: Long?): CC()
class APIGetChatItemTTL: CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
@@ -1494,10 +1529,10 @@ sealed class CC {
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ListContacts: CC()
class ApiUpdateProfile(val profile: Profile): CC()
class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): 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 ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
class CreateMyAddress: CC()
class DeleteMyAddress: CC()
class ShowMyAddress: CC()
@@ -1543,8 +1578,9 @@ sealed class CC {
is APICreateGroupLink -> "/_create link #$groupId"
is APIDeleteGroupLink -> "/_delete link #$groupId"
is APIGetGroupLink -> "/_get link #$groupId"
is GetUserSMPServers -> "/smp_servers"
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is GetUserSMPServers -> "/smp"
is SetUserSMPServers -> "/_smp ${smpServersStr(smpServers)}"
is TestSMPServer -> "/smp test $smpServer"
is APISetChatItemTTL -> "/_ttl ${chatItemTTLStr(seconds)}"
is APIGetChatItemTTL -> "/ttl"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
@@ -1560,10 +1596,10 @@ sealed class CC {
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ListContacts -> "/contacts"
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
is ApiParseMarkdown -> "/_parse $text"
is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}"
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
is CreateMyAddress -> "/address"
is DeleteMyAddress -> "/delete_address"
is ShowMyAddress -> "/show_address"
@@ -1612,6 +1648,7 @@ sealed class CC {
is APIGetGroupLink -> "apiGetGroupLink"
is GetUserSMPServers -> "getUserSMPServers"
is SetUserSMPServers -> "setUserSMPServers"
is TestSMPServer -> "testSMPServer"
is APISetChatItemTTL -> "apiSetChatItemTTL"
is APIGetChatItemTTL -> "apiGetChatItemTTL"
is APISetNetworkConfig -> "/apiSetNetworkConfig"
@@ -1627,10 +1664,10 @@ sealed class CC {
is ApiClearChat -> "apiClearChat"
is ListContacts -> "listContacts"
is ApiUpdateProfile -> "updateProfile"
is ApiSetContactPrefs -> "apiSetContactPrefs"
is ApiParseMarkdown -> "apiParseMarkdown"
is ApiSetContactAlias -> "apiSetContactAlias"
is ApiSetConnectionAlias -> "apiSetConnectionAlias"
is ApiSetContactPrefs -> "apiSetContactPrefs"
is CreateMyAddress -> "createMyAddress"
is DeleteMyAddress -> "deleteMyAddress"
is ShowMyAddress -> "showMyAddress"
@@ -1669,7 +1706,7 @@ sealed class CC {
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
fun smpServersStr(smpServers: List<String>) = if (smpServers.isEmpty()) "default" else smpServers.joinToString(separator = ";")
fun smpServersStr(smpServers: List<ServerCfg>) = if (smpServers.isEmpty()) "default" else json.encodeToString(SMPServersConfig(smpServers))
}
}
@@ -1700,6 +1737,147 @@ class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? =
@Serializable
class DBEncryptionConfig(val currentKey: String, val newKey: String)
@Serializable
data class SMPServersConfig(
val smpServers: List<ServerCfg>
)
@Serializable
data class ServerCfg(
val server: String,
val preset: Boolean,
val tested: Boolean? = null,
val enabled: Boolean
) {
@Transient
private val createdAt: Date = Date()
// val sendEnabled: Boolean // can we potentially want to prevent sending on the servers we use to receive?
// Even if we don't see the use case, it's probably better to allow it in the model
// In any case, "trusted/known" servers are out of scope of this change
val id: String
get() = "$server $createdAt"
val isBlank: Boolean
get() = server.isBlank()
companion object {
val empty = ServerCfg(server = "", preset = false, tested = null, enabled = true)
class SampleData(
val preset: ServerCfg,
val custom: ServerCfg,
val untested: ServerCfg
)
val sampleData = SampleData(
preset = ServerCfg(
server = "smp://abcd@smp8.simplex.im",
preset = true,
tested = true,
enabled = true
),
custom = ServerCfg(
server = "smp://abcd@smp9.simplex.im",
preset = false,
tested = false,
enabled = false
),
untested = ServerCfg(
server = "smp://abcd@smp10.simplex.im",
preset = false,
tested = null,
enabled = true
)
)
}
}
@Serializable
enum class SMPTestStep {
@SerialName("connect") Connect,
@SerialName("createQueue") CreateQueue,
@SerialName("secureQueue") SecureQueue,
@SerialName("deleteQueue") DeleteQueue,
@SerialName("disconnect") Disconnect;
val text: String get() = when (this) {
Connect -> generalGetString(R.string.smp_server_test_connect)
CreateQueue -> generalGetString(R.string.smp_server_test_create_queue)
SecureQueue -> generalGetString(R.string.smp_server_test_secure_queue)
DeleteQueue -> generalGetString(R.string.smp_server_test_delete_queue)
Disconnect -> generalGetString(R.string.smp_server_test_disconnect)
}
}
@Serializable
data class SMPTestFailure(
val testStep: SMPTestStep,
val testError: AgentErrorType
) {
override fun equals(other: Any?): Boolean {
if (other !is SMPTestFailure) return false
return other.testStep == this.testStep
}
override fun hashCode(): Int {
return testStep.hashCode()
}
val localizedDescription: String get() {
val err = String.format(generalGetString(R.string.error_smp_test_failed_at_step), testStep.text)
return when {
testError is AgentErrorType.SMP && testError.smpErr is SMPErrorType.AUTH ->
err + " " + generalGetString(R.string.error_smp_test_server_auth)
testError is AgentErrorType.BROKER && testError.brokerErr is BrokerErrorType.NETWORK ->
err + " " + generalGetString(R.string.error_smp_test_certificate)
else -> err
}
}
}
@Serializable
data class ServerAddress(
val hostnames: List<String>,
val port: String,
val keyHash: String,
val basicAuth: String = ""
) {
val uri: String
get() =
"smp://${keyHash}${if (basicAuth.isEmpty()) "" else ":$basicAuth"}@${hostnames.joinToString(",")}"
val valid: Boolean
get() = hostnames.isNotEmpty() && hostnames.toSet().size == hostnames.size
companion object {
val empty = ServerAddress(
hostnames = emptyList(),
port = "",
keyHash = "",
basicAuth = ""
)
val sampleData = ServerAddress(
hostnames = listOf("smp.simplex.im", "1234.onion"),
port = "",
keyHash = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=",
basicAuth = "server_password"
)
fun parseServerAddress(s: String): ServerAddress? {
val parsed = chatParseServer(s)
return runCatching { json.decodeFromString(ParsedServerAddress.serializer(), parsed) }
.onFailure { Log.d(TAG, "parseServerAddress decode error: $it") }
.getOrNull()?.serverAddress
}
}
}
@Serializable
data class ParsedServerAddress (
var serverAddress: ServerAddress?,
var parseError: String
)
@Serializable
data class NetCfg(
val socksProxy: String? = null,
@@ -1717,8 +1895,8 @@ data class NetCfg(
val defaults: NetCfg =
NetCfg(
socksProxy = null,
tcpConnectTimeout = 7_500_000,
tcpTimeout = 5_000_000,
tcpConnectTimeout = 10_000_000,
tcpTimeout = 7_000_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 600_000_000
)
@@ -1726,8 +1904,8 @@ data class NetCfg(
val proxyDefaults: NetCfg =
NetCfg(
socksProxy = ":9050",
tcpConnectTimeout = 15_000_000,
tcpTimeout = 10_000_000,
tcpConnectTimeout = 20_000_000,
tcpTimeout = 15_000_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 600_000_000
)
@@ -1779,29 +1957,294 @@ data class ChatSettings(
)
@Serializable
data class ChatPreferences(
val voice: ChatPreference? = null
data class FullChatPreferences(
val fullDelete: ChatPreference,
val voice: ChatPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(fullDelete = fullDelete, voice = voice)
companion object {
val default = ChatPreferences(
voice = ChatPreference(allow = PrefAllowed.NO)
)
val empty = ChatPreferences(
voice = null
)
val sampleData = FullChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
}
}
@Serializable
data class ChatPreferences(
val fullDelete: ChatPreference? = null,
val voice: ChatPreference? = null,
) {
companion object {
val sampleData = ChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
}
}
@Serializable
data class ChatPreference(
val allow: PrefAllowed
val allow: FeatureAllowed
)
@Serializable
enum class PrefAllowed {
@SerialName("always") ALWAYS,
data class ContactUserPreferences(
val fullDelete: ContactUserPreference,
val voice: ContactUserPreference,
) {
companion object {
val sampleData = ContactUserPreferences(
fullDelete = ContactUserPreference(
enabled = FeatureEnabled(forUser = false, forContact = false),
userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.NO)),
contactPreference = ChatPreference(allow = FeatureAllowed.NO)
),
voice = ContactUserPreference(
enabled = FeatureEnabled(forUser = true, forContact = true),
userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.YES)),
contactPreference = ChatPreference(allow = FeatureAllowed.YES)
)
)
}
}
@Serializable
data class ContactUserPreference(
val enabled: FeatureEnabled,
val userPreference: ContactUserPref,
val contactPreference: ChatPreference,
)
@Serializable
data class FeatureEnabled(
val forUser: Boolean,
val forContact: Boolean
) {
val text: String
get() = when {
forUser && forContact -> generalGetString(R.string.feature_enabled)
forUser -> generalGetString(R.string.feature_enabled_for_you)
forContact -> generalGetString(R.string.feature_enabled_for_contact)
else -> generalGetString(R.string.feature_off)
}
val iconColor: Color
get() = if (forUser) SimplexGreen else if (forContact) WarningYellow else HighOrLowlight
companion object {
fun enabled(user: ChatPreference, contact: ChatPreference): FeatureEnabled =
when {
user.allow == FeatureAllowed.ALWAYS && contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = true)
user.allow == FeatureAllowed.NO && contact.allow == FeatureAllowed.ALWAYS -> FeatureEnabled(forUser = true, forContact = false)
contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
user.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
else -> FeatureEnabled(forUser = true, forContact = true)
}
}
}
@Serializable
sealed class ContactUserPref {
@Serializable @SerialName("contact") data class Contact(val preference: ChatPreference): ContactUserPref() // contact override is set
@Serializable @SerialName("user") data class User(val preference: ChatPreference): ContactUserPref() // global user default is used
}
@Serializable
enum class Feature {
@SerialName("fullDelete") FullDelete,
@SerialName("voice") Voice;
fun text() =
when(this) {
FullDelete -> generalGetString(R.string.full_deletion)
Voice -> generalGetString(R.string.voice_messages)
}
fun icon(filled: Boolean) =
when(this) {
FullDelete -> if (filled) Icons.Filled.DeleteForever else Icons.Outlined.DeleteForever
Voice -> if (filled) Icons.Filled.KeyboardVoice else Icons.Outlined.KeyboardVoice
}
fun allowDescription(allowed: FeatureAllowed): String =
when (this) {
FullDelete -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_irreversibly_delete)
FeatureAllowed.YES -> generalGetString(R.string.allow_irreversible_message_deletion_only_if)
FeatureAllowed.NO -> generalGetString(R.string.contacts_can_mark_messages_for_deletion)
}
Voice -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_to_send_voice_messages)
FeatureAllowed.YES -> generalGetString(R.string.allow_voice_messages_only_if)
FeatureAllowed.NO -> generalGetString(R.string.prohibit_sending_voice_messages)
}
}
fun enabledDescription(enabled: FeatureEnabled): String =
when (this) {
FullDelete -> when {
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contacts_can_delete)
enabled.forUser -> generalGetString(R.string.only_you_can_delete_messages)
enabled.forContact -> generalGetString(R.string.only_your_contact_can_delete)
else -> generalGetString(R.string.message_deletion_prohibited)
}
Voice -> when {
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_send_voice)
enabled.forUser -> generalGetString(R.string.only_you_can_send_voice)
enabled.forContact -> generalGetString(R.string.only_your_contact_can_send_voice)
else -> generalGetString(R.string.voice_prohibited_in_this_chat)
}
}
fun enableGroupPrefDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String =
if (canEdit) {
when(this) {
FullDelete -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_delete_messages)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_deletion)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_voice)
}
}
} else {
when(this) {
FullDelete -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_delete)
GroupFeatureEnabled.OFF -> generalGetString(R.string.message_deletion_prohibited_in_chat)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.voice_messages_are_prohibited)
}
}
}
}
@Serializable
sealed class ContactFeatureAllowed {
@Serializable @SerialName("userDefault") data class UserDefault(val default: FeatureAllowed): ContactFeatureAllowed()
@Serializable @SerialName("always") object Always: ContactFeatureAllowed()
@Serializable @SerialName("yes") object Yes: ContactFeatureAllowed()
@Serializable @SerialName("no") object No: ContactFeatureAllowed()
companion object {
fun values(def: FeatureAllowed): List<ContactFeatureAllowed> = listOf(UserDefault(def), Always, Yes, No)
}
val allowed: FeatureAllowed
get() = when (this) {
is UserDefault -> this.default
is Always -> FeatureAllowed.ALWAYS
is Yes -> FeatureAllowed.YES
is No -> FeatureAllowed.NO
}
val text: String
get() = when (this) {
is UserDefault -> String.format(generalGetString(R.string.chat_preferences_default), default.text)
is Always -> generalGetString(R.string.chat_preferences_always)
is Yes -> generalGetString(R.string.chat_preferences_yes)
is No -> generalGetString(R.string.chat_preferences_no)
}
}
data class ContactFeaturesAllowed(
val fullDelete: ContactFeatureAllowed,
val voice: ContactFeatureAllowed
) {
companion object {
val sampleData = ContactFeaturesAllowed(
fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO),
voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES)
)
}
}
fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed =
ContactFeaturesAllowed(
fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
)
fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference): ContactFeatureAllowed =
when (val pref = contactUserPreference.userPreference) {
is ContactUserPref.User -> ContactFeatureAllowed.UserDefault(pref.preference.allow)
is ContactUserPref.Contact -> when (pref.preference.allow) {
FeatureAllowed.ALWAYS -> ContactFeatureAllowed.Always
FeatureAllowed.YES -> ContactFeatureAllowed.Yes
FeatureAllowed.NO -> ContactFeatureAllowed.No
}
}
fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed): ChatPreferences =
ChatPreferences(
fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
)
fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): ChatPreference? =
when(contactFeatureAllowed) {
is ContactFeatureAllowed.UserDefault -> null
is ContactFeatureAllowed.Always -> ChatPreference(allow = FeatureAllowed.ALWAYS)
is ContactFeatureAllowed.Yes -> ChatPreference(allow = FeatureAllowed.YES)
is ContactFeatureAllowed.No -> ChatPreference(allow = FeatureAllowed.NO)
}
@Serializable
enum class FeatureAllowed {
@SerialName("yes") YES,
@SerialName("no") NO
@SerialName("no") NO,
@SerialName("always") ALWAYS;
val text: String
get() = when(this) {
ALWAYS -> generalGetString(R.string.chat_preferences_always)
YES -> generalGetString(R.string.chat_preferences_yes)
NO -> generalGetString(R.string.chat_preferences_no)
}
}
@Serializable
data class FullGroupPreferences(
val fullDelete: GroupPreference,
val voice: GroupPreference
) {
fun toGroupPreferences(): GroupPreferences =
GroupPreferences(fullDelete = fullDelete, voice = voice)
companion object {
val sampleData = FullGroupPreferences(fullDelete = GroupPreference(enable = GroupFeatureEnabled.OFF), voice = GroupPreference(enable = GroupFeatureEnabled.ON))
}
}
@Serializable
data class GroupPreferences(
val fullDelete: GroupPreference?,
val voice: GroupPreference?
) {
companion object {
val sampleData = GroupPreferences(fullDelete = GroupPreference(enable = GroupFeatureEnabled.OFF), voice = GroupPreference(enable = GroupFeatureEnabled.ON))
}
}
@Serializable
data class GroupPreference(
val enable: GroupFeatureEnabled
)
@Serializable
enum class GroupFeatureEnabled {
@SerialName("on") ON,
@SerialName("off") OFF;
val text: String
get() = when (this) {
ON -> generalGetString(R.string.chat_preferences_on)
OFF -> generalGetString(R.string.chat_preferences_off)
}
val iconColor: Color
get() = if (this == ON) SimplexGreen else HighOrLowlight
}
val json = Json {
@@ -1841,7 +2284,8 @@ sealed class CR {
@Serializable @SerialName("chatStopped") class ChatStopped: 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("userSMPServers") class UserSMPServers(val smpServers: List<ServerCfg>, val presetSMPServers: List<String>): CR()
@Serializable @SerialName("smpTestResult") class SmpTestResult(val smpTestFailure: SMPTestFailure? = null): 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()
@@ -1856,7 +2300,7 @@ sealed class 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("contactPrefsUpdated") class ContactPrefsUpdated(val toContact: Contact): CR()
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val fromContact: Contact, val toContact: Contact): CR()
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
@Serializable @SerialName("userContactLink") class UserContactLink(val contactLink: UserContactLinkRec): CR()
@Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val contactLink: UserContactLinkRec): CR()
@@ -1939,6 +2383,7 @@ sealed class CR {
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
is UserSMPServers -> "userSMPServers"
is SmpTestResult -> "smpTestResult"
is ChatItemTTL -> "chatItemTTL"
is NetworkConfig -> "networkConfig"
is ContactInfo -> "contactInfo"
@@ -2033,7 +2478,8 @@ sealed class CR {
is ChatStopped -> noDetails()
is ApiChats -> json.encodeToString(chats)
is ApiChat -> json.encodeToString(chat)
is UserSMPServers -> json.encodeToString(smpServers)
is UserSMPServers -> "$smpServers: ${json.encodeToString(smpServers)}\n$presetSMPServers: ${json.encodeToString(presetSMPServers)}"
is SmpTestResult -> json.encodeToString(smpTestFailure)
is ChatItemTTL -> json.encodeToString(chatItemTTL)
is NetworkConfig -> json.encodeToString(networkConfig)
is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}"
@@ -2048,7 +2494,7 @@ sealed class CR {
is UserProfileUpdated -> json.encodeToString(toProfile)
is ContactAliasUpdated -> json.encodeToString(toContact)
is ConnectionAliasUpdated -> json.encodeToString(toConnection)
is ContactPrefsUpdated -> json.encodeToString(toContact)
is ContactPrefsUpdated -> "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}"
is ParsedMarkdown -> json.encodeToString(formattedText)
is UserContactLink -> contactLink.responseDetails
is UserContactLinkUpdated -> contactLink.responseDetails
@@ -2191,10 +2637,12 @@ sealed class ChatErrorType {
val string: String get() = when (this) {
is NoActiveUser -> "noActiveUser"
is InvalidConnReq -> "invalidConnReq"
is FileAlreadyReceiving -> "fileAlreadyReceiving"
is СommandError -> "commandError $message"
}
@Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType()
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
@Serializable @SerialName("fileAlreadyReceiving") class FileAlreadyReceiving: ChatErrorType()
@Serializable @SerialName("commandError") class СommandError(val message: String): ChatErrorType()
}

View File

@@ -25,8 +25,7 @@ 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.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
@@ -136,7 +135,7 @@ fun TerminalLayout(
topBar = { CloseSheetBar(close) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
SendMsgView(composeState, false, false, false, sendCommand, ::onMessageChange, { _, _, _ -> }, {}, {}, textStyle)
}
},
modifier = Modifier.navigationBarsWithImePadding()
@@ -164,7 +163,8 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
LazyColumn(state = listState, reverseLayout = true) {
items(reversedTerminalItems) { item ->
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
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,
@@ -173,7 +173,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
.clickable {
ModalManager.shared.showModal {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(item.details)
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
}
}
}.padding(horizontal = 8.dp, vertical = 4.dp)

View File

@@ -42,8 +42,7 @@ 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.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@@ -63,7 +62,7 @@ fun ActiveCallView(chatModel: ChatModel) {
DisposableEffect(Unit) {
onDispose {
// Stop it when call ended
if (!ntfModeService) SimplexService.stop(SimplexApp.context)
if (!ntfModeService) SimplexService.safeStopService(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
@@ -357,6 +356,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
@Composable
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
val scope = rememberCoroutineScope()
val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
@@ -435,7 +435,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
Log.d(TAG, "WebRTCView: webview ready")
// for debugging
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
withApi {
scope.launch {
delay(2000L)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = wv

View File

@@ -35,7 +35,7 @@ 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 chat.simplex.app.views.usersettings.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@@ -47,7 +47,6 @@ fun ChatInfoView(
customUserProfile: Profile?,
localAlias: String,
close: () -> Unit,
onChatUpdated: (Chat) -> Unit,
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
@@ -61,7 +60,12 @@ fun ChatInfoView(
localAlias,
developerTools,
onLocalAliasChanged = {
setContactAlias(chat.chatInfo.apiId, it, chatModel, onChatUpdated)
setContactAlias(chat.chatInfo.apiId, it, chatModel)
},
openPreferences = {
ModalManager.shared.showModal(true) {
ContactPreferencesView(chatModel, chatModel.currentUser.value ?: return@showModal, contact.contactId)
}
},
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
@@ -118,6 +122,7 @@ fun ChatInfoLayout(
localAlias: String,
developerTools: Boolean,
onLocalAliasChanged: (String) -> Unit,
openPreferences: () -> Unit,
deleteContact: () -> Unit,
clearChat: () -> Unit,
switchContactAddress: () -> Unit,
@@ -144,13 +149,16 @@ fun ChatInfoLayout(
}
}
SectionSpacer()
SectionView {
ContactPreferencesButton(openPreferences)
}
SectionSpacer()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
if (developerTools) {
SwitchAddressButton(switchContactAddress)
SectionDivider()
}
SwitchAddressButton(switchContactAddress)
SectionDivider()
if (connStats != null) {
SectionItemView({
AlertManager.shared.showAlertMsg(
@@ -328,6 +336,15 @@ fun SwitchAddressButton(onClick: () -> Unit) {
}
}
@Composable
private fun ContactPreferencesButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.contact_preferences),
click = onClick
)
}
@Composable
fun ClearChatButton(onClick: () -> Unit) {
SettingsActionItem(
@@ -350,10 +367,9 @@ fun DeleteContactButton(onClick: () -> Unit) {
)
}
private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel, onChatUpdated: (Chat) -> Unit) = withApi {
private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel) = withApi {
chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let {
chatModel.updateContact(it)
onChatUpdated(chatModel.getChat(chatModel.chatId.value ?: return@withApi) ?: return@withApi)
}
}
@@ -388,6 +404,7 @@ fun PreviewChatInfoLayout() {
connStats = null,
onLocalAliasChanged = {},
customUserProfile = null,
openPreferences = {},
deleteContact = {},
clearChat = {},
switchContactAddress = {},

View File

@@ -50,8 +50,8 @@ import java.io.File
import kotlin.math.sign
@Composable
fun ChatView(chatModel: ChatModel) {
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
@@ -61,41 +61,48 @@ fun ChatView(chatModel: ChatModel) {
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (activeChat.value?.id != chatModel.chatId.value) {
activeChat.value = if (chatModel.chatId.value == null) {
null
} else {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (activeChat.value?.id != chatModel.chatId.value && chatModel.chatId.value != null) {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
chatModel.getChat(chatModel.chatId.value!!)
activeChat.value = chatModel.getChat(chatModel.chatId.value!!)
}
markUnreadChatAsRead(activeChat, chatModel)
}
markUnreadChatAsRead(activeChat, chatModel)
}
}
launch {
// .toList() is important for making observation working
snapshotFlow { chatModel.chats.toList() }
.distinctUntilChanged()
.collect { chats ->
chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }.let {
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
if (it?.chatInfo != activeChat.value?.chatInfo) {
activeChat.value = it
}}
}
}
}
val view = LocalView.current
if (activeChat.value == null || user == null) {
chatModel.chatId.value = null
} else {
val chat = activeChat.value!!
BackHandler { chatModel.chatId.value = null }
// We need to have real unreadCount value for displaying it inside top right button
// Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember {
derivedStateOf {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
}
}
ChatLayout(
user,
chat,
unreadCount,
composeState,
@@ -108,25 +115,30 @@ fun ChatView(chatModel: ChatModel) {
}
},
attachmentOption,
scope,
attachmentBottomSheetState,
chatModel.chatItems,
searchText,
useLinkPreviews = useLinkPreviews,
linkMode = chatModel.simplexLinkMode.value,
chatModelIncognito = chatModel.incognito.value,
back = { chatModel.chatId.value = null },
back = {
hideKeyboard(view)
AudioPlayer.stop()
chatModel.chatId.value = null
},
info = {
hideKeyboard(view)
withApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
if (chat.chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
ModalManager.shared.showModalCloseable(true) { close ->
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) {
activeChat.value = it
val contact = remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
contact.value?.let { ct ->
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
}
}
} else if (cInfo is ChatInfo.Group) {
setGroupMembers(cInfo.groupInfo, chatModel)
} else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, close)
}
@@ -134,6 +146,7 @@ fun ChatView(chatModel: ChatModel) {
}
},
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
ModalManager.shared.showModalCloseable(true) { close ->
@@ -177,6 +190,7 @@ fun ChatView(chatModel: ChatModel) {
}
},
acceptCall = { contact ->
hideKeyboard(view)
val invitation = chatModel.callInvitations.remove(contact.id)
if (invitation == null) {
AlertManager.shared.showAlertMsg("Call already ended!")
@@ -185,6 +199,7 @@ fun ChatView(chatModel: ChatModel) {
}
},
addMembers = { groupInfo ->
hideKeyboard(view)
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
@@ -211,24 +226,24 @@ fun ChatView(chatModel: ChatModel) {
apiFindMessages(c.chatInfo, chatModel, value)
searchText.value = value
}
}
},
onComposed,
)
}
}
@Composable
fun ChatLayout(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
chatModelIncognito: Boolean,
back: () -> Unit,
info: () -> Unit,
@@ -243,8 +258,10 @@ fun ChatLayout(
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
onComposed: () -> Unit,
) {
Surface(
val scope = rememberCoroutineScope()
Box(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background)
@@ -275,9 +292,9 @@ fun ChatLayout(
) { contentPadding ->
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
ChatItemsList(
user, chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
)
}
}
@@ -428,13 +445,13 @@ val CIListStateSaver = run {
@Composable
fun BoxWithConstraintsScope.ChatItemsList(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
chatModelIncognito: Boolean,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
@@ -444,11 +461,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
acceptCall: (Contact) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: () -> Unit,
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
ScrollToBottom(chat.id, listState)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
@@ -476,6 +492,16 @@ fun BoxWithConstraintsScope.ChatItemsList(
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
}
}
LaunchedEffect(Unit) {
var stopListening = false
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
.distinctUntilChanged()
.filter { !stopListening }
.collect {
onComposed()
stopListening = true
}
}
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems) { i, cItem ->
CompositionLocalProvider(
@@ -538,11 +564,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
} else {
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
} else { // direct message
@@ -553,7 +579,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
end = if (sent) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
@@ -700,7 +726,7 @@ fun PreloadItems(
.map {
val totalItemsNumber = it.totalItemsCount
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
totalItemsNumber
else
0
@@ -914,7 +940,6 @@ fun PreviewChatLayout() {
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") }
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = chatItems,
@@ -924,11 +949,11 @@ fun PreviewChatLayout() {
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
chatModelIncognito = false,
back = {},
info = {},
@@ -943,6 +968,7 @@ fun PreviewChatLayout() {
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
onComposed = {},
)
}
}
@@ -972,7 +998,6 @@ fun PreviewGroupChatLayout() {
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") }
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = chatItems,
@@ -982,11 +1007,11 @@ fun PreviewGroupChatLayout() {
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
chatModelIncognito = false,
back = {},
info = {},
@@ -1001,6 +1026,7 @@ fun PreviewGroupChatLayout() {
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
onComposed = {},
)
}
}

View File

@@ -1,12 +1,13 @@
package chat.simplex.app.views.chat
import ComposeVoiceView
import ComposeFileView
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.graphics.ImageDecoder.DecodeException
import android.graphics.drawable.AnimatedImageDrawable
import android.net.Uri
import android.provider.MediaStore
@@ -14,30 +15,29 @@ import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.CallSuper
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Reply
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.net.toUri
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.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
@@ -51,6 +51,7 @@ sealed class ComposePreview {
@Serializable object NoPreview: ComposePreview()
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
@Serializable class FilePreview(val fileName: String): ComposePreview()
}
@@ -69,7 +70,7 @@ data class ComposeState(
val inProgress: Boolean = false,
val useLinkPreviews: Boolean
) {
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this (
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this(
editingItem.content.text,
chatItemPreview(editingItem),
ComposeContextItem.EditingItem(editingItem),
@@ -86,6 +87,7 @@ data class ComposeState(
get() = {
val hasContent = when (preview) {
is ComposePreview.ImagePreview -> true
is ComposePreview.VoicePreview -> true
is ComposePreview.FilePreview -> true
else -> message.isNotEmpty()
}
@@ -95,6 +97,7 @@ data class ComposeState(
get() =
when (preview) {
is ComposePreview.ImagePreview -> false
is ComposePreview.VoicePreview -> false
is ComposePreview.FilePreview -> false
else -> useLinkPreviews
}
@@ -120,11 +123,12 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
is MsgContent.MCText -> ComposePreview.NoPreview
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true)
is MsgContent.MCFile -> {
val fileName = chatItem.file?.fileName ?: ""
ComposePreview.FilePreview(fileName)
}
else -> ComposePreview.NoPreview
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
}
}
@@ -137,53 +141,27 @@ fun ComposeView(
showChooseAttachment: () -> Unit
) {
val context = LocalContext.current
val linkUrl = remember { mutableStateOf<String?>(null) }
val prevLinkUrl = remember { mutableStateOf<String?>(null) }
val pendingLinkUrl = remember { mutableStateOf<String?>(null) }
val cancelledLinks = remember { mutableSetOf<String>() }
val linkUrl = rememberSaveable { mutableStateOf<String?>(null) }
val prevLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
// attachments
val chosenContent = remember { mutableStateOf<List<UploadContent>>(emptyList()) }
val chosenFile = remember { mutableStateOf<Uri?>(null) }
val photoUri = remember { mutableStateOf<Uri?>(null) }
val photoTmpFile = remember { mutableStateOf<File?>(null) }
class ComposeTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
@CallSuper
override fun createIntent(context: Context, input: Void?): Intent {
photoTmpFile.value = File.createTempFile("image", ".bmp", SimplexApp.context.filesDir)
photoUri.value = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", photoTmpFile.value!!)
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
.putExtra(MediaStore.EXTRA_OUTPUT, photoUri.value)
}
override fun getSynchronousResult(
context: Context,
input: Void?
): SynchronousResult<Bitmap?>? = null
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
val photoUriVal = photoUri.value
val photoTmpFileVal = photoTmpFile.value
return if (resultCode == Activity.RESULT_OK && photoUriVal != null && photoTmpFileVal != null) {
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, photoUriVal)
val bitmap = ImageDecoder.decodeBitmap(source)
photoTmpFileVal.delete()
bitmap
} else {
Log.e(TAG, "Getting image from camera cancelled or failed.")
photoTmpFile.value?.delete()
null
}
}
}
val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? ->
if (bitmap != null) {
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>> (
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
)
val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) }
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
chosenContent.value = listOf(UploadContent.SimpleImage(bitmap))
chosenContent.value = listOf(UploadContent.SimpleImage(uri))
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview)))
}
}
@@ -199,8 +177,17 @@ fun ComposeView(
val imagesPreview = ArrayList<String>()
uris.forEach { uri ->
val source = ImageDecoder.createSource(context.contentResolver, uri)
val drawable = ImageDecoder.decodeDrawable(source)
var bitmap: Bitmap? = ImageDecoder.decodeBitmap(source)
val drawable = try {
ImageDecoder.decodeDrawable(source)
} catch (e: DecodeException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.image_decoding_exception_title),
text = generalGetString(R.string.image_decoding_exception_desc)
)
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
null
}
var bitmap: Bitmap? = if (drawable != null) ImageDecoder.decodeBitmap(source) else null
if (drawable is AnimatedImageDrawable) {
// It's a gif or webp
val fileSize = getFileSize(context, uri)
@@ -214,7 +201,7 @@ fun ComposeView(
)
}
} else {
if (bitmap != null) content.add(UploadContent.SimpleImage(bitmap))
content.add(UploadContent.SimpleImage(uri))
}
if (bitmap != null) {
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
@@ -243,7 +230,7 @@ fun ComposeView(
}
}
}
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { processPickedImage(it, null) }
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
@@ -345,6 +332,7 @@ fun ComposeView(
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
}
@@ -354,6 +342,7 @@ fun ComposeView(
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
textStyle.value = smallFont
chosenContent.value = emptyList()
chosenAudio.value = null
chosenFile.value = null
linkUrl.value = null
prevLinkUrl.value = null
@@ -391,7 +380,7 @@ fun ComposeView(
is ComposePreview.ImagePreview -> {
chosenContent.value.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(context, it.bitmap)
is UploadContent.SimpleImage -> saveImage(context, it.uri)
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
}
if (file != null) {
@@ -400,6 +389,15 @@ fun ComposeView(
}
}
}
is ComposePreview.VoicePreview -> {
val chosenAudioVal = chosenAudio.value
if (chosenAudioVal != null) {
val file = chosenAudioVal.first.toFile().name
files.add((file))
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
@@ -450,6 +448,36 @@ fun ComposeView(
}
}
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
val file = File(filePath)
chosenAudio.value = file.toUri() to durationMs
chatModel.filesToDelete.add(file)
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
}
fun allowVoiceToContact() {
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
val featuresAllowed = contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
withApi {
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed).copy(voice = ChatPreference(FeatureAllowed.YES))
val toContact = chatModel.controller.apiSetContactPrefs(contact.contactId, prefs)
if (toContact != null) {
chatModel.updateContact(toContact)
}
}
}
fun showDisabledVoiceAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.voice_messages_prohibited),
text = generalGetString(
if (chat.chatInfo is ChatInfo.Direct)
R.string.ask_your_contact_to_enable_voice
else
R.string.only_group_owners_can_enable_voice
)
)
}
fun cancelLinkPreview() {
val uri = composeState.value.linkPreview?.uri
if (uri != null) {
@@ -464,6 +492,11 @@ fun ComposeView(
chosenContent.value = emptyList()
}
fun cancelVoice() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenContent.value = emptyList()
}
fun cancelFile() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenFile.value = null
@@ -479,6 +512,13 @@ fun ComposeView(
::cancelImages,
cancelEnabled = !composeState.value.editing
)
is ComposePreview.VoicePreview -> ComposeVoiceView(
preview.voice,
preview.durationMs,
preview.finished,
cancelEnabled = !composeState.value.editing,
::cancelVoice
)
is ComposePreview.FilePreview -> ComposeFileView(
preview.fileName,
::cancelFile,
@@ -513,44 +553,70 @@ fun ComposeView(
Column {
contextItemView()
when {
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
else -> previewView()
}
Row(
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
val attachEnabled = !composeState.value.editing
Box(Modifier.padding(bottom = 12.dp)) {
val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
IconButton(showChooseAttachment, enabled = attachEnabled) {
Icon(
Icons.Filled.AttachFile,
contentDescription = stringResource(R.string.attach),
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.clickable {
if (attachEnabled) {
showChooseAttachment()
}
}
)
}
val allowedVoiceByPrefs = remember(chat.chatInfo) {
when (chat.chatInfo) {
is ChatInfo.Direct -> chat.chatInfo.contact.mergedPreferences.voice.enabled.forUser
is ChatInfo.Group -> chat.chatInfo.groupInfo.fullGroupPreferences.voice.enable == GroupFeatureEnabled.ON
else -> false
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
when (chat.chatInfo) {
is ChatInfo.Direct -> with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
else -> false
}
}
SendMsgView(
composeState,
showVoiceRecordIcon = true,
allowedVoiceByPrefs = allowedVoiceByPrefs,
needToAllowVoiceToContact = needToAllowVoiceToContact,
sendMessage = {
sendMessage()
resetLinkPreview()
},
::onMessageChange,
::onAudioAdded,
::allowVoiceToContact,
::showDisabledVoiceAlert,
textStyle
)
}
}
}
class PickFromGallery: ActivityResultContract<Int, List<Uri>>() {
class PickFromGallery: ActivityResultContract<Int, Uri?>() {
override fun createIntent(context: Context, input: Int) =
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
type = "image/*"
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
}
class PickMultipleFromGallery: 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)

View File

@@ -0,0 +1,128 @@
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Close
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.SentColorLight
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun ComposeVoiceView(
filePath: String,
recordedDurationMs: Int,
finishedRecording: Boolean,
cancelEnabled: Boolean,
cancelVoice: () -> Unit
) {
BoxWithConstraints(Modifier
.fillMaxWidth()
) {
val audioPlaying = rememberSaveable { mutableStateOf(false) }
val progress = rememberSaveable { mutableStateOf(0) }
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
val progressBarWidth = remember { Animatable(0f) }
LaunchedEffect(recordedDurationMs, finishedRecording) {
snapshotFlow { progress.value }
.distinctUntilChanged()
.collect {
val startTime = when {
audioPlaying.value -> progress.value
finishedRecording && progress.value == duration.value -> progress.value
finishedRecording -> 0
else -> recordedDurationMs
}
val endTime = when {
finishedRecording -> duration.value
audioPlaying.value -> recordedDurationMs
else -> MAX_VOICE_MILLIS_FOR_SENDING.toInt()
}
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
}
}
Spacer(
Modifier
.requiredWidth(progressBarWidth.value.dp)
.padding(top = 58.dp)
.height(3.dp)
.background(MaterialTheme.colors.primary)
)
Row(
Modifier
.height(60.dp)
.fillMaxWidth()
.padding(top = 8.dp)
.background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
if (!audioPlaying.value) {
AudioPlayer.play(filePath, audioPlaying, progress, duration)
} else {
AudioPlayer.pause(audioPlaying, progress)
}
},
enabled = finishedRecording) {
Icon(
if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
stringResource(R.string.icon_descr_file),
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
tint = if (finishedRecording) MaterialTheme.colors.primary else HighOrLowlight
)
}
val numberInText = remember(recordedDurationMs, progress.value) {
derivedStateOf { if (audioPlaying.value) progress.value / 1000 else recordedDurationMs / 1000 }
}
Text(
durationToString(numberInText.value),
fontSize = 18.sp,
color = HighOrLowlight,
)
Spacer(Modifier.weight(1f))
if (cancelEnabled) {
IconButton(
onClick = {
AudioPlayer.stop(filePath)
cancelVoice()
},
modifier = Modifier.padding(0.dp)
) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
}
}
@Preview
@Composable
fun PreviewComposeAudioView() {
SimpleXTheme {
ComposeFileView(
"test.txt",
cancelFile = {},
cancelEnabled = true
)
}
}

View File

@@ -0,0 +1,141 @@
package chat.simplex.app.views.chat
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun ContactPreferencesView(
m: ChatModel,
user: User,
contactId: Long,
) {
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
val ct = contact.value ?: return
var featuresAllowed by remember(ct) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
var currentFeaturesAllowed by remember(ct) { mutableStateOf(featuresAllowed) }
ContactPreferencesLayout(
featuresAllowed,
currentFeaturesAllowed,
user,
ct,
applyPrefs = { prefs ->
featuresAllowed = prefs
},
reset = {
featuresAllowed = currentFeaturesAllowed
},
savePrefs = {
withApi {
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
if (toContact != null) {
m.updateContact(toContact)
currentFeaturesAllowed = featuresAllowed
}
}
},
)
}
@Composable
private fun ContactPreferencesLayout(
featuresAllowed: ContactFeaturesAllowed,
currentFeaturesAllowed: ContactFeaturesAllowed,
user: User,
contact: Contact,
applyPrefs: (ContactFeaturesAllowed) -> Unit,
reset: () -> Unit,
savePrefs: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.contact_preferences))
// val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
// FeatureSection(Feature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
// applyPrefs(featuresAllowed.copy(fullDelete = it))
// }
// SectionSpacer()
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
FeatureSection(Feature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
applyPrefs(featuresAllowed.copy(voice = it))
}
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = featuresAllowed == currentFeaturesAllowed
)
}
}
@Composable
private fun FeatureSection(
feature: Feature,
userDefault: FeatureAllowed,
pref: ContactUserPreference,
allowFeature: State<ContactFeatureAllowed>,
onSelected: (ContactFeatureAllowed) -> Unit
) {
val enabled = FeatureEnabled.enabled(
user = ChatPreference(allow = allowFeature.value.allowed),
contact = pref.contactPreference
)
SectionView(
feature.text().uppercase(),
icon = feature.icon(true),
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
leadingIcon = true,
) {
SectionItemView {
ExposedDropDownSettingRow(
generalGetString(R.string.chat_preferences_you_allow),
ContactFeatureAllowed.values(userDefault).map { it to it.text },
allowFeature,
icon = null,
onSelected = onSelected
)
}
SectionDivider()
InfoRow(
generalGetString(R.string.chat_preferences_contact_allows),
pref.contactPreference.allow.text
)
}
SectionTextFooter(feature.enabledDescription(enabled))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
SectionDivider()
SectionItemView(save, disabled = disabled) {
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
}

View File

@@ -14,8 +14,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.*
@@ -53,6 +52,7 @@ fun ContextItemView(
MarkdownText(
contextItem.text, contextItem.formattedText,
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
linkMode = SimplexLinkMode.DESCRIPTION,
modifier = Modifier.fillMaxWidth(),
)
}

View File

@@ -1,7 +1,10 @@
package chat.simplex.app.views.chat
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.text.InputType
import android.view.ViewGroup
@@ -12,38 +15,226 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.ArrowUpward
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.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.doOnTextChanged
import androidx.core.widget.*
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.SharedContent
import kotlinx.coroutines.delay
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.*
import java.io.*
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
showVoiceRecordIcon: Boolean,
allowedVoiceByPrefs: Boolean,
needToAllowVoiceToContact: Boolean,
sendMessage: () -> Unit,
onMessageChange: (String) -> Unit,
onAudioAdded: (String, Int, Boolean) -> Unit,
allowVoiceToContact: () -> Unit,
showDisabledVoiceAlert: () -> Unit,
textStyle: MutableState<TextStyle>
) {
Column(Modifier.padding(vertical = 8.dp)) {
Box {
val cs = composeState.value
val attachEnabled = !composeState.value.editing
val filePath = rememberSaveable { mutableStateOf(null as String?) }
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && showVoiceRecordIcon && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
Box(if (recordingTimeRange.first == 0L)
Modifier
else
Modifier.clickable(false, onClick = {})
) {
NativeKeyboard(composeState, textStyle, onMessageChange)
}
Box(Modifier.align(Alignment.BottomEnd)) {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
} else if (!showVoiceButton) {
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
)
}
} else {
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.RECORD_AUDIO,
)
)
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
var now by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (isActive) {
now = System.currentTimeMillis()
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
delay(100)
}
}
val stopRecordingAndAddAudio: () -> Unit = {
rec.stop()
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
}
val startStopRecording: () -> Unit = {
when {
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
needToAllowVoiceToContact -> {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.allow_voice_messages_question),
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
confirmText = generalGetString(R.string.allow_verb),
dismissText = generalGetString(R.string.cancel_verb),
onConfirm = allowVoiceToContact,
)
}
!allowedVoiceByPrefs -> showDisabledVoiceAlert()
recordingInProgress.value -> stopRecordingAndAddAudio()
filePath.value == null -> {
recordingTimeRange = System.currentTimeMillis()..0L
filePath.value = rec.start(stopRecordingAndAddAudio)
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
}
}
var stopRecOnNextClick by remember { mutableStateOf(false) }
val context = LocalContext.current
DisposableEffect(stopRecOnNextClick) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
if (stopRecOnNextClick) {
// Lock orientation to current orientation because screen rotation will break the recording
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
val cleanUp = { remove: Boolean ->
rec.stop()
AudioPlayer.stop(filePath.value)
if (remove) filePath.value?.let { File(it).delete() }
filePath.value = null
stopRecOnNextClick = false
recordingTimeRange = 0L..0L
}
LaunchedEffect(cs.preview) {
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
// Pressed on X icon in preview
cleanUp(true)
}
}
val interactionSource = interactionSourceWithTapDetection(
// It's just a key for triggering dropping a state in the compose function. Without it
// nothing will react on changed params like needToAllowVoiceToContact or allowedVoiceByPrefs
needToAllowVoiceToContact.toString() + allowedVoiceByPrefs.toString(),
onPress = {
if (filePath.value == null) startStopRecording()
},
onClick = {
// Voice not allowed or not granted voice record permission for the app
if (!allowedVoiceByPrefs || !permissionsState.allPermissionsGranted) return@interactionSourceWithTapDetection
if (!recordingInProgress.value && filePath.value != null) {
sendMessage()
cleanUp(false)
} else if (stopRecOnNextClick) {
stopRecordingAndAddAudio()
stopRecOnNextClick = false
} else {
// tapped and didn't hold a finger
stopRecOnNextClick = true
}
},
onCancel = startStopRecording,
onRelease = startStopRecording
)
val sendButtonModifier = if (recordingTimeRange.last != 0L)
Modifier.clip(CircleShape).background(color)
else
Modifier
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
Icon(
when {
recordingTimeRange.last != 0L -> Icons.Outlined.ArrowUpward
stopRecOnNextClick -> Icons.Filled.Stop
allowedVoiceByPrefs -> Icons.Filled.KeyboardVoice
else -> Icons.Outlined.KeyboardVoice
},
stringResource(R.string.icon_descr_record_voice_message),
tint = when {
recordingTimeRange.last != 0L -> Color.White
stopRecOnNextClick -> MaterialTheme.colors.primary
allowedVoiceByPrefs -> MaterialTheme.colors.primary
else -> HighOrLowlight
},
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.then(sendButtonModifier)
)
}
DisposableEffect(Unit) {
onDispose {
rec.stop()
}
}
}
}
}
}
}
@Composable
private fun NativeKeyboard(
composeState: MutableState<ComposeState>,
textStyle: MutableState<TextStyle>,
onMessageChange: (String) -> Unit
) {
val cs = composeState.value
val textColor = MaterialTheme.colors.onBackground
val tintColor = MaterialTheme.colors.secondary
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
var showKeyboard by remember { mutableStateOf(false) }
LaunchedEffect(cs.contextItem) {
when (cs.contextItem) {
@@ -58,99 +249,69 @@ fun SendMsgView(
}
}
}
val textColor = MaterialTheme.colors.onBackground
val tintColor = MaterialTheme.colors.secondary
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
Column(Modifier.padding(vertical = 8.dp)) {
Box {
AndroidView(modifier = Modifier, factory = {
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
override fun setOnReceiveContentListener(
mimeTypes: Array<out String>?,
listener: android.view.OnReceiveContentListener?
) {
super.setOnReceiveContentListener(mimeTypes, listener)
}
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo)
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
return@OnCommitContentListener false
}
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
true
}
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
}
}
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
editText.maxLines = 16
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
editText.setTextColor(textColor.toArgb())
editText.textSize = textStyle.value.fontSize.value
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
DrawableCompat.setTint(drawable, tintColor.toArgb())
editText.background = drawable
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
editText.setText(cs.message)
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
editText
}) {
it.setTextColor(textColor.toArgb())
it.textSize = textStyle.value.fontSize.value
DrawableCompat.setTint(it.background, tintColor.toArgb())
if (cs.message != it.text.toString()) {
it.setText(cs.message)
// Set cursor to the end of the text
it.setSelection(it.text.length)
}
if (showKeyboard) {
it.requestFocus()
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
showKeyboard = false
}
AndroidView(modifier = Modifier, factory = {
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
override fun setOnReceiveContentListener(
mimeTypes: Array<out String>?,
listener: android.view.OnReceiveContentListener?
) {
super.setOnReceiveContentListener(mimeTypes, listener)
}
Box(Modifier.align(Alignment.BottomEnd)) {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
) {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp),
color = HighOrLowlight,
strokeWidth = 3.dp
)
} else {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cs.sendEnabled()) {
sendMessage()
}
}
)
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo)
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
return@OnCommitContentListener false
}
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
true
}
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
}
}
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
editText.maxLines = 16
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
editText.setTextColor(textColor.toArgb())
editText.textSize = textStyle.value.fontSize.value
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
DrawableCompat.setTint(drawable, tintColor.toArgb())
editText.background = drawable
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
editText.setText(cs.message)
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
editText
}) {
it.setTextColor(textColor.toArgb())
it.textSize = textStyle.value.fontSize.value
DrawableCompat.setTint(it.background, tintColor.toArgb())
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
it.isFocusableInTouchMode = it.isFocusable
if (cs.message != it.text.toString()) {
it.setText(cs.message)
// Set cursor to the end of the text
it.setSelection(it.text.length)
}
if (showKeyboard) {
it.requestFocus()
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
showKeyboard = false
}
}
if (composeState.value.preview is ComposePreview.VoicePreview) {
Text(
generalGetString(R.string.voice_message_send_text),
Modifier.padding(padding),
color = HighOrLowlight,
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
)
}
}
@@ -167,8 +328,14 @@ fun PreviewSendMsgView() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
needToAllowVoiceToContact = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -188,8 +355,14 @@ fun PreviewSendMsgViewEditing() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateEditing) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
needToAllowVoiceToContact = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -209,8 +382,14 @@ fun PreviewSendMsgViewInProgress() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
needToAllowVoiceToContact = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}

View File

@@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -35,14 +34,16 @@ import chat.simplex.app.views.usersettings.SettingsActionItem
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
val selectedContacts = remember { mutableStateListOf<Long>() }
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
var allowModifyMembers by remember { mutableStateOf(true) }
BackHandler(onBack = close)
AddGroupMembersLayout(
groupInfo = groupInfo,
contactsToAdd = getContactsToAdd(chatModel),
selectedContacts = selectedContacts,
selectedRole = selectedRole,
allowModifyMembers = allowModifyMembers,
inviteMembers = {
allowModifyMembers = false
withApi {
for (contactId in selectedContacts) {
val member = chatModel.controller.apiAddMember(groupInfo.groupId, contactId, selectedRole.value)
@@ -79,8 +80,9 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
fun AddGroupMembersLayout(
groupInfo: GroupInfo,
contactsToAdd: List<Contact>,
selectedContacts: SnapshotStateList<Long>,
selectedContacts: List<Long>,
selectedRole: MutableState<GroupMemberRole>,
allowModifyMembers: Boolean,
inviteMembers: () -> Unit,
clearSelection: () -> Unit,
addContact: (Long) -> Unit,
@@ -119,18 +121,18 @@ fun AddGroupMembersLayout(
} else {
SectionView {
SectionItemView {
RoleSelectionRow(groupInfo, selectedRole)
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
}
SectionDivider()
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty())
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
}
SectionCustomFooter {
InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection)
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
}
SectionSpacer()
SectionView {
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, addContact, removeContact)
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
}
SectionSpacer()
}
@@ -138,7 +140,7 @@ fun AddGroupMembersLayout(
}
@Composable
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>, enabled: Boolean) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -150,7 +152,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
values,
selectedRole,
icon = null,
enabled = remember { mutableStateOf(true) },
enabled = rememberUpdatedState(enabled),
onSelected = { selectedRole.value = it }
)
}
@@ -169,7 +171,7 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
}
@Composable
fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) {
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -182,11 +184,11 @@ fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit)
fontSize = 12.sp
)
Box(
Modifier.clickable { clearSelection() }
Modifier.clickable { if (enabled) clearSelection() }
) {
Text(
stringResource(R.string.clear_contacts_selection_button),
color = MaterialTheme.colors.primary,
color = if (enabled) MaterialTheme.colors.primary else HighOrLowlight,
fontSize = 12.sp
)
}
@@ -203,8 +205,9 @@ fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit)
@Composable
fun ContactList(
contacts: List<Contact>,
selectedContacts: SnapshotStateList<Long>,
selectedContacts: List<Long>,
groupInfo: GroupInfo,
enabled: Boolean,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit
) {
@@ -212,7 +215,8 @@ fun ContactList(
contacts.forEachIndexed { index, contact ->
ContactCheckRow(
contact, groupInfo, addContact, removeContact,
checked = selectedContacts.contains(contact.apiId)
checked = selectedContacts.contains(contact.apiId),
enabled = enabled,
)
if (index < contacts.lastIndex) {
SectionDivider()
@@ -227,7 +231,8 @@ fun ContactCheckRow(
groupInfo: GroupInfo,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit,
checked: Boolean
checked: Boolean,
enabled: Boolean,
) {
val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
val icon: ImageVector
@@ -237,19 +242,23 @@ fun ContactCheckRow(
iconColor = HighOrLowlight
} else if (checked) {
icon = Icons.Filled.CheckCircle
iconColor = MaterialTheme.colors.primary
iconColor = if (enabled) MaterialTheme.colors.primary else HighOrLowlight
} else {
icon = Icons.Outlined.Circle
iconColor = HighOrLowlight
}
SectionItemView(click = {
if (prohibitedToInviteIncognito) {
showProhibitedToInviteIncognitoAlertDialog()
} else if (!checked)
addContact(contact.apiId)
else
removeContact(contact.apiId)
}) {
SectionItemView(
click = if (enabled) {
{
if (prohibitedToInviteIncognito) {
showProhibitedToInviteIncognitoAlertDialog()
} else if (!checked)
addContact(contact.apiId)
else
removeContact(contact.apiId)
}
} else null
) {
ProfileImage(size = 36.dp, contact.image)
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Text(
@@ -282,6 +291,7 @@ fun PreviewAddGroupMembersLayout() {
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
selectedContacts = remember { mutableStateListOf() },
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
allowModifyMembers = true,
inviteMembers = {},
clearSelection = {},
addContact = {},

View File

@@ -4,6 +4,7 @@ import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
@@ -28,6 +29,7 @@ import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
import chat.simplex.app.views.chatlist.setGroupMembers
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
@Composable
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
@@ -62,6 +64,14 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
editGroupProfile = {
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
openPreferences = {
ModalManager.shared.showModal(true) {
GroupPreferencesView(
chatModel,
groupInfo
)
}
},
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
@@ -120,6 +130,7 @@ fun GroupChatInfoLayout(
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
openPreferences: () -> Unit,
deleteGroup: () -> Unit,
clearChat: () -> Unit,
leaveGroup: () -> Unit,
@@ -139,6 +150,16 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
SectionDivider()
}
GroupPreferencesButton(openPreferences)
}
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
SectionSpacer()
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
SectionItemView(manageGroupLink) { GroupLinkButton() }
@@ -160,10 +181,6 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
SectionDivider()
}
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
SectionDivider()
@@ -211,6 +228,15 @@ fun GroupChatInfoHeader(cInfo: ChatInfo) {
}
}
@Composable
private fun GroupPreferencesButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.group_preferences),
click = onClick
)
}
@Composable
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
Row(
@@ -286,10 +312,10 @@ fun GroupLinkButton() {
Icon(
Icons.Outlined.Link,
stringResource(R.string.group_link),
tint = MaterialTheme.colors.primary
tint = HighOrLowlight
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.group_link), color = MaterialTheme.colors.primary)
Text(stringResource(R.string.group_link))
}
}
@@ -303,10 +329,10 @@ fun EditGroupProfileButton() {
Icon(
Icons.Outlined.Edit,
stringResource(R.string.button_edit_group_profile),
tint = MaterialTheme.colors.primary
tint = HighOrLowlight
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
Text(stringResource(R.string.button_edit_group_profile))
}
}
@@ -355,7 +381,7 @@ fun PreviewGroupChatInfoLayout() {
groupInfo = GroupInfo.sampleData,
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
)
}
}

View File

@@ -160,10 +160,8 @@ fun GroupMemberInfoLayout(
}
SectionSpacer()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
if (developerTools) {
SwitchAddressButton(switchMemberAddress)
SectionDivider()
}
SwitchAddressButton(switchMemberAddress)
SectionDivider()
if (connStats != null) {
val rcvServers = connStats.rcvServers
val sndServers = connStats.sndServers

View File

@@ -0,0 +1,117 @@
package chat.simplex.app.views.chat.group
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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 GroupPreferencesView(m: ChatModel, groupInfo: GroupInfo) {
var preferences by remember { mutableStateOf(groupInfo.fullGroupPreferences) }
var currentPreferences by remember { mutableStateOf(preferences) }
GroupPreferencesLayout(
preferences,
currentPreferences,
groupInfo,
applyPrefs = { prefs ->
preferences = prefs
},
reset = {
preferences = currentPreferences
},
savePrefs = {
withApi {
val gp = groupInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
val gInfo = m.controller.apiUpdateGroup(groupInfo.groupId, gp)
if (gInfo != null) {
m.updateGroup(gInfo)
currentPreferences = preferences
}
}
},
)
}
@Composable
private fun GroupPreferencesLayout(
preferences: FullGroupPreferences,
currentPreferences: FullGroupPreferences,
groupInfo: GroupInfo,
applyPrefs: (FullGroupPreferences) -> Unit,
reset: () -> Unit,
savePrefs: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.group_preferences))
// val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
// FeatureSection(Feature.FullDelete, allowFullDeletion, groupInfo) {
// applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
// }
// SectionSpacer()
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
FeatureSection(Feature.Voice, allowVoice, groupInfo) {
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
}
if (groupInfo.canEdit) {
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = preferences == currentPreferences
)
}
}
}
@Composable
private fun FeatureSection(feature: Feature, enableFeature: State<GroupFeatureEnabled>, groupInfo: GroupInfo, onSelected: (GroupFeatureEnabled) -> Unit) {
SectionView {
if (groupInfo.canEdit) {
SectionItemView {
ExposedDropDownSettingRow(
feature.text(),
GroupFeatureEnabled.values().map { it to it.text },
enableFeature,
icon = feature.icon(false),
onSelected = onSelected
)
}
} else {
InfoRow(
feature.text(),
enableFeature.value.text
)
}
}
SectionTextFooter(feature.enableGroupPrefDescription(enableFeature.value, groupInfo.canEdit))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
SectionDivider()
SectionItemView(save, disabled = disabled) {
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
}

View File

@@ -2,11 +2,13 @@ package chat.simplex.app.views.chat.group
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -53,8 +55,8 @@ fun GroupProfileLayout(
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val displayName = remember { mutableStateOf(groupProfile.displayName) }
val fullName = remember { mutableStateOf(groupProfile.fullName) }
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val profileImage = remember { mutableStateOf(groupProfile.image) }
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val focusRequester = remember { FocusRequester() }

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.durationToString
@Composable
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
@@ -24,7 +25,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
Modifier
.padding(horizontal = 4.dp)
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green)
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
when (status) {
CICallStatus.Pending -> if (sent) {
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
@@ -38,7 +39,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
CICallStatus.Ended -> Row {
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
Text(status.duration(duration), color = HighOrLowlight)
Text(durationToString(duration), color = HighOrLowlight)
}
CICallStatus.Error -> {}
}

View File

@@ -0,0 +1,31 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
@Composable
fun CIChatFeatureView(
chatItem: ChatItem,
feature: Feature,
iconColor: Color
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(feature.icon(true), feature.text(), Modifier.size(15.dp), tint = iconColor)
Text(
chatEventText(chatItem),
Modifier,
// this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp
fontSize = 12.sp
)
}
}

View File

@@ -18,32 +18,35 @@ import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun CIEventView(ci: ChatItem) {
fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
}
Surface {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
buildAnnotatedString {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
withGroupEventStyle(this, memberDisplayName)
append(" ")
}
withGroupEventStyle(this, ci.content.text)
append(" ")
withGroupEventStyle(this, ci.timestampText)
},
chatEventText(ci),
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
)
}
}
}
private fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
}
fun chatEventText(ci: ChatItem): AnnotatedString =
buildAnnotatedString {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
withGroupEventStyle(this, memberDisplayName)
append(" ")
}
withGroupEventStyle(this, ci.content.text)
append(" ")
withGroupEventStyle(this, ci.timestampText)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,

View File

@@ -205,6 +205,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(ChatInfo.Direct.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
}
}

View File

@@ -160,7 +160,9 @@ fun CIImageView(
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
imageLoader = imageLoader
)
val view = LocalView.current
imageView(imagePainter, onClick = {
hideKeyboard(view)
if (getLoadedFilePath(context, file) != null) {
ModalManager.shared.showCustomModal(animated = false) { close ->
ImageFullScreenView(imageProvider, close)

View File

@@ -15,6 +15,7 @@ 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.WarningYellow
import kotlinx.datetime.Clock
@Composable
@@ -51,7 +52,7 @@ fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
}
is CIStatus.SndError -> {
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = WarningYellow)
}
is CIStatus.RcvNew -> {
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)

View File

@@ -0,0 +1,243 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun CIVoiceView(
providedDurationSec: Int,
file: CIFile?,
edited: Boolean,
sent: Boolean,
hasText: Boolean,
ci: ChatItem,
metaColor: Color,
longClick: () -> Unit,
) {
Row(
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (file != null) {
val context = LocalContext.current
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
val play = {
AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
brokenAudio = !audioPlaying.value
}
val pause = {
AudioPlayer.pause(audioPlaying, progress)
}
val time = if (audioPlaying.value) progress.value else duration.value
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
val text = durationToString(time / 1000)
if (hasText) {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Text(
text,
Modifier
.padding(start = 12.dp, end = 5.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
textAlign = TextAlign.Start,
maxLines = 1
)
} else {
if (sent) {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
Text(
text,
Modifier
.padding(end = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
}
} else {
Row {
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text,
Modifier
.padding(start = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
Spacer(Modifier.height(56.dp))
}
}
}
}
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
val metaReserve = if (edited)
" "
else
" "
Text(metaReserve)
}
}
}
@Composable
private fun PlayPauseButton(
audioPlaying: Boolean,
sent: Boolean,
angle: Float,
strokeWidth: Float,
strokeColor: Color,
enabled: Boolean,
error: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
) {
Surface(
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
color = if (sent) SentColorLight else ReceivedColorLight,
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
) {
Box(
Modifier
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp)
.combinedClickable(
onClick = { if (!audioPlaying) play() else pause() },
onLongClick = longClick
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = null,
Modifier.size(36.dp),
tint = if (error) WarningOrange else if (!enabled) HighOrLowlight else MaterialTheme.colors.primary
)
}
}
}
@Composable
private fun VoiceMsgIndicator(
file: CIFile?,
audioPlaying: Boolean,
sent: Boolean,
hasText: Boolean,
progress: State<Int>?,
duration: State<Int>?,
error: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
) {
val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
if (file != null && file.loaded && progress != null && duration != null) {
val angle = 360f * (progress.value.toDouble() / duration.value).toFloat()
if (hasText) {
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) {
Icon(
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = null,
Modifier.size(36.dp),
tint = MaterialTheme.colors.primary
)
}
} else {
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
}
} else {
if (file?.fileStatus == CIFileStatus.RcvInvitation
|| file?.fileStatus == CIFileStatus.RcvTransfer
|| file?.fileStatus == CIFileStatus.RcvAccepted) {
Box(
Modifier
.size(56.dp)
.clip(RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center
) {
ProgressIndicator()
}
} else {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {}, longClick)
}
}
}
private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
val brush = Brush.linearGradient(
0f to Color.Transparent,
0f to color,
start = Offset(0f, 0f),
end = Offset(strokeWidth, strokeWidth),
tileMode = TileMode.Clamp
)
onDrawWithContent {
drawContent()
drawArc(
brush = brush,
startAngle = -90f,
sweepAngle = angle,
useCenter = false,
topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
size = Size(size.width - strokeWidth, size.height - strokeWidth),
style = Stroke(width = strokeWidth, cap = StrokeCap.Square)
)
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(
Modifier.size(32.dp),
color = if (isInDarkTheme()) FileDark else FileLight,
strokeWidth = 4.dp
)
}

View File

@@ -1,6 +1,5 @@
package chat.simplex.app.views.chat.item
import android.content.*
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -29,16 +28,13 @@ import kotlinx.datetime.Clock
@Composable
fun ChatItemView(
user: User,
cInfo: ChatInfo,
cItem: ChatItem,
composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
chatModelIncognito: Boolean,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
@@ -46,6 +42,7 @@ fun ChatItemView(
scrollToItem: (Long) -> Unit,
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
@@ -56,17 +53,28 @@ fun ChatItemView(
.fillMaxWidth(),
contentAlignment = alignment,
) {
val onClick = {
when (cItem.meta.itemStatus) {
is CIStatus.SndErrorAuth -> {
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
}
is CIStatus.SndError -> {
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError.string}")
}
else -> {}
}
}
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable fun ContentItem() {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
val onLinkLongClick = { _: String -> showMenu.value = true }
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem)
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
DropdownMenu(
expanded = showMenu.value,
@@ -84,29 +92,30 @@ fun ChatItemView(
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
filePath != null -> shareFile(cxt, cItem.text, filePath)
else -> shareText(cxt, cItem.content.text)
filePath != null -> shareFile(context, cItem.text, filePath)
else -> shareText(context, cItem.content.text)
}
showMenu.value = false
})
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
copyText(cxt, cItem.content.text)
copyText(context, cItem.content.text)
showMenu.value = false
})
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
val filePath = getLoadedFilePath(context, cItem.file)
if (filePath != null) {
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> saveImage(context, cItem.file)
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
else -> {}
}
showMenu.value = false
})
}
}
if (cItem.meta.editable) {
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false
@@ -161,6 +170,11 @@ fun ChatItemView(
is CIContent.SndGroupEventContent -> CIEventView(cItem)
is CIContent.RcvConnEventContent -> CIEventView(cItem)
is CIContent.SndConnEventContent -> CIEventView(cItem)
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.feature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.feature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
}
}
}
@@ -210,20 +224,25 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM
)
}
private fun showMsgDeliveryErrorAlert(description: String) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.message_delivery_error_title),
text = description,
)
}
@Preview
@Composable
fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
@@ -238,13 +257,11 @@ fun PreviewChatItemView() {
fun PreviewChatItemViewDeletedContent() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(),
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},

View File

@@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -39,6 +39,7 @@ fun FramedItemView(
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
linkMode: SimplexLinkMode,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
onLinkLongClick: (link: String) -> Unit = {},
@@ -58,7 +59,8 @@ fun FramedItemView(
) {
MarkdownText(
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
linkMode = linkMode
)
}
}
@@ -87,13 +89,13 @@ fun FramedItemView(
modifier = Modifier.size(68.dp).clipToBounds()
)
}
is MsgContent.MCFile -> {
is MsgContent.MCFile, is MsgContent.MCVoice -> {
Box(Modifier.fillMaxWidth().weight(1f)) {
ciQuotedMsgView(qi)
}
Icon(
Icons.Filled.InsertDriveFile,
stringResource(R.string.icon_descr_file),
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.Mic,
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
Modifier
.padding(top = 6.dp, end = 4.dp)
.size(22.dp),
@@ -105,7 +107,16 @@ fun FramedItemView(
}
}
val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null
@Composable
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (text != "") {
CIMarkdownText(ci, showMember, linkMode = linkMode, uriHandler)
}
}
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVoice) && ci.content.text.isEmpty() && ci.quotedItem == null
Box(Modifier
.clip(RoundedCornerShape(18.dp))
.background(
@@ -139,26 +150,35 @@ fun FramedItemView(
if (mc.text == "") {
metaColor = Color.White
} else {
CIMarkdownText(ci, showMember, uriHandler)
CIMarkdownText(ci, showMember, linkMode, uriHandler)
}
}
is MsgContent.MCFile -> {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, mc.text != "" || ci.quotedItem != null, ci, metaColor, longClick = { onLinkLongClick("") })
if (mc.text != "") {
CIMarkdownText(ci, showMember, uriHandler)
CIMarkdownText(ci, showMember, linkMode, uriHandler)
}
}
is MsgContent.MCFile -> ciFileView(ci, mc.text)
is MsgContent.MCUnknown ->
if (ci.file == null) {
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
} else {
ciFileView(ci, mc.text)
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
}
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
else -> CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
}
}
}
}
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
}
}
}
}
@@ -168,13 +188,14 @@ fun FramedItemView(
fun CIMarkdownText(
ci: ChatItem,
showMember: Boolean,
linkMode: SimplexLinkMode,
uriHandler: UriHandler?,
onLinkLongClick: (link: String) -> Unit = {}
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
MarkdownText(
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
metaText = ci.timestampText, edited = ci.meta.itemEdited,
metaText = ci.timestampText, edited = ci.meta.itemEdited, linkMode = linkMode,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
)
}
@@ -225,6 +246,7 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -241,6 +263,7 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -261,6 +284,7 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -282,6 +306,7 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -303,6 +328,7 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -331,6 +357,7 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -359,6 +386,7 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -386,6 +414,7 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)

View File

@@ -15,6 +15,7 @@ import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.views.helpers.*
@@ -76,12 +77,14 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
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)
SideEffect {
scope.launch {
when (settledCurrentPage) {
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
index + 1 -> {
provider.scrollToStart()
pagerState.scrollToPage(0)
}
}
}
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import androidx.core.text.BidiFormatter
@@ -49,6 +50,7 @@ fun MarkdownText (
uriHandler: UriHandler? = null,
senderBold: Boolean = false,
modifier: Modifier = Modifier,
linkMode: SimplexLinkMode,
onLinkLongClick: (link: String) -> Unit = {}
) {
val textLayoutDirection = remember (text) {
@@ -79,12 +81,16 @@ fun MarkdownText (
for (ft in formattedText) {
if (ft.format == null) append(ft.text)
else {
val link = ft.link
val link = ft.link(linkMode)
if (link != null) {
hasLinks = true
val ftStyle = ft.format.style
val ftStyle = if (ft.format is Format.SimplexLink && !ft.format.trustedUri && linkMode == SimplexLinkMode.BROWSER) {
SpanStyle(color = Color.Red, textDecoration = TextDecoration.Underline)
} else {
ft.format.style
}
withAnnotation(tag = "URL", annotation = link) {
withStyle(ftStyle) { append(ft.text) }
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
}
} else {
withStyle(ft.format.style) { append(ft.text) }

View File

@@ -33,6 +33,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
}
val stopped = chatModel.chatRunning.value == false
val linkMode = chatModel.controller.appPrefs.simplexLinkMode.get()
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)
@@ -40,7 +41,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
when (chat.chatInfo) {
is ChatInfo.Direct ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
click = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -48,7 +49,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
)
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -536,13 +537,11 @@ fun ChatListNavLinkLayout(
) {
var modifier = Modifier.fillMaxWidth()
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
Surface(modifier) {
Box(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.padding(start = 8.dp)
.padding(end = 12.dp),
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
verticalAlignment = Alignment.Top
) {
chatLinkPreview()
@@ -588,7 +587,8 @@ fun PreviewChatListNavLinkDirect() {
),
false,
null,
stopped = false
stopped = false,
linkMode = SimplexLinkMode.DESCRIPTION
)
},
click = {},
@@ -625,7 +625,8 @@ fun PreviewChatListNavLinkGroup() {
),
false,
null,
stopped = false
stopped = false,
linkMode = SimplexLinkMode.DESCRIPTION
)
},
click = {},

View File

@@ -26,7 +26,7 @@ import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.*
@Composable
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) {
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean, linkMode: SimplexLinkMode) {
val cInfo = chat.chatInfo
@Composable
@@ -86,6 +86,7 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
ci.text,
ci.formattedText,
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
linkMode = linkMode,
senderBold = true,
metaText = null,
maxLines = 2,
@@ -232,6 +233,6 @@ fun ChatStatusImage(chat: Chat) {
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
ChatPreviewView(Chat.sampleData, false, "", stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
}
}

View File

@@ -126,7 +126,7 @@ fun DatabaseLayout(
chatDbChanged: Boolean,
useKeyChain: Boolean,
chatDbEncrypted: Boolean?,
initialRandomDBPassphrase: Preference<Boolean>,
initialRandomDBPassphrase: SharedPreference<Boolean>,
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
@@ -409,7 +409,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Cont
m.controller.apiStopChat()
runChat.value = false
m.chatRunning.value = false
SimplexService.stop(context)
SimplexService.safeStopService(context)
MessagesFetcherWorker.cancelAll()
} catch (e: Error) {
runChat.value = true
@@ -683,7 +683,7 @@ fun PreviewDatabaseLayout() {
chatDbChanged = false,
useKeyChain = false,
chatDbEncrypted = false,
initialRandomDBPassphrase = Preference({ true }, {}),
initialRandomDBPassphrase = SharedPreference({ true }, {}),
importArchiveLauncher = rememberGetContentLauncher {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },

View File

@@ -1,27 +1,28 @@
package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.ui.theme.DEFAULT_PADDING
class AlertManager {
var alertView = mutableStateOf<(@Composable () -> Unit)?>(null)
var presentAlert = mutableStateOf<Boolean>(false)
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
fun showAlert(alert: @Composable () -> Unit) {
Log.d(TAG, "AlertManager.showAlert")
alertView.value = alert
presentAlert.value = true
alertViews.add(alert)
}
fun hideAlert() {
presentAlert.value = false
alertView.value = null
alertViews.removeLastOrNull()
}
fun showAlertDialogButtons(
@@ -40,6 +41,26 @@ class AlertManager {
}
}
fun showAlertDialogButtonsColumn(
title: String,
text: String? = null,
buttons: @Composable () -> Unit,
) {
showAlert {
Dialog(onDismissRequest = this::hideAlert) {
Column(Modifier.background(MaterialTheme.colors.background)) {
Text(title, Modifier.padding(DEFAULT_PADDING), fontSize = 18.sp)
if (text != null) {
Text(text)
}
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
buttons()
}
}
}
}
}
fun showAlertDialog(
title: String,
text: String? = null,
@@ -101,7 +122,7 @@ class AlertManager {
@Composable
fun showInView() {
if (presentAlert.value) alertView.value?.invoke()
remember { alertViews }.lastOrNull()?.invoke()
}
companion object {

View File

@@ -2,4 +2,8 @@ package chat.simplex.app.views.helpers
import androidx.compose.animation.core.*
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)

View File

@@ -33,6 +33,6 @@ enum class NewChatSheetState {
}
sealed class UploadContent {
data class SimpleImage(val bitmap: Bitmap): UploadContent()
data class SimpleImage(val uri: Uri): UploadContent()
data class AnimatedImage(val uri: Uri): UploadContent()
}

View File

@@ -10,9 +10,12 @@ 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.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.HighOrLowlight
@Composable
@@ -27,7 +30,7 @@ fun <T> ExposedDropDownSettingRow(
onSelected: (T) -> Unit
) {
Row(
Modifier.fillMaxWidth(),
Modifier.fillMaxWidth().padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
var expanded by remember { mutableStateOf(false) }
@@ -40,9 +43,7 @@ fun <T> ExposedDropDownSettingRow(
tint = iconTint
)
}
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
Spacer(Modifier.fillMaxWidth().weight(1f))
Text(title, Modifier.weight(1f), color = if (enabled.value) Color.Unspecified else HighOrLowlight)
ExposedDropdownMenuBox(
expanded = expanded,
@@ -55,8 +56,10 @@ fun <T> ExposedDropDownSettingRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
val maxWidth = with(LocalDensity.current){ 180.sp.toDp() }
Text(
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
Modifier.widthIn(max = maxWidth),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight

View File

@@ -213,6 +213,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
return interactionSource
}
@Composable
fun interactionSourceWithTapDetection(key: Any, onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
val interactionSource = remember(key) { MutableInteractionSource() }
LaunchedEffect(interactionSource) {
var firstTapTime = 0L
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
firstTapTime = System.currentTimeMillis(); onPress()
}
is PressInteraction.Release -> if (firstTapTime + 1000L < System.currentTimeMillis()) onRelease() else onClick()
is PressInteraction.Cancel -> onCancel()
}
}
}
return interactionSource
}
suspend fun PointerInputScope.detectTransformGestures(
allowIntercept: () -> Boolean,
panZoomLock: Boolean = false,

View File

@@ -2,8 +2,7 @@ package chat.simplex.app.views.helpers
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.*
import android.net.Uri
@@ -20,8 +19,9 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
@@ -31,7 +31,12 @@ import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.json
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.chat.PickFromGallery
import chat.simplex.app.views.newchat.ActionButton
import kotlinx.serialization.builtins.*
import kotlinx.serialization.decodeFromString
import java.io.ByteArrayOutputStream
import java.io.File
import kotlin.math.min
@@ -106,15 +111,12 @@ fun base64ToBitmap(base64ImageString: String): Bitmap {
}
}
class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
private var uri: Uri? = null
private var tmpFile: File? = null
lateinit var externalContext: Context
class CustomTakePicturePreview(var uri: Uri?, var tmpFile: File?): ActivityResultContract<Void?, Uri?>() {
@CallSuper
override fun createIntent(context: Context, input: Void?): Intent {
externalContext = context
tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
// Since the class should return Uri, the file should be deleted somewhere else. And in order to be sure, delegate this to system
tmpFile?.deleteOnExit()
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
@@ -123,20 +125,28 @@ class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
override fun getSynchronousResult(
context: Context,
input: Void?
): SynchronousResult<Bitmap?>? = null
): SynchronousResult<Uri?>? = null
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return if (resultCode == Activity.RESULT_OK && uri != null) {
val source = ImageDecoder.createSource(externalContext.contentResolver, uri!!)
val bitmap = ImageDecoder.decodeBitmap(source)
tmpFile?.delete()
bitmap
uri
} else {
Log.e(TAG, "Getting image from camera cancelled or failed.")
tmpFile?.delete()
null
}
}
companion object {
fun saver(): Saver<CustomTakePicturePreview, *> = Saver(
save = { json.encodeToString(ListSerializer(String.serializer().nullable), listOf(it.uri?.toString(), it.tmpFile?.toString())) },
restore = {
val data = json.decodeFromString(ListSerializer(String.serializer().nullable), it)
val uri = if (data[0] != null) Uri.parse(data[0]) else null
val tmpFile = if (data[1] != null) File(data[1]) else null
CustomTakePicturePreview(uri, tmpFile)
}
)
}
}
//class GetGalleryContent: ActivityResultContracts.GetContent() {
// override fun createIntent(context: Context, input: String): Intent {
@@ -148,8 +158,12 @@ class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
@Composable
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher<Void?, Bitmap?> =
rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
fun rememberCameraLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<Void?, Uri?> {
val contract = rememberSaveable(stateSaver = CustomTakePicturePreview.saver()) {
mutableStateOf(CustomTakePicturePreview(null, null))
}
return rememberLauncherForActivityResult(contract = contract.value, cb)
}
@Composable
fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher<String, Boolean> =
@@ -165,22 +179,26 @@ fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivit
@Composable
fun GetImageBottomSheet(
imageBitmap: MutableState<Bitmap?>,
imageBitmap: MutableState<Uri?>,
onImageChange: (Bitmap) -> Unit,
hideBottomSheet: () -> Unit
) {
val context = LocalContext.current
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
val processPickedImage = { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
imageBitmap.value = bitmap
imageBitmap.value = uri
onImageChange(bitmap)
}
}
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
if (bitmap != null) {
imageBitmap.value = bitmap
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { processPickedImage(it) }
val galleryLauncherFallback = rememberGetContentLauncher { processPickedImage(it) }
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
if (uri != null) {
imageBitmap.value = uri
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
onImageChange(bitmap)
}
}
@@ -219,7 +237,11 @@ fun GetImageBottomSheet(
}
}
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
galleryLauncher.launch("image/*")
try {
galleryLauncher.launch(0)
} catch (e: ActivityNotFoundException) {
galleryLauncherFallback.launch("image/*")
}
hideBottomSheet()
}
}

View File

@@ -89,18 +89,6 @@ fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
)
fun laErrorToast(context: Context, errString: CharSequence) = Toast.makeText(
context,
if (errString.isNotEmpty()) String.format(generalGetString(R.string.auth_error_w_desc), errString) else generalGetString(R.string.auth_error),
Toast.LENGTH_SHORT
).show()
fun laFailedToast(context: Context) = Toast.makeText(
context,
generalGetString(R.string.auth_failed),
Toast.LENGTH_SHORT
).show()
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
generalGetString(R.string.auth_unavailable),
generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)

View File

@@ -0,0 +1,240 @@
package chat.simplex.app.views.helpers
import android.media.*
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
import android.os.Build
import android.util.Log
import androidx.compose.runtime.*
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import kotlinx.coroutines.*
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
interface Recorder {
val recordingInProgress: MutableState<Boolean>
fun start(onStop: () -> Unit): String
fun stop()
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
}
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
companion object {
// Allows to stop the recorder from outside without having the recorder in a variable
var stopRecording: (() -> Unit)? = null
}
override val recordingInProgress = mutableStateOf(false)
private var recorder: MediaRecorder? = null
private fun initRecorder() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(SimplexApp.context)
} else {
MediaRecorder()
}
override fun start(onStop: () -> Unit): String {
AudioPlayer.stop()
recordingInProgress.value = true
val rec: MediaRecorder
recorder = initRecorder().also { rec = it }
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioChannels(1)
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(16000)
rec.setMaxDuration(-1) // TODO set limit
rec.setMaxFileSize(recordedBytesLimit)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
rec.setOutputFile(filePath)
rec.prepare()
rec.start()
rec.setOnInfoListener { mr, what, extra ->
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
stop()
onStop()
}
}
stopRecording = { stop(); onStop() }
return filePath
}
override fun stop() {
if (!recordingInProgress.value) return
stopRecording = null
recordingInProgress.value = false
recorder?.metrics?.
runCatching {
recorder?.stop()
}
runCatching {
recorder?.reset()
}
runCatching {
// release all resources
recorder?.release()
}
recorder = null
}
override fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>) {
stop()
runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") }
}
}
object AudioPlayer {
private val player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
}
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
}
// Filepath: String, onProgressUpdate
// onProgressUpdate(null) means stop
private val currentlyPlaying: MutableState<Pair<String, (position: Int?) -> Unit>?> = mutableStateOf(null)
private var progressJob: Job? = null
// Returns real duration of the track
private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?) -> Unit): Int? {
if (!File(filePath).exists()) {
Log.e(TAG, "No such file: $filePath")
return null
}
RecorderNative.stopRecording?.invoke()
val current = currentlyPlaying.value
if (current == null || current.first != filePath) {
stopListener()
player.reset()
runCatching {
player.setDataSource(filePath)
}.onFailure {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
return null
}
runCatching { player.prepare() }.onFailure {
// Can happen when audio file is broken
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
return null
}
}
if (seek != null) player.seekTo(seek)
player.start()
currentlyPlaying.value = filePath to onProgressUpdate
progressJob = CoroutineScope(Dispatchers.Default).launch {
onProgressUpdate(player.currentPosition)
while(isActive && player.isPlaying) {
// Even when current position is equal to duration, the player has isPlaying == true for some time,
// so help to make the playback stopped in UI immediately
if (player.currentPosition == player.duration) {
onProgressUpdate(player.currentPosition)
break
}
delay(50)
onProgressUpdate(player.currentPosition)
}
/*
* Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases
* the player can show position != duration even if they actually equal.
* Let's say to a listener that the position == duration in case of coroutine finished without cancel
* */
if (isActive) {
onProgressUpdate(player.duration)
}
onProgressUpdate(null)
}
return player.duration
}
private fun pause(): Int {
progressJob?.cancel()
progressJob = null
player.pause()
return player.currentPosition
}
fun stop() {
if (!player.isPlaying) return
player.stop()
stopListener()
}
fun stop(item: ChatItem) = stop(item.file?.fileName)
// FileName or filePath are ok
fun stop(fileName: String?) {
if (fileName != null && currentlyPlaying.value?.first?.endsWith(fileName) == true) {
stop()
}
}
private fun stopListener() {
progressJob?.cancel()
progressJob = null
// Notify prev audio listener about stop
currentlyPlaying.value?.second?.invoke(null)
currentlyPlaying.value = null
}
fun play(
filePath: String?,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
resetOnStop: Boolean = false
) {
if (progress.value == duration.value) {
progress.value = 0
}
val realDuration = start(filePath ?: return, progress.value) { pro ->
if (pro != null) {
progress.value = pro
}
if (pro == null || pro == duration.value) {
audioPlaying.value = false
if (resetOnStop) {
progress.value = 0
} else if (pro == duration.value) {
progress.value = duration.value
}
}
}
audioPlaying.value = realDuration != null
// Update to real duration instead of what was received in ChatInfo
realDuration?.let { duration.value = it }
}
fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
pro.value = pause()
audioPlaying.value = false
}
fun duration(filePath: String): Int {
var res = 0
kotlin.runCatching {
helperPlayer.setDataSource(filePath)
helperPlayer.prepare()
helperPlayer.start()
helperPlayer.stop()
res = helperPlayer.duration
helperPlayer.reset()
}
return res
}
}

View File

@@ -40,6 +40,12 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str
keyboard?.show()
}
DisposableEffect(Unit) {
onDispose {
if (searchText.text.isNotEmpty()) onValueChange("")
}
}
val enabled = true
val colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Unspecified,

View File

@@ -10,6 +10,7 @@ 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.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.app.ui.theme.*
@@ -31,6 +32,28 @@ fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(),
}
}
@Composable
fun SectionView(
title: String,
icon: ImageVector,
iconTint: Color = HighOrLowlight,
leadingIcon: Boolean = false,
padding: PaddingValues = PaddingValues(),
content: (@Composable ColumnScope.() -> Unit)
) {
Column {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
if (leadingIcon) Icon(icon, null, Modifier.padding(end = 4.dp).size(iconSize), tint = iconTint)
Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, fontSize = 12.sp)
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = 4.dp).size(iconSize), tint = iconTint)
}
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
}
}
}
@Composable
fun <T> SectionViewSelectable(
title: String?,
@@ -56,7 +79,12 @@ fun <T> SectionViewSelectable(
}
@Composable
fun SectionItemView(click: (() -> Unit)? = null, minHeight: Dp = 46.dp, disabled: Boolean = false, content: (@Composable RowScope.() -> Unit)) {
fun SectionItemView(
click: (() -> Unit)? = null,
minHeight: Dp = 46.dp,
disabled: Boolean = false,
content: (@Composable RowScope.() -> Unit)
) {
val modifier = Modifier
.fillMaxWidth()
.sizeIn(minHeight = minHeight)

View File

@@ -12,21 +12,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.HighOrLowlight
@Composable
fun TextEditor(modifier: Modifier, text: MutableState<String>) {
fun TextEditor(
modifier: Modifier,
text: MutableState<String>,
border: Boolean = true,
fontSize: TextUnit = 14.sp,
background: Color = MaterialTheme.colors.background,
onChange: ((String) -> Unit)? = null
) {
BasicTextField(
value = text.value,
onValueChange = { text.value = it },
onValueChange = { text.value = it; onChange?.invoke(it) },
textStyle = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 14.sp,
fontFamily = FontFamily.Monospace, fontSize = fontSize,
color = MaterialTheme.colors.onBackground
),
keyboardOptions = KeyboardOptions.Default.copy(
@@ -37,17 +44,17 @@ fun TextEditor(modifier: Modifier, text: MutableState<String>) {
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
shape = if (border) RoundedCornerShape(10.dp) else RectangleShape,
border = if (border) BorderStroke(1.dp, MaterialTheme.colors.secondary) else null
) {
Row(
Modifier.background(MaterialTheme.colors.background),
Modifier.background(background),
verticalAlignment = Alignment.Top
) {
Box(
Modifier
.weight(1f)
.padding(vertical = 5.dp, horizontal = 7.dp)
.padding(vertical = 5.dp, horizontal = if (border) 7.dp else DEFAULT_PADDING)
) {
innerTextField()
}

View File

@@ -1,7 +1,5 @@
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.*
@@ -14,9 +12,12 @@ import android.text.SpannedString
import android.text.style.*
import android.util.Base64
import android.util.Log
import android.view.View
import android.view.ViewTreeObserver
import android.view.inputmethod.InputMethodManager
import androidx.annotation.StringRes
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
@@ -69,6 +70,9 @@ fun getKeyboardState(): State<KeyboardState> {
return keyboardState
}
fun hideKeyboard(view: View) =
(SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0)
// Resource to annotated string from
// https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources
fun generalGetString(id: Int): String {
@@ -215,6 +219,11 @@ 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_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
const val MAX_FILE_SIZE: Long = 8000000
fun getFilesDirectory(context: Context): String {
@@ -306,6 +315,12 @@ fun getFileSize(context: Context, uri: Uri): Long? {
}
}
fun saveImage(context: Context, uri: Uri): String? {
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
return saveImage(context, bitmap)
}
fun saveImage(context: Context, image: Bitmap): String? {
return try {
val ext = if (image.hasAlpha()) "png" else "jpg"
@@ -432,9 +447,17 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
return fileCount to bytes
}
fun durationToString(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
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)
val LongRange.Companion.saver
get() = Saver<MutableState<LongRange>, Pair<Long, Long>>(
save = { it.value.first to it.value.last },
restore = { mutableStateOf(it.first..it.second) }
)

View File

@@ -1,6 +1,7 @@
package chat.simplex.app.views.newchat
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -8,6 +9,7 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -60,8 +62,8 @@ fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> U
val scope = rememberCoroutineScope()
val displayName = remember { mutableStateOf("") }
val fullName = remember { mutableStateOf("") }
val profileImage = remember { mutableStateOf<String?>(null) }
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
val profileImage = rememberSaveable { mutableStateOf<String?>(null) }
val focusRequester = remember { FocusRequester() }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {

View File

@@ -3,6 +3,7 @@ package chat.simplex.app.views.newchat
import android.content.ClipboardManager
import android.content.res.Configuration
import android.net.Uri
import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -17,6 +18,7 @@ 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.TAG
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@@ -35,10 +37,21 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
connectViaLink = { connReqUri ->
try {
val uri = Uri.parse(connReqUri)
withUriAction(uri) { action ->
if (connectViaUri(chatModel, action, uri)) {
close()
withUriAction(uri) { linkType ->
val action = suspend {
Log.d(TAG, "connectViaUri: connecting")
if (connectViaUri(chatModel, linkType, uri)) {
close()
}
}
if (linkType == ConnectionLinkType.GROUP) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.connect_via_group_link),
text = generalGetString(R.string.you_will_join_group),
confirmText = generalGetString(R.string.connect_via_link_verb),
onConfirm = { withApi { action() } }
)
} else action()
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(

View File

@@ -3,6 +3,7 @@ package chat.simplex.app.views.newchat
import android.Manifest
import android.content.res.Configuration
import android.net.Uri
import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -14,12 +15,17 @@ 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 androidx.core.net.toUri
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.json
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
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Composable
fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
@@ -33,10 +39,21 @@ fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
QRCodeScanner { connReqUri ->
try {
val uri = Uri.parse(connReqUri)
withUriAction(uri) { action ->
if (connectViaUri(chatModel, action, uri)) {
close()
withUriAction(uri) { linkType ->
val action = suspend {
Log.d(TAG, "connectViaUri: connecting")
if (connectViaUri(chatModel, linkType, uri)) {
close()
}
}
if (linkType == ConnectionLinkType.GROUP) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.connect_via_group_link),
text = generalGetString(R.string.you_will_join_group),
confirmText = generalGetString(R.string.connect_via_link_verb),
onConfirm = { withApi { action() } }
)
} else action()
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
@@ -49,10 +66,34 @@ fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
)
}
fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
enum class ConnectionLinkType {
CONTACT, INVITATION, GROUP
}
@Serializable
sealed class CReqClientData {
@Serializable @SerialName("group") data class Group(val groupLinkId: String): CReqClientData()
}
fun withUriAction(uri: Uri, run: suspend (ConnectionLinkType) -> Unit) {
val action = uri.path?.drop(1)?.replace("/", "")
if (action == "contact" || action == "invitation") {
withApi { run(action) }
val data = uri.toString().replaceFirst("#/", "/").toUri().getQueryParameter("data")
val type = when {
data != null -> {
val parsed = runCatching {
json.decodeFromString(CReqClientData.serializer(), data)
}
when {
parsed.getOrNull() is CReqClientData.Group -> ConnectionLinkType.GROUP
else -> null
}
}
action == "contact" -> ConnectionLinkType.CONTACT
action == "invitation" -> ConnectionLinkType.INVITATION
else -> null
}
if (type != null) {
withApi { run(type) }
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.invalid_contact_link),
@@ -61,14 +102,17 @@ fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
}
}
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri): Boolean {
suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: Uri): Boolean {
val r = chatModel.controller.apiConnect(uri.toString())
if (r) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.connection_request_sent),
text =
if (action == "contact") generalGetString(R.string.you_will_be_connected_when_your_connection_request_is_accepted)
else generalGetString(R.string.you_will_be_connected_when_your_contacts_device_is_online)
when (action) {
ConnectionLinkType.CONTACT -> generalGetString(R.string.you_will_be_connected_when_your_connection_request_is_accepted)
ConnectionLinkType.INVITATION -> generalGetString(R.string.you_will_be_connected_when_your_contacts_device_is_online)
ConnectionLinkType.GROUP -> generalGetString(R.string.you_will_be_connected_when_group_host_device_is_online)
}
)
}
return r

View File

@@ -30,8 +30,8 @@ fun CallSettingsView(m: ChatModel,
@Composable
fun CallSettingsLayout(
webrtcPolicyRelay: Preference<Boolean>,
callOnLockScreen: Preference<CallOnLockScreen>,
webrtcPolicyRelay: SharedPreference<Boolean>,
callOnLockScreen: SharedPreference<CallOnLockScreen>,
editIceServers: () -> Unit,
) {
Column(
@@ -79,7 +79,7 @@ private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: Sta
@Composable
fun SharedPreferenceToggle(
text: String,
preference: Preference<Boolean>,
preference: SharedPreference<Boolean>,
preferenceState: MutableState<Boolean>? = null
) {
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
@@ -106,7 +106,7 @@ fun SharedPreferenceToggleWithIcon(
icon: ImageVector,
stopped: Boolean = false,
onClickInfo: () -> Unit,
preference: Preference<Boolean>,
preference: SharedPreference<Boolean>,
preferenceState: MutableState<Boolean>? = null
) {
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
@@ -135,7 +135,7 @@ fun SharedPreferenceToggleWithIcon(
}
@Composable
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: SharedPreference<T>, value: T) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text)
val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary)

View File

@@ -3,6 +3,9 @@ package chat.simplex.app.views.usersettings
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import chat.simplex.app.R
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
import java.security.KeyStore
import javax.crypto.*
import javax.crypto.spec.GCMParameterSpec
@@ -10,11 +13,24 @@ import javax.crypto.spec.GCMParameterSpec
@SuppressLint("ObsoleteSdkInt")
internal class Cryptor {
private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
private var warningShown = false
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String {
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? {
val secretKey = getSecretKey(alias)
if (secretKey == null) {
if (!warningShown) {
// Repeated calls will not show the alert again
warningShown = true
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.wrong_passphrase),
text = generalGetString(R.string.restore_passphrase_not_found_desc)
)
}
return null
}
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(alias), spec)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
return String(cipher.doFinal(data))
}
@@ -29,7 +45,7 @@ internal class Cryptor {
keyStore.deleteEntry(alias)
}
private fun createSecretKey(alias: String): SecretKey {
private fun createSecretKey(alias: String): SecretKey? {
if (keyStore.containsAlias(alias)) return getSecretKey(alias)
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, "AndroidKeyStore")
keyGenerator.init(
@@ -41,8 +57,8 @@ internal class Cryptor {
return keyGenerator.generateKey()
}
private fun getSecretKey(alias: String): SecretKey {
return (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey
private fun getSecretKey(alias: String): SecretKey? {
return (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey
}
companion object {

View File

@@ -32,6 +32,10 @@ fun NetworkAndServersView(
val developerTools = chatModel.controller.appPrefs.developerTools.get()
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
LaunchedEffect(Unit) {
chatModel.userSMPServersUnsaved.value = null
}
NetworkAndServersLayout(
developerTools = developerTools,
networkUseSocksProxy = networkUseSocksProxy,
@@ -112,7 +116,7 @@ fun NetworkAndServersView(
) {
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) })
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showSettingsModal { SMPServersView(it) })
SectionDivider()
SectionItemView {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)

View File

@@ -57,7 +57,7 @@ fun NotificationsSettingsView(
if (mode == NotificationsMode.SERVICE)
SimplexService.start(SimplexApp.context)
else
SimplexService.stop(SimplexApp.context)
SimplexService.safeStopService(SimplexApp.context)
}
if (mode != NotificationsMode.PERIODIC) {

View File

@@ -0,0 +1,109 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun PreferencesView(m: ChatModel, user: User) {
var preferences by remember { mutableStateOf(user.fullPreferences) }
var currentPreferences by remember { mutableStateOf(preferences) }
PreferencesLayout(
preferences,
currentPreferences,
applyPrefs = { prefs ->
preferences = prefs
},
reset = {
preferences = currentPreferences
},
savePrefs = {
withApi {
val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences())
val updatedProfile = m.controller.apiUpdateProfile(newProfile)
if (updatedProfile != null) {
val updatedUser = user.copy(
profile = updatedProfile.toLocalProfile(user.profile.profileId),
fullPreferences = preferences
)
currentPreferences = preferences
m.currentUser.value = updatedUser
}
}
},
)
}
@Composable
private fun PreferencesLayout(
preferences: FullChatPreferences,
currentPreferences: FullChatPreferences,
applyPrefs: (FullChatPreferences) -> Unit,
reset: () -> Unit,
savePrefs: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.your_preferences))
// val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) }
// FeatureSection(Feature.FullDelete, allowFullDeletion) {
// applyPrefs(preferences.copy(fullDelete = ChatPreference(allow = it)))
// }
// SectionSpacer()
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
FeatureSection(Feature.Voice, allowVoice) {
applyPrefs(preferences.copy(voice = ChatPreference(allow = it)))
}
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = preferences == currentPreferences
)
}
}
@Composable
private fun FeatureSection(feature: Feature, allowFeature: State<FeatureAllowed>, onSelected: (FeatureAllowed) -> Unit) {
SectionView {
SectionItemView {
ExposedDropDownSettingRow(
feature.text(),
FeatureAllowed.values().map { it to it.text },
allowFeature,
icon = feature.icon(false),
onSelected = onSelected
)
}
}
SectionTextFooter(feature.allowDescription(allowFeature.value))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
SectionDivider()
SectionItemView(save, disabled = disabled) {
Text(stringResource(R.string.save_and_notify_contacts), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
}

View File

@@ -1,39 +1,74 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.AppBarTitle
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.*
@Composable
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
fun PrivacySettingsView(
chatModel: ChatModel,
setPerformLA: (Boolean) -> Unit
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode
AppBarTitle(stringResource(R.string.your_privacy))
SectionView(stringResource(R.string.settings_section_title_device)) {
ChatLockItem(chatModel.performLA, setPerformLA)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.VisibilityOff, stringResource(R.string.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen)
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_chats)) {
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
SectionDivider()
if (chatModel.controller.appPrefs.developerTools.get()) {
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
SectionDivider()
}
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
SectionDivider()
SectionItemView { SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = {
simplexLinkMode.set(it)
chatModel.simplexLinkMode.value = it
}) }
}
if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) {
SectionTextFooter(stringResource(R.string.simplex_link_mode_browser_warning))
}
}
}
@Composable
private fun SimpleXLinkOptions(simplexLinkModeState: State<SimplexLinkMode>, onSelected: (SimplexLinkMode) -> Unit) {
val values = remember {
SimplexLinkMode.values().map {
when (it) {
SimplexLinkMode.DESCRIPTION -> it to generalGetString(R.string.simplex_link_mode_description)
SimplexLinkMode.FULL -> it to generalGetString(R.string.simplex_link_mode_full)
SimplexLinkMode.BROWSER -> it to generalGetString(R.string.simplex_link_mode_browser)
}
}
}
ExposedDropDownSettingRow(
generalGetString(R.string.simplex_link_mode),
values,
simplexLinkModeState,
icon = null,
enabled = remember { mutableStateOf(true) },
onSelected = onSelected
)
}

View File

@@ -0,0 +1,200 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionItemViewSpaceBetween
import SectionSpacer
import SectionView
import android.util.Log
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.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.TAG
import chat.simplex.app.model.*
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@Composable
fun SMPServerView(m: ChatModel, server: ServerCfg, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
var testing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
SMPServerLayout(
testing,
server,
testServer = {
testing = true
scope.launch {
val res = testServerConnection(server, m)
if (isActive) {
onUpdate(res.first)
testing = false
}
}
},
onUpdate,
onDelete
)
if (testing) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
@Composable
private fun SMPServerLayout(
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(if (server.preset) R.string.smp_servers_preset_server else R.string.smp_servers_your_server))
if (server.preset) {
PresetServer(testing, server, testServer, onUpdate, onDelete)
} else {
CustomServer(testing, server, testServer, onUpdate, onDelete)
}
}
}
@Composable
private fun PresetServer(
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
SectionView(stringResource(R.string.smp_servers_preset_address).uppercase()) {
SelectionContainer {
Text(
server.server,
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
color = HighOrLowlight
)
)
}
}
SectionSpacer()
UseServerSection(true, testing, server, testServer, onUpdate, onDelete)
}
@Composable
private fun CustomServer(
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
val serverAddress = remember { mutableStateOf(server.server) }
val valid = remember { derivedStateOf { parseServerAddress(serverAddress.value)?.valid == true } }
SectionView(
stringResource(R.string.smp_servers_your_server_address).uppercase(),
icon = Icons.Outlined.ErrorOutline,
iconTint = if (!valid.value) MaterialTheme.colors.error else Color.Transparent,
) {
val testedPreviously = remember { mutableMapOf<String, Boolean?>() }
TextEditor(
Modifier.height(144.dp),
text = serverAddress,
border = false,
fontSize = 16.sp,
background = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background
) {
testedPreviously[server.server] = server.tested
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
}
}
SectionSpacer()
UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete)
SectionSpacer()
if (valid.value) {
SectionView(stringResource(R.string.smp_servers_add_to_another_device).uppercase()) {
QRCode(serverAddress.value, Modifier.aspectRatio(1f))
}
}
}
@Composable
private fun UseServerSection(
valid: Boolean,
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
SectionView(stringResource(R.string.smp_servers_use_server).uppercase()) {
SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) {
Text(stringResource(R.string.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else HighOrLowlight)
ShowTestStatus(server)
}
SectionDivider()
SectionItemView {
val enabled = rememberUpdatedState(server.enabled)
PreferenceToggle(stringResource(R.string.smp_servers_use_server_for_new_conn), enabled.value) { onUpdate(server.copy(enabled = it)) }
}
SectionDivider()
SectionItemView(onDelete, disabled = testing) {
Text(stringResource(R.string.smp_servers_delete_server), color = if (testing) HighOrLowlight else MaterialTheme.colors.error)
}
}
}
@Composable
fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) =
when (server.tested) {
true -> Icon(Icons.Outlined.Check, null, modifier, tint = SimplexGreen)
false -> Icon(Icons.Outlined.Clear, null, modifier, tint = MaterialTheme.colors.error)
else -> Icon(Icons.Outlined.Check, null, modifier, tint = Color.Transparent)
}
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, SMPTestFailure?> =
try {
val r = m.controller.testSMPServer(server.server)
server.copy(tested = r == null) to r
} catch (e: Exception) {
Log.e(TAG, "testServerConnection ${e.stackTraceToString()}")
server.copy(tested = false) to null
}
fun serverHostname(srv: ServerCfg): String =
parseServerAddress(srv.server)?.hostnames?.firstOrNull() ?: srv.server

View File

@@ -1,258 +0,0 @@
package chat.simplex.app.views.usersettings
import SectionItemViewSpaceBetween
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun SMPServersView(chatModel: ChatModel) {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
fun saveSMPServers(smpServers: List<String>) {
withApi {
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
if (r) {
chatModel.userSMPServers.value = smpServers
if (smpServers.isEmpty()) {
isUserSMPServers = false
editSMPServers = true
} else {
editSMPServers = false
}
}
}
}
SMPServersLayout(
isUserSMPServers = isUserSMPServers,
editSMPServers = editSMPServers,
userSMPServersStr = userSMPServersStr,
isUserSMPServersOnOff = { switch ->
if (switch) {
isUserSMPServers = true
} else {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
if (userSMPServers.isNotEmpty()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.use_simplex_chat_servers__question),
text = generalGetString(R.string.saved_SMP_servers_will_be_removed),
confirmText = generalGetString(R.string.confirm_verb),
onConfirm = {
saveSMPServers(listOf())
isUserSMPServers = false
userSMPServersStr.value = ""
}
)
} else {
isUserSMPServers = false
userSMPServersStr.value = ""
}
}
}
},
cancelEdit = {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
isUserSMPServers = userSMPServers.isNotEmpty()
editSMPServers = !isUserSMPServers
userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
}
},
saveSMPServers = { saveSMPServers(it) },
editOn = { editSMPServers = true },
)
}
}
@Composable
fun SMPServersLayout(
isUserSMPServers: Boolean,
editSMPServers: Boolean,
userSMPServersStr: MutableState<String>,
isUserSMPServersOnOff: (Boolean) -> Unit,
cancelEdit: () -> Unit,
saveSMPServers: (List<String>) -> Unit,
editOn: () -> Unit,
) {
Column {
AppBarTitle(stringResource(R.string.your_SMP_servers))
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
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)
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(
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()
}
}
}
}
}
}
}
@Composable
private fun howToButton() {
val uriHandler = LocalUriHandler.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
) {
Text(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)
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutDefaultServers() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = false,
editSMPServers = true,
userSMPServersStr = remember { mutableStateOf("") },
isUserSMPServersOnOff = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutUserServersEditOn() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = true,
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
isUserSMPServersOnOff = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutUserServersEditOff() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = false,
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
isUserSMPServersOnOff = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}

View File

@@ -0,0 +1,311 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
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.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.launch
@Composable
fun SMPServersView(m: ChatModel) {
var servers by remember {
mutableStateOf(m.userSMPServersUnsaved.value ?: m.userSMPServers.value ?: emptyList())
}
val testing = rememberSaveable { mutableStateOf(false) }
val serversUnchanged = remember { derivedStateOf { servers == m.userSMPServers.value || testing.value } }
val saveDisabled = remember {
derivedStateOf {
servers.isEmpty() || servers == m.userSMPServers.value || testing.value || !servers.all { srv ->
val address = parseServerAddress(srv.server)
address != null && uniqueAddress(srv, address, servers)
}
}
}
fun showServer(server: ServerCfg) {
ModalManager.shared.showModalCloseable(true) { close ->
var old by remember { mutableStateOf(server) }
val index = servers.indexOf(old)
SMPServerView(
m,
old,
onUpdate = { updated ->
val newServers = ArrayList(servers)
newServers.removeAt(index)
newServers.add(index, updated)
old = updated
servers = newServers
m.userSMPServersUnsaved.value = servers
},
onDelete = {
val newServers = ArrayList(servers)
newServers.removeAt(index)
servers = newServers
m.userSMPServersUnsaved.value = servers
close()
})
}
}
val scope = rememberCoroutineScope()
SMPServersLayout(
testing.value,
servers,
serversUnchanged.value,
saveDisabled.value,
addServer = {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(R.string.smp_servers_add),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
servers = servers + ServerCfg.empty
// No saving until something will be changed on the next screen to prevent blank servers on the list
showServer(servers.last())
}) {
Text(stringResource(R.string.smp_servers_enter_manually))
}
SectionItemView({
AlertManager.shared.hideAlert()
ModalManager.shared.showModalCloseable { close ->
ScanSMPServer {
close()
servers = servers + it
m.userSMPServersUnsaved.value = servers
}
}
}
) {
Text(stringResource(R.string.smp_servers_scan_qr))
}
val hasAllPresets = hasAllPresets(servers, m)
if (!hasAllPresets) {
SectionItemView({
AlertManager.shared.hideAlert()
servers = (servers + addAllPresets(servers, m)).sortedByDescending { it.preset }
}) {
Text(stringResource(R.string.smp_servers_preset_add), color = MaterialTheme.colors.onBackground)
}
}
}
}
)
},
testServers = {
scope.launch {
testServers(testing, servers, m) {
servers = it
m.userSMPServersUnsaved.value = servers
}
}
},
resetServers = {
servers = m.userSMPServers.value ?: emptyList()
m.userSMPServersUnsaved.value = null
},
saveSMPServers = {
saveSMPServers(servers, m)
},
showServer = ::showServer,
)
if (testing.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
@Composable
private fun SMPServersLayout(
testing: Boolean,
servers: List<ServerCfg>,
serversUnchanged: Boolean,
saveDisabled: Boolean,
addServer: () -> Unit,
testServers: () -> Unit,
resetServers: () -> Unit,
saveSMPServers: () -> Unit,
showServer: (ServerCfg) -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING),
) {
AppBarTitle(stringResource(R.string.your_SMP_servers))
SectionView(stringResource(R.string.smp_servers).uppercase()) {
for (srv in servers) {
SectionItemView({ showServer(srv) }, disabled = testing) {
SmpServerView(srv, servers, testing)
}
SectionDivider()
}
SettingsActionItem(
Icons.Outlined.Add,
stringResource(R.string.smp_servers_add),
addServer,
disabled = testing,
textColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary,
iconColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary
)
}
SectionSpacer()
SectionView {
SectionItemView(resetServers, disabled = serversUnchanged) {
Text(stringResource(R.string.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else HighOrLowlight)
}
SectionDivider()
SectionItemView(testServers, disabled = testing) {
Text(stringResource(R.string.smp_servers_test_servers), color = if (!testing) MaterialTheme.colors.onBackground else HighOrLowlight)
}
SectionDivider()
SectionItemView(saveSMPServers, disabled = saveDisabled) {
Text(stringResource(R.string.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else HighOrLowlight)
}
}
SectionSpacer()
SectionView {
HowToButton()
}
}
}
@Composable
private fun SmpServerView(srv: ServerCfg, servers: List<ServerCfg>, disabled: Boolean) {
val address = parseServerAddress(srv.server)
when {
address == null || !address.valid || !uniqueAddress(srv, address, servers) -> InvalidServer()
!srv.enabled -> Icon(Icons.Outlined.DoNotDisturb, null, tint = HighOrLowlight)
else -> ShowTestStatus(srv)
}
Spacer(Modifier.padding(horizontal = 4.dp))
val text = address?.hostnames?.firstOrNull() ?: srv.server
if (srv.enabled) {
Text(text, color = if (disabled) HighOrLowlight else MaterialTheme.colors.onBackground, maxLines = 1)
} else {
Text(text, maxLines = 1, color = HighOrLowlight)
}
}
@Composable
private fun HowToButton() {
val uriHandler = LocalUriHandler.current
SettingsActionItem(
Icons.Outlined.OpenInNew,
stringResource(R.string.how_to_use_your_servers),
{ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SERVER.md") },
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary
)
}
@Composable
fun InvalidServer() {
Icon(Icons.Outlined.ErrorOutline, null, tint = MaterialTheme.colors.error)
}
private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List<ServerCfg>): Boolean = servers.all { srv ->
address.hostnames.all { host ->
srv.id == s.id || !srv.server.contains(host)
}
}
private fun hasAllPresets(servers: List<ServerCfg>, m: ChatModel): Boolean =
m.presetSMPServers.value?.all { hasPreset(it, servers) } ?: true
private fun addAllPresets(servers: List<ServerCfg>, m: ChatModel): List<ServerCfg> {
val toAdd = ArrayList<ServerCfg>()
for (srv in m.presetSMPServers.value ?: emptyList()) {
if (!hasPreset(srv, servers)) {
toAdd.add(ServerCfg(srv, preset = true, tested = null, enabled = true))
}
}
return toAdd
}
private fun hasPreset(srv: String, servers: List<ServerCfg>): Boolean =
servers.any { it.server == srv }
private suspend fun testServers(testing: MutableState<Boolean>, servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit) {
val resetStatus = resetTestStatus(servers)
onUpdated(resetStatus)
testing.value = true
val fs = runServersTest(resetStatus, m) { onUpdated(it) }
testing.value = false
if (fs.isNotEmpty()) {
val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n")
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.smp_servers_test_failed),
text = generalGetString(R.string.smp_servers_test_some_failed) + "\n" + msg
)
}
}
private fun resetTestStatus(servers: List<ServerCfg>): List<ServerCfg> {
val copy = ArrayList(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
copy.removeAt(index)
copy.add(index, server.copy(tested = null))
}
}
return copy
}
private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit): Map<String, SMPTestFailure> {
val fs: MutableMap<String, SMPTestFailure> = mutableMapOf()
val updatedServers = ArrayList<ServerCfg>(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
val (updatedServer, f) = testServerConnection(server, m)
updatedServers.removeAt(index)
updatedServers.add(index, updatedServer)
// toList() is important. Otherwise, Compose will not redraw the screen after first update
onUpdated(updatedServers.toList())
if (f != null) {
fs[serverHostname(updatedServer)] = f
}
}
}
return fs
}
private fun saveSMPServers(servers: List<ServerCfg>, m: ChatModel) {
withApi {
if (m.controller.setUserSMPServers(servers)) {
m.userSMPServers.value = servers
m.userSMPServersUnsaved.value = null
}
}
}

View File

@@ -0,0 +1,55 @@
package chat.simplex.app.views.usersettings
import android.Manifest
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.app.model.ServerCfg
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCodeScanner
import com.google.accompanist.permissions.rememberPermissionState
@Composable
fun ScanSMPServer(onNext: (ServerCfg) -> Unit) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
ScanSMPServerLayout(onNext)
}
@Composable
private fun ScanSMPServerLayout(onNext: (ServerCfg) -> Unit) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.smp_servers_scan_qr), false)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
.padding(bottom = 12.dp)
) {
QRCodeScanner { text ->
val res = parseServerAddress(text)
if (res != null) {
onNext(ServerCfg(text, false, null, true))
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.smp_servers_invalid_address),
text = generalGetString(R.string.smp_servers_check_address)
)
}
}
}
}
}

View File

@@ -80,8 +80,8 @@ fun SettingsLayout(
stopped: Boolean,
encrypted: Boolean,
incognito: MutableState<Boolean>,
incognitoPref: Preference<Boolean>,
developerTools: Preference<Boolean>,
incognitoPref: SharedPreference<Boolean>,
developerTools: SharedPreference<Boolean>,
userDisplayName: String,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
@@ -116,20 +116,22 @@ fun SettingsLayout(
SectionDivider()
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
SectionDivider()
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
ChatPreferencesItem(showSettingsModal)
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_settings)) {
SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
SectionDivider()
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() }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionSpacer()
@@ -146,13 +148,25 @@ fun SettingsLayout(
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_support)) {
ContributeItem(uriHandler)
SectionDivider()
RateAppItem(uriHandler)
SectionDivider()
StarOnGithubItem(uriHandler)
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_develop)) {
ChatConsoleItem(showTerminal)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools)
SectionDivider()
InstallTerminalAppItem(uriHandler)
val devTools = remember { mutableStateOf(developerTools.get()) }
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools, devTools)
SectionDivider()
if (devTools.value) {
ChatConsoleItem(showTerminal)
SectionDivider()
InstallTerminalAppItem(uriHandler)
SectionDivider()
}
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
// SectionDivider()
AppVersionItem()
@@ -163,7 +177,7 @@ fun SettingsLayout(
@Composable
fun SettingsIncognitoActionItem(
incognitoPref: Preference<Boolean>,
incognitoPref: SharedPreference<Boolean>,
incognito: MutableState<Boolean>,
stopped: Boolean,
onClickInfo: () -> Unit,
@@ -225,6 +239,20 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable fun ChatPreferencesItem(showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.chat_preferences),
click = {
withApi {
showSettingsModal {
PreferencesView(it, it.currentUser.value ?: return@showSettingsModal)
}()
}
}
)
}
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -252,6 +280,46 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable private fun ContributeItem(uriHandler: UriHandler) {
SectionItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat#contribute") }) {
Icon(
Icons.Outlined.Keyboard,
contentDescription = "GitHub",
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(generalGetString(R.string.contribute), color = MaterialTheme.colors.primary)
}
}
@Composable private fun RateAppItem(uriHandler: UriHandler) {
SectionItemView({
runCatching { uriHandler.openUri("market://details?id=chat.simplex.app") }
.onFailure { uriHandler.openUri("https://play.google.com/store/apps/details?id=chat.simplex.app") }
}
) {
Icon(
Icons.Outlined.StarOutline,
contentDescription = "Google Play",
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(generalGetString(R.string.rate_the_app), color = MaterialTheme.colors.primary)
}
}
@Composable private fun StarOnGithubItem(uriHandler: UriHandler) {
SectionItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
Icon(
painter = painterResource(id = R.drawable.ic_github),
contentDescription = "GitHub",
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(generalGetString(R.string.star_on_github), color = MaterialTheme.colors.primary)
}
}
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) {
SectionItemView(showTerminal) {
Icon(
@@ -313,8 +381,8 @@ fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = n
}
@Composable
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
SectionItemView() {
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: SharedPreference<Boolean>, prefState: MutableState<Boolean>? = null) {
SectionItemView {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
@@ -330,7 +398,7 @@ fun SettingsPreferenceItemWithInfo(
text: String,
stopped: Boolean,
onClickInfo: () -> Unit,
pref: Preference<Boolean>,
pref: SharedPreference<Boolean>,
prefState: MutableState<Boolean>? = null
) {
SectionItemView(onClickInfo) {
@@ -343,20 +411,42 @@ fun SettingsPreferenceItemWithInfo(
}
@Composable
fun PreferenceToggleWithIcon(
fun PreferenceToggle(
text: String,
icon: ImageVector,
iconColor: Color = HighOrLowlight,
checked: Boolean,
onChange: (Boolean) -> Unit = {},
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
icon,
null,
tint = iconColor
Text(text)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = checked,
onCheckedChange = onChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
)
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
}
@Composable
fun PreferenceToggleWithIcon(
text: String,
icon: ImageVector? = null,
iconColor: Color? = HighOrLowlight,
checked: Boolean,
onChange: (Boolean) -> Unit = {},
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Icon(
icon,
null,
tint = iconColor ?: HighOrLowlight
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
Text(text)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
@@ -386,8 +476,8 @@ fun PreviewSettingsLayout() {
stopped = false,
encrypted = false,
incognito = remember { mutableStateOf(false) },
incognitoPref = Preference({ false }, {}),
developerTools = Preference({ false }, {}),
incognitoPref = SharedPreference({ false }, {}),
developerTools = SharedPreference({ false }, {}),
userDisplayName = "Alice",
setPerformLA = {},
showModal = { {} },

View File

@@ -2,6 +2,7 @@ package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@@ -12,6 +13,7 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
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,7 +36,7 @@ import kotlinx.coroutines.launch
fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
val user = chatModel.currentUser.value
if (user != null) {
val editProfile = remember { mutableStateOf(false) }
val editProfile = rememberSaveable { mutableStateOf(false) }
var profile by remember { mutableStateOf(user.profile.toProfile()) }
UserProfileLayout(
editProfile = editProfile,
@@ -67,8 +69,8 @@ fun UserProfileLayout(
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val displayName = remember { mutableStateOf(profile.displayName) }
val fullName = remember { mutableStateOf(profile.fullName) }
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val profileImage = remember { mutableStateOf(profile.image) }
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
val profileImage = rememberSaveable { mutableStateOf(profile.image) }
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()

View File

@@ -6,7 +6,9 @@
<!-- Connect via Link - MainActivity.kt -->
<string name="connect_via_contact_link">Über den Kontakt-Link verbinden?</string>
<string name="connect_via_invitation_link">Über den Einladungs-Link verbinden?</string>
<string name="connect_via_group_link">Über den Gruppen-Link verbinden?</string>
<string name="profile_will_be_sent_to_contact_sending_link">Ihr Profil wird an den Kontakt gesendet von dem Sie diesen Link erhalten haben.</string>
<string name="you_will_join_group">Sie werden der Gruppe beitreten, auf die sich dieser Link bezieht und sich mit deren Gruppenmitgliedern verbinden.</string>
<string name="connect_via_link_verb">Verbinden</string>
<!-- Server info - ChatModel.kt -->
@@ -39,6 +41,17 @@
<string name="description_via_one_time_link">über einen Einmal-Link</string>
<string name="description_via_one_time_link_incognito">Inkognito über einen Einmal-Link</string>
<!-- FormattedText, SimpleX links - ChatModel.kt -->
<string name="simplex_link_contact">SimpleX Kontaktadressen-Link</string>
<string name="simplex_link_invitation">SimpleX Einmal-Link</string>
<string name="simplex_link_group">SimpleX Gruppen-Link</string>
<string name="simplex_link_connection">über <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
<string name="simplex_link_mode">SimpleX-Links</string>
<string name="simplex_link_mode_description">Beschreibung</string>
<string name="simplex_link_mode_full">Vollständiger Link</string>
<string name="simplex_link_mode_browser">Über den Browser</string>
<string name="simplex_link_mode_browser_warning">Das Öffnen des Links über den Browser kann die Privatsphäre und Sicherheit der Verbindung reduzieren. SimpleX-Links, denen nicht vertraut wird, werden Rot sein.</string>
<!-- SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Fehler beim Speichern der SMP-Server</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Stellen Sie sicher, dass die SMP-Server Adressen das richtige Format haben, zeilenweise angeordnet und nicht kopiert sind.</string>
@@ -56,7 +69,7 @@
<string name="error_receiving_file">Fehler beim Empfangen der Datei</string>
<string name="error_creating_address">Fehler beim Erstellen der Adresse</string>
<string name="contact_already_exists">Kontakt ist bereits vorhanden</string>
<string name="you_are_already_connected_to_vName_via_this_link">Sie sind bereits über diesen Link mit <xliff:g id="contactName" example="Alice">%1$s!</xliff:g> verbunden.</string>
<string name="you_are_already_connected_to_vName_via_this_link">Sie sind bereits mit <xliff:g id="contactName" example="Alice">%1$s!</xliff:g> verbunden.</string>
<string name="invalid_connection_link">Ungültiger Verbindungslink</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Überprüfen Sie bitte, ob Sie den richtigen Link genutzt haben oder bitten Sie Ihren Kontakt darum, Ihnen nochmal einen Link zuzusenden.</string>
<string name="connection_error_auth">Verbindungsfehler (AUTH)</string>
@@ -68,6 +81,14 @@
<string name="error_deleting_contact_request">Fehler beim Löschen der Kontaktanfrage</string>
<string name="error_deleting_pending_contact_connection">Fehler beim Löschen der anstehenden Kontaktaufnahme</string>
<string name="error_changing_address">Fehler beim Wechseln der Adresse</string>
<string name="error_smp_test_failed_at_step">Der Test ist beim Schritt %s fehlgeschlagen.</string>
<string name="error_smp_test_server_auth">Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort.</string>
<string name="error_smp_test_certificate">Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig.</string>
<string name="smp_server_test_connect">Verbinde</string>
<string name="smp_server_test_create_queue">Erzeuge Warteschlange</string>
<string name="smp_server_test_secure_queue">Sichere Warteschlange</string>
<string name="smp_server_test_delete_queue">Lösche Warteschlange</string>
<string name="smp_server_test_disconnect">Trenne Verbindung</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Sofortige Benachrichtigungen</string>
@@ -129,16 +150,16 @@
<string name="auth_enable_simplex_lock">SimpleX Sperre aktivieren</string>
<string name="auth_disable_simplex_lock">SimpleX Sperre deaktivieren</string>
<string name="auth_confirm_credential">Bestätigen Sie Ihre Zugangsdaten</string>
<string name="auth_error">Authentifizierungsfehler</string>
<string name="auth_error_w_desc">Authentifizierungsfehler: <xliff:g id="desc">%1$s</xliff:g></string>
<string name="auth_failed">Authentifizierung fehlgeschlagen</string>
<string name="auth_unavailable">Authentifizierung nicht verfügbar</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Geräteauthentifizierung ist nicht aktiviert. Sie können die SimpleX Sperre über die Einstellungen aktivieren, sobald Sie die Geräteauthentifizierung aktiviert haben.</string>
<string name="auth_device_authentication_is_disabled_turning_off">Geräteauthentifizierung ist deaktiviert. SimpleX Sperre ist abgeschaltet.</string>
<string name="auth_retry">Wiederholen</string>
<string name="auth_stop_chat">Chat beenden</string>
<string name="auth_open_chat_console">Chat-Konsole öffnen</string>
<!-- Chat Alerts - ChatItemView.kt -->
<string name="message_delivery_error_title">Fehler bei der Nachrichtenzustellung</string>
<string name="message_delivery_error_desc">Dieser Kontakt hat sehr wahrscheinlich die Verbindung mit Ihnen gelöscht.</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Antwort</string>
<string name="share_verb">Teilen</string>
@@ -146,6 +167,7 @@
<string name="save_verb">Speichern</string>
<string name="edit_verb">Bearbeiten</string>
<string name="delete_verb">Löschen</string>
<string name="allow_verb">Erlauben</string>
<string name="delete_message__question">Die Nachricht löschen?</string>
<string name="delete_message_cannot_be_undone_warning">Nachricht wird gelöscht - dies kann nicht rückgängig gemacht werden!</string>
<string name="for_me_only">Nur für mich</string>
@@ -183,6 +205,8 @@
<string name="icon_descr_cancel_file_preview">Dateivorschau abbrechen</string>
<string name="images_limit_title">Zu viele Bilder!</string>
<string name="images_limit_desc">Es können nur 10 Bilder auf einmal gesendet werden</string>
<string name="image_decoding_exception_title">Dekodierungsfehler</string>
<string name="image_decoding_exception_desc">Das Bild kann nicht dekodiert werden. Bitte versuchen Sie es mit einem anderen Bild oder wenden Sie sich an die Entwickler.</string>
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Bild</string>
@@ -204,6 +228,10 @@
<string name="file_not_found">Datei nicht gefunden</string>
<string name="error_saving_file">Fehler beim Speichern der Datei</string>
<!-- Voice messages -->
<string name="voice_message">Sprachnachricht</string>
<string name="voice_message_send_text">Sprachnachricht…</string>
<!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Benachrichtigungen</string>
@@ -217,15 +245,22 @@
<string name="icon_descr_server_status_error">Fehler</string>
<string name="icon_descr_server_status_pending">Ausstehend</string>
<string name="switch_receiving_address_question">Empfängeradresse wechseln?</string>
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Address-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Nachricht senden</string>
<string name="icon_descr_record_voice_message">Nehme Sprachnachricht auf</string>
<string name="allow_voice_messages_question">Sprachnachrichten erlauben?</string>
<string name="you_need_to_allow_to_send_voice">Sie müssen Ihrem Kontakt das Senden von Sprachnachrichten erlauben, damit Sie sie senden können.</string>
<string name="voice_messages_prohibited">Sprachnachrichten unzulässig!</string>
<string name="ask_your_contact_to_enable_voice">Bitten Sie Ihren Kontakt darum, das Senden von Sprachnachrichten zu aktivieren.</string>
<string name="only_group_owners_can_enable_voice">Sprachnachrichten können nur von Gruppen-Eigentümern aktiviert werden.</string>
<!-- General Actions / Responses -->
<string name="back">Zurück</string>
<string name="cancel_verb">Abbrechen</string>
<string name="confirm_verb">Bestätigen</string>
<string name="reset_verb">Zurücksetzen</string>
<string name="ok">OK</string>
<string name="no_details">Keine Details</string>
<string name="add_contact">Kontakt hinzufügen</string>
@@ -354,19 +389,39 @@
<string name="how_to_use_simplex_chat">Wie man SimpleX nutzt</string>
<string name="markdown_help">Markdown Hilfe</string>
<string name="markdown_in_messages">Markdown in Nachrichten</string>
<string name="chat_with_the_founder">Verbinden Sie sich mit den Entwicklern</string>
<string name="chat_with_the_founder">Senden Sie Fragen und Ideen</string>
<string name="send_us_an_email">Senden Sie uns eine E-Mail</string>
<string name="chat_lock">SimpleX Sperre</string>
<string name="chat_console">Chat Konsole</string>
<string name="smp_servers">SMP-Server</string>
<string name="smp_servers_preset_address">Voreingestellte Serveradresse</string>
<string name="smp_servers_preset_add">Füge voreingestellte Server hinzu</string>
<string name="smp_servers_add">Füge Server hinzu…</string>
<string name="smp_servers_test_server">Teste Server</string>
<string name="smp_servers_test_servers">Teste alle Server</string>
<string name="smp_servers_save">Sichere alle Server</string>
<string name="smp_servers_test_failed">Server Test ist fehlgeschlagen!</string>
<string name="smp_servers_test_some_failed">Einige Server haben den Test nicht bestanden:</string>
<string name="smp_servers_scan_qr">Scannen Sie den QR-Code des Servers</string>
<string name="smp_servers_enter_manually">Geben Sie den Server manuell ein</string>
<string name="smp_servers_preset_server">Voreingestellter Server</string>
<string name="smp_servers_your_server">Ihr Server</string>
<string name="smp_servers_your_server_address">Ihre Serveradresse</string>
<string name="smp_servers_use_server">Server nutzen</string>
<string name="smp_servers_use_server_for_new_conn">Für neue Verbindungen nutzen</string>
<string name="smp_servers_add_to_another_device">Einem anderen Gerät hinzufügen</string>
<string name="smp_servers_invalid_address">Ungültige Serveradresse!</string>
<string name="smp_servers_check_address">Überprüfen Sie die Serveradresse und versuchen Sie es nochmal.</string>
<string name="smp_servers_delete_server">Server löschen</string>
<string name="install_simplex_chat_for_terminal">Installieren Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> als Terminalanwendung</string>
<string name="star_on_github">Stern auf GitHub vergeben</string>
<string name="contribute">Unterstützen Sie uns</string>
<string name="rate_the_app">Bewerten Sie die App</string>
<string name="use_simplex_chat_servers__question">Verwenden Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Server?</string>
<string name="saved_SMP_servers_will_be_removed">Gespeicherte SMP-Server werden entfernt.</string>
<string name="your_SMP_servers">Ihre SMP-Server</string>
<string name="configure_SMP_servers">SMP-Server konfigurieren</string>
<string name="using_simplex_chat_servers">Verwendung von <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Servern.</string>
<string name="enter_one_SMP_server_per_line">SMP-Server (einer pro Zeile)</string>
<string name="how_to">Anleitung</string>
<string name="how_to_use_your_servers">Wie Sie Ihre Server nutzen</string>
<string name="saved_ICE_servers_will_be_removed">Gespeicherte WebRTC ICE-Server werden entfernt.</string>
<string name="your_ICE_servers">Ihre ICE-Server</string>
<string name="configure_ICE_servers">Konfigurieren Sie ICE-Server</string>
@@ -416,7 +471,9 @@
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt.\n\n<xliff:g id="appName">SimpleX</xliff:g>-Server können Ihr Profil nicht sehen.</string>
<string name="edit_image">Bild bearbeiten</string>
<string name="delete_image">Bild löschen</string>
<string name="save_and_notify_contact">Speichern (und Kontakt benachrichtigen)</string>
<string name="save_and_notify_contacts">Speichern (und Kontakte benachrichtigen)</string>
<string name="save_and_notify_group_members">Speichern (und Gruppenmitglieder benachrichtigen)</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">Sie haben volle Kontrolle über Ihren Chat!</string>
@@ -475,12 +532,12 @@
<!-- How SimpleX Works -->
<string name="how_simplex_works">Wie <xliff:g id="appName">SimpleX</xliff:g> funktioniert</string>
<string name="many_people_asked_how_can_it_deliver">Viele Menschen haben gefragt: <i>Wenn <xliff:g id="appName">SimpleX</xliff:g> keine Benutzerkennungen hat, wie kann es dann Nachrichten zustellen?</i></string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Zum Schutz Ihrer Privatsphäre verwendet <xliff:g id="appName">SimpleX</xliff:g> an Stelle von Benutzer-IDs, die von allen anderen Plattformen verwendet werden, Kennungen für Nachrichtenwarteschlangen, die für jeden Ihrer Kontakte individuell sind.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Sie legen fest, über welche Server Sie Ihre Nachrichten <b>empfangen</b> und an Ihre Kontakte <b>senden</b>.</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine <b>2-Schichten Ende-zu-Ende-Verschlüsselung</b> gesendet werden.</string>
<string name="read_more_in_github">Erfahren Sie mehr dazu in unserem GitHub-Repository.</string>
<string name="read_more_in_github_with_link">Erfahren Sie mehr dazu in unserem <font color="#0088ff">GitHub-Repository</font>.</string>
<string name="many_people_asked_how_can_it_deliver">Viele Menschen haben gefragt: <i>Wie kann <xliff:g id="appName">SimpleX</xliff:g> Nachrichten zustellen, wenn es keine Benutzerkennungen gibt?</i></string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Zum Schutz Ihrer Privatsphäre verwendet <xliff:g id="appName">SimpleX</xliff:g> an Stelle von Benutzerkennungen, die von allen anderen Plattformen verwendet werden, Kennungen für Nachrichtenwarteschlangen, die für jeden Ihrer Kontakte individuell sind.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Sie können selbst festlegen, über welche Server Sie Ihre Nachrichten <b>empfangen</b> und an Ihre Kontakte <b>senden</b> wollen.</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Nur die Endgeräte speichern die Benutzerprofile, Kontakte, Gruppen und Nachrichten, welche über eine <b>2-Schichten Ende-zu-Ende-Verschlüsselung</b> gesendet werden.</string>
<string name="read_more_in_github">Erfahren Sie in unserem GitHub-Repository mehr dazu.</string>
<string name="read_more_in_github_with_link">Erfahren Sie in unserem <font color="#0088ff">GitHub-Repository</font> mehr dazu.</string>
<!-- MakeConnection -->
<string name="paste_the_link_you_received">Fügen Sie den erhaltenen Link ein</string>
@@ -552,14 +609,16 @@
<!-- Privacy settings -->
<string name="privacy_and_security">Datenschutz &amp; Sicherheit</string>
<string name="your_privacy">Meine Privatsphäre</string>
<string name="protect_app_screen">App-Bildschirm schützen</string>
<string name="auto_accept_images">Bilder automatisch akzeptieren</string>
<string name="transfer_images_faster">Bilder schneller übertragen (BETA)</string>
<string name="transfer_images_faster">Bilder schneller übertragen</string>
<string name="send_link_previews">Link-Vorschau senden</string>
<!-- Settings sections -->
<string name="settings_section_title_you">MEINE DATEN</string>
<string name="settings_section_title_settings">EINSTELLUNGEN</string>
<string name="settings_section_title_help">HILFE</string>
<string name="settings_section_title_support">UNTERSTÜTZUNG VON SIMPLEX CHAT</string>
<string name="settings_section_title_develop">ENTWICKLUNG</string>
<string name="settings_section_title_device">GERÄT</string>
<string name="settings_section_title_chats">CHATS</string>
@@ -674,6 +733,7 @@
<string name="restore_database_alert_desc">Bitte geben Sie das vorherige Passwort ein, nachdem Sie die Datenbanksicherung wiederhergestellt haben. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="restore_database_alert_confirm">Wiederherstellen</string>
<string name="database_restore_error">Fehler bei der Wiederherstellung der Datenbank</string>
<string name="restore_passphrase_not_found_desc">Das Passwort wurde nicht im Schlüsselbund gefunden. Bitte geben Sie es manuell ein. Das kann passieren, wenn Sie die App-Daten mit einem Backup-Programm wieder hergestellt haben. Bitte nehmen Sie Kontakt mit den Entwicklern auf, wenn das nicht der Fall ist.</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Chat wurde beendet</string>
@@ -791,6 +851,7 @@
<string name="all_group_members_will_remain_connected">Alle Gruppenmitglieder bleiben verbunden.</string>
<string name="error_creating_link_for_group">Fehler beim Erzeugen des Gruppen-Links</string>
<string name="error_deleting_link_for_group">Fehler beim Löschen des Gruppen-Links</string>
<string name="only_group_owners_can_change_prefs">Gruppenpräferenzen können nur von Gruppen-Eigentümern geändert werden.</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">FÜR KONSOLE</string>
@@ -822,7 +883,7 @@
<string name="receiving_via">Empfangen über</string>
<string name="sending_via">Senden über</string>
<string name="network_status">Netzwerkstatus</string>
<string name="switch_receiving_address">Wechseln der Empfängeradresse (BETA)</string>
<string name="switch_receiving_address">Wechseln der Empfängeradresse</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Geheime Gruppe erstellen</string>
@@ -872,4 +933,48 @@
<string name="save_color">Farbe speichern</string>
<string name="reset_color">Farben zurücksetzen</string>
<string name="color_primary">Akzent</string>
<!-- Preferences.kt -->
<string name="chat_preferences_you_allow">Sie erlauben</string>
<string name="chat_preferences_contact_allows">Der Kontakt erlaubt</string>
<string name="chat_preferences_default">Voreinstellung (%s)</string>
<string name="chat_preferences_yes">Ja</string>
<string name="chat_preferences_no">Nein</string>
<string name="chat_preferences_always">Immer</string>
<string name="chat_preferences_on">Ein</string>
<string name="chat_preferences_off">Aus</string>
<string name="chat_preferences">Chat Präferenzen</string>
<string name="contact_preferences">Kontakt Präferenzen</string>
<string name="group_preferences">Gruppen Präferenzen</string>
<string name="your_preferences">Ihre Präferenzen</string>
<string name="full_deletion">Vollständige Löschung</string>
<string name="voice_messages">Sprachnachrichten</string>
<string name="feature_enabled">aktiviert</string>
<string name="feature_enabled_for_you">Für Sie aktiviert</string>
<string name="feature_enabled_for_contact">FÜr Kontakt aktiviert</string>
<string name="feature_off">Aus</string>
<string name="feature_received_prohibited">empfangen, nicht erlaubt</string>
<string name="allow_your_contacts_irreversibly_delete">Erlauben Sie Ihren Kontakten gesendete Nachrichten unwiederbringlich zu löschen.</string>
<string name="allow_irreversible_message_deletion_only_if">Erlauben Sie das unwiederbringliche Löschen von Nachrichten nur dann, wenn es Ihnen Ihr Kontakt ebenfalls erlaubt.</string>
<string name="contacts_can_mark_messages_for_deletion">Ihre Kontakte können Nachrichten zum Löschen markieren. Sie können diese Nachrichten trotzdem anschauen.</string>
<string name="allow_your_contacts_to_send_voice_messages">Erlauben Sie Ihre Kontakten Sprachnachrichten zu senden.</string>
<string name="allow_voice_messages_only_if">Erlauben Sie Sprachnachrichten nur dann, wenn Ihr Kontakt diese ebenfalls erlaubt.</string>
<string name="prohibit_sending_voice_messages">Das Senden von Sprachnachrichten verbieten.</string>
<string name="both_you_and_your_contacts_can_delete">Sowohl Ihr Kontakt, als auch Sie können Nachrichten unwiederbringlich löschen.</string>
<string name="only_you_can_delete_messages">Nur Sie können Nachrichten unwiederbringlich löschen (Ihr Kontakt kann sie zum Löschen markieren).</string>
<string name="only_your_contact_can_delete">Nur Ihr Kontakt kann Nachrichten unwiederbringlich löschen (Sie können sie zum Löschen markieren).</string>
<string name="message_deletion_prohibited">In diesem Chat ist das unwiederbringliche Löschen von Nachrichten untersagt.</string>
<string name="both_you_and_your_contact_can_send_voice">Sowohl Ihr Kontakt, als auch Sie können Sprachnachrichten senden.</string>
<string name="only_you_can_send_voice">Nur Sie können Sprachnachrichten senden.</string>
<string name="only_your_contact_can_send_voice">Nur Ihr Kontakt kann Sprachnachrichten senden.</string>
<string name="voice_prohibited_in_this_chat">In diesem Chat sind Sprachnachrichten untersagt.</string>
<string name="allow_to_delete_messages">Unwiederbringliches Löschen von gesendeten Nachrichten erlauben.</string>
<string name="prohibit_message_deletion">Unwiederbringliches Löschen von Nachrichten verbieten.</string>
<string name="allow_to_send_voice">Senden von Sprachnachrichten erlauben.</string>
<string name="prohibit_sending_voice">Senden von Sprachnachrichten untersagen.</string>
<string name="group_members_can_delete">Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen.</string>
<string name="message_deletion_prohibited_in_chat">In diesem Chat ist das unwiederbringliche Löschen von Nachrichten verboten.</string>
<string name="group_members_can_send_voice">Gruppenmitglieder können Sprachnachrichten senden.</string>
<string name="voice_messages_are_prohibited">In diesem Chat sind Sprachnachrichten untersagt.</string>
</resources>

View File

@@ -6,7 +6,9 @@
<!-- Connect via Link - MainActivity.kt -->
<string name="connect_via_contact_link">Соединиться через ссылку-контакт?</string>
<string name="connect_via_invitation_link">Соединиться через ссылку-приглашение?</string>
<string name="connect_via_group_link">Соединиться через ссылку группы?</string>
<string name="profile_will_be_sent_to_contact_sending_link">Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку.</string>
<string name="you_will_join_group">Вы вступите в группу, на которую ссылается эта ссылка.</string>
<string name="connect_via_link_verb">Соединиться</string>
<!-- Server info - ChatModel.kt -->
@@ -39,6 +41,17 @@
<string name="description_via_one_time_link">через одноразовую ссылку</string>
<string name="description_via_one_time_link_incognito">инкогнито через одноразовую ссылку</string>
<!-- FormattedText, SimpleX links - ChatModel.kt -->
<string name="simplex_link_contact">SimpleX ссылка-контакт</string>
<string name="simplex_link_invitation">SimpleX одноразовая ссылка</string>
<string name="simplex_link_group">SimpleX ссылка группы</string>
<string name="simplex_link_connection">через <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
<string name="simplex_link_mode">SimpleX ссылки</string>
<string name="simplex_link_mode_description">Описание</string>
<string name="simplex_link_mode_full">Полная ссылка</string>
<string name="simplex_link_mode_browser">В браузере</string>
<string name="simplex_link_mode_browser_warning">Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными.</string>
<!-- SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Ошибка при сохранении SMP серверов</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется.</string>
@@ -56,7 +69,7 @@
<string name="error_receiving_file">Ошибка при получении файла</string>
<string name="error_creating_address">Ошибка при создании адреса</string>
<string name="contact_already_exists">Существующий контакт</string>
<string name="you_are_already_connected_to_vName_via_this_link">Вы уже соединены с <xliff:g id="contactName" example="Alice">%1$s!</xliff:g> через эту ссылку.</string>
<string name="you_are_already_connected_to_vName_via_this_link">Вы уже соединены с контактом <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
<string name="invalid_connection_link">Ошибка в ссылке контакта</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Пожалуйста, проверьте, что вы использовали правильную ссылку, или попросите ваш контакт отправить вам новую.</string>
<string name="connection_error_auth">Ошибка соединения (AUTH)</string>
@@ -68,6 +81,14 @@
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
<string name="error_deleting_pending_contact_connection">Ошибка удаления ожидаемого соединения</string>
<string name="error_changing_address">Ошибка при изменении адреса</string>
<string name="error_smp_test_failed_at_step">Ошибка теста на шаге %s.</string>
<string name="error_smp_test_server_auth">Сервер требует авторизации для создания очередей, проверьте пароль</string>
<string name="error_smp_test_certificate">Возможно, хэш сертификата в адресе сервера неверный</string>
<string name="smp_server_test_connect">Соединение</string>
<string name="smp_server_test_create_queue">Создание очереди</string>
<string name="smp_server_test_secure_queue">Защита очереди</string>
<string name="smp_server_test_delete_queue">Удаление очереди</string>
<string name="smp_server_test_disconnect">Разрыв соединения</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
@@ -129,16 +150,16 @@
<string name="auth_enable_simplex_lock">Включить блокировку SimpleX</string>
<string name="auth_disable_simplex_lock">Отключить блокировку SimpleX</string>
<string name="auth_confirm_credential">Пройдите аутентификацию</string>
<string name="auth_error">Ошибка аутентификации</string>
<string name="auth_error_w_desc">Ошибка аутентификации: <xliff:g id="desc">%1$s</xliff:g></string>
<string name="auth_failed">Ошибка аутентификации</string>
<string name="auth_unavailable">Аутентификация недоступна</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации.</string>
<string name="auth_device_authentication_is_disabled_turning_off">Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</string>
<string name="auth_retry">Повторить</string>
<string name="auth_stop_chat">Остановить чат</string>
<string name="auth_open_chat_console">Открыть консоль</string>
<!-- Chat Alerts - ChatItemView.kt -->
<string name="message_delivery_error_title">Ошибка доставки сообщения</string>
<string name="message_delivery_error_desc">Скорее всего, этот контакт удалил соединение с вами.</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Ответить</string>
<string name="share_verb">Поделиться</string>
@@ -146,6 +167,7 @@
<string name="save_verb">Сохранить</string>
<string name="edit_verb">Редактировать</string>
<string name="delete_verb">Удалить</string>
<string name="allow_verb">Разрешить</string>
<string name="delete_message__question">Удалить сообщение?</string>
<string name="delete_message_cannot_be_undone_warning">Сообщение будет удалено это действие нельзя отменить!</string>
<string name="for_me_only">Только для меня</string>
@@ -183,6 +205,8 @@
<string name="icon_descr_cancel_file_preview">Удалить превью файла</string>
<string name="images_limit_title">Слишком много изображений!</string>
<string name="images_limit_desc">Только 10 изображений могут быть отправлены одномоментно</string>
<string name="image_decoding_exception_title">Ошибка декодирования</string>
<string name="image_decoding_exception_desc">Не получается декодировать изображение. Пожалуйста, попробуйте другое изображение или свяжитесь с разработчиками.</string>
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Изображение</string>
@@ -204,6 +228,10 @@
<string name="file_not_found">Файл не найден</string>
<string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Voice messages -->
<string name="voice_message">Голосовое сообщение</string>
<string name="voice_message_send_text">Голосовое сообщение…</string>
<!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Уведомления</string>
@@ -221,11 +249,18 @@
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Отправить сообщение</string>
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
<string name="allow_voice_messages_question">Разрешить голосовые сообщения?</string>
<string name="you_need_to_allow_to_send_voice">Чтобы включить отправку голосовых сообщений, разрешите их вашему контакту.</string>
<string name="voice_messages_prohibited">Голосовые сообщения запрещены!</string>
<string name="ask_your_contact_to_enable_voice">Попросите вашего контакта разрешить отправку голосовых сообщений.</string>
<string name="only_group_owners_can_enable_voice">Только владельцы группы могут разрешить голосовые сообщения.</string>
<!-- General Actions / Responses -->
<string name="back">Назад</string>
<string name="cancel_verb">Отменить</string>
<string name="confirm_verb">Подтвердить</string>
<string name="reset_verb">Сбросить</string>
<string name="ok">OK</string>
<string name="no_details">нет описания</string>
<string name="add_contact">Одноразовая ссылка</string>
@@ -351,19 +386,39 @@
<string name="how_to_use_simplex_chat">Как использовать</string>
<string name="markdown_help">Форматирование сообщений</string>
<string name="markdown_in_messages">Форматирование сообщений</string>
<string name="chat_with_the_founder">Соединиться с разработчиками</string>
<string name="chat_with_the_founder">Отправьте вопросы и идеи</string>
<string name="send_us_an_email">Отправить email</string>
<string name="chat_lock">Блокировка SimpleX</string>
<string name="chat_console">Консоль</string>
<string name="smp_servers">SMP серверы</string>
<string name="smp_servers_preset_address">Адрес сервера по умолчанию</string>
<string name="smp_servers_preset_add">Добавить серверы по умолчанию</string>
<string name="smp_servers_add">Добавить сервер…</string>
<string name="smp_servers_test_server">Тестировать сервер</string>
<string name="smp_servers_test_servers">Тестировать серверы</string>
<string name="smp_servers_save">Сохранить серверы</string>
<string name="smp_servers_test_failed">Ошибка теста сервера!</string>
<string name="smp_servers_test_some_failed">Серверы не прошли тест:</string>
<string name="smp_servers_scan_qr">Сканировать QR код сервера</string>
<string name="smp_servers_enter_manually">Ввести сервер вручную</string>
<string name="smp_servers_preset_server">Сервер по умолчанию</string>
<string name="smp_servers_your_server">Ваш сервер</string>
<string name="smp_servers_your_server_address">Адрес вашего сервера</string>
<string name="smp_servers_use_server">Использовать сервер</string>
<string name="smp_servers_use_server_for_new_conn">Использовать для новых соединений</string>
<string name="smp_servers_add_to_another_device">Добавить на другое устройство</string>
<string name="smp_servers_invalid_address">Ошибка в адресе сервера!</string>
<string name="smp_servers_check_address">Проверьте адрес сервера и попробуйте снова.</string>
<string name="smp_servers_delete_server">Удалить сервер</string>
<string name="install_simplex_chat_for_terminal"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</string>
<string name="star_on_github">Поставить звездочку в GitHub</string>
<string name="contribute">Внести свой вклад</string>
<string name="rate_the_app">Оценить приложение</string>
<string name="use_simplex_chat_servers__question">Использовать серверы предосталенные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>?</string>
<string name="saved_SMP_servers_will_be_removed">Сохраненные SMP серверы будут удалены.</string>
<string name="your_SMP_servers">Ваши SMP серверы</string>
<string name="configure_SMP_servers">Настройка SMP серверов</string>
<string name="using_simplex_chat_servers">Используются серверы предоставленные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<string name="enter_one_SMP_server_per_line">Введите SMP серверы, каждый сервер в отдельной строке:</string>
<string name="how_to">Инфо</string>
<string name="how_to_use_your_servers">Как использовать серверы</string>
<string name="saved_ICE_servers_will_be_removed">Сохраненные WebRTC ICE серверы будут удалены.</string>
<string name="your_ICE_servers">Ваши ICE серверы</string>
<string name="configure_ICE_servers">Настройка ICE серверов</string>
@@ -413,7 +468,9 @@
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\n<xliff:g id="appName">SimpleX</xliff:g> серверы не могут получить доступ к вашему профилю.</string>
<string name="edit_image">Поменять аватар</string>
<string name="delete_image">Удалить аватар</string>
<string name="save_and_notify_contact">Сохранить (и уведомить контакт)</string>
<string name="save_and_notify_contacts">Сохранить (и послать обновление контактам)</string>
<string name="save_and_notify_group_members">Сохранить (и уведомить членов группы)</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
@@ -552,14 +609,16 @@
<!-- Privacy settings -->
<string name="privacy_and_security">Конфиденциальность</string>
<string name="your_privacy">Конфиденциальность</string>
<string name="protect_app_screen">Защитить экран приложения</string>
<string name="auto_accept_images">Автоприем изображений</string>
<string name="transfer_images_faster">Передавать изображения быстрее (BETA)</string>
<string name="transfer_images_faster">Передавать изображения быстрее</string>
<string name="send_link_previews">Отправлять картинки ссылок</string>
<!-- Settings sections -->
<string name="settings_section_title_you">ВЫ</string>
<string name="settings_section_title_settings">НАСТРОЙКИ</string>
<string name="settings_section_title_help">ПОМОЩЬ</string>
<string name="settings_section_title_support">ПОДДЕРЖАТЬ SIMPLEX CHAT</string>
<string name="settings_section_title_develop">ДЛЯ РАЗРАБОТЧИКОВ</string>
<string name="settings_section_title_device">УСТРОЙСТВО</string>
<string name="settings_section_title_chats">ЧАТЫ</string>
@@ -674,6 +733,7 @@
<string name="restore_database_alert_desc">Введите предыдущий пароль после восстановления резервной копии. Это действие нельзя отменить.</string>
<string name="restore_database_alert_confirm">Восстановить</string>
<string name="database_restore_error">Ошибка при восстановлении базы данных</string>
<string name="restore_passphrase_not_found_desc">Пароль не найден в Keystore, пожалуйста, введите его вручную. Это могло произойти, если вы восстановили данные приложения с помощью инструмента резервного копирования. Если это не так, пожалуйста, свяжитесь с разработчиками.</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Чат остановлен</string>
@@ -791,6 +851,7 @@
<string name="all_group_members_will_remain_connected">Все члены группы, которые соединились через эту ссылку, останутся в группе.</string>
<string name="error_creating_link_for_group">Ошибка при создании ссылки группы</string>
<string name="error_deleting_link_for_group">Ошибка при удалении ссылки группы</string>
<string name="only_group_owners_can_change_prefs">Только владельцы группы могут изменять предпочтения группы.</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
@@ -822,7 +883,7 @@
<string name="receiving_via">Получение через</string>
<string name="sending_via">Отправка через</string>
<string name="network_status">Состояние сети</string>
<string name="switch_receiving_address">Переключить адрес получения (BETA)</string>
<string name="switch_receiving_address">Переключить адрес получения</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Создать скрытую группу</string>
@@ -871,4 +932,48 @@
<string name="save_color">Сохранить цвет</string>
<string name="reset_color">Сбросить цвета</string>
<string name="color_primary">Акцент</string>
<!-- Preferences.kt -->
<string name="chat_preferences_you_allow">Вы разрешаете</string>
<string name="chat_preferences_contact_allows">Контакт разрешает</string>
<string name="chat_preferences_default">по умолчанию (%s)</string>
<string name="chat_preferences_yes">да</string>
<string name="chat_preferences_no">нет</string>
<string name="chat_preferences_always">всегда</string>
<string name="chat_preferences_on">да</string>
<string name="chat_preferences_off">нет</string>
<string name="chat_preferences">Предпочтения</string>
<string name="contact_preferences">Предпочтения контакта</string>
<string name="group_preferences">Предпочтения группы</string>
<string name="your_preferences">Ваши предпочтения</string>
<string name="full_deletion">Полное удаление</string>
<string name="voice_messages">Голосовые сообщения</string>
<string name="feature_enabled">включено</string>
<string name="feature_enabled_for_you">включено для вас</string>
<string name="feature_enabled_for_contact">включено для контакта</string>
<string name="feature_off">выключено</string>
<string name="feature_received_prohibited">получено, не разрешено</string>
<string name="allow_your_contacts_irreversibly_delete">Разрешить вашим контактам необратимо удалять отправленные сообщения.</string>
<string name="allow_irreversible_message_deletion_only_if">Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.</string>
<string name="contacts_can_mark_messages_for_deletion">Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.</string>
<string name="allow_your_contacts_to_send_voice_messages">Разрешить вашим контактам отправлять голосовые сообщения.</string>
<string name="allow_voice_messages_only_if">Разрешить голосовые сообщения, только если их разрешает ваш контакт.</string>
<string name="prohibit_sending_voice_messages">Запретить отправлять голосовые сообщений.</string>
<string name="both_you_and_your_contacts_can_delete">Вы и ваш контакт можете необратимо удалять отправленные сообщения.</string>
<string name="only_you_can_delete_messages">Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).</string>
<string name="only_your_contact_can_delete">Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).</string>
<string name="message_deletion_prohibited">Необратимое удаление сообщений запрещено в этом чате.</string>
<string name="both_you_and_your_contact_can_send_voice">Вы и ваш контакт можете отправлять голосовые сообщения.</string>
<string name="only_you_can_send_voice">Только вы можете отправлять голосовые сообщения.</string>
<string name="only_your_contact_can_send_voice">Только ваш контакт может отправлять голосовые сообщения.</string>
<string name="voice_prohibited_in_this_chat">Голосовые сообщения запрещены в этом чате.</string>
<string name="allow_to_delete_messages">Разрешить необратимо удалять отправленные сообщения.</string>
<string name="prohibit_message_deletion">Запретить необратимое удаление сообщений.</string>
<string name="allow_to_send_voice">Разрешить отправлять голосовые сообщения.</string>
<string name="prohibit_sending_voice">Запретить отправлять голосовые сообщений.</string>
<string name="group_members_can_delete">Члены группы могут необратимо удалять отправленные сообщения.</string>
<string name="message_deletion_prohibited_in_chat">Необратимое удаление сообщений запрещено в этом чате.</string>
<string name="group_members_can_send_voice">Члены группы могут отправлять голосовые сообщения.</string>
<string name="voice_messages_are_prohibited">Голосовые сообщения запрещены в этом чате.</string>
</resources>

View File

@@ -6,7 +6,9 @@
<!-- Connect via Link - MainActivity.kt -->
<string name="connect_via_contact_link">Connect via contact link?</string>
<string name="connect_via_invitation_link">Connect via invitation link?</string>
<string name="connect_via_group_link">Connect via group link?</string>
<string name="profile_will_be_sent_to_contact_sending_link">Your profile will be sent to the contact that you received this link from.</string>
<string name="you_will_join_group">You will join a group this link refers to and connect to its group members.</string>
<string name="connect_via_link_verb">Connect</string>
<!-- Server info - ChatModel.kt -->
@@ -39,6 +41,17 @@
<string name="description_via_one_time_link">via one-time link</string>
<string name="description_via_one_time_link_incognito">incognito via one-time link</string>
<!-- FormattedText, SimpleX links - ChatModel.kt -->
<string name="simplex_link_contact">SimpleX contact address</string>
<string name="simplex_link_invitation">SimpleX one-time invitation</string>
<string name="simplex_link_group">SimpleX group link</string>
<string name="simplex_link_connection">via <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
<string name="simplex_link_mode">SimpleX links</string>
<string name="simplex_link_mode_description">Description</string>
<string name="simplex_link_mode_full">Full link</string>
<string name="simplex_link_mode_browser">Via browser</string>
<string name="simplex_link_mode_browser_warning">Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.</string>
<!-- SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Error saving SMP servers</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Make sure SMP server addresses are in correct format, line separated and are not duplicated.</string>
@@ -56,7 +69,7 @@
<string name="error_receiving_file">Error receiving file</string>
<string name="error_creating_address">Error creating address</string>
<string name="contact_already_exists">Contact already exists</string>
<string name="you_are_already_connected_to_vName_via_this_link">You are already connected to <xliff:g id="contactName" example="Alice">%1$s!</xliff:g> via this link.</string>
<string name="you_are_already_connected_to_vName_via_this_link">You are already connected to <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
<string name="invalid_connection_link">Invalid connection link</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Please check that you used the correct link or ask your contact to send you another one.</string>
<string name="connection_error_auth">Connection error (AUTH)</string>
@@ -68,6 +81,14 @@
<string name="error_deleting_contact_request">Error deleting contact request</string>
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
<string name="error_changing_address">Error changing address</string>
<string name="error_smp_test_failed_at_step">Test failed at step %s.</string>
<string name="error_smp_test_server_auth">Server requires authorization to create queues, check password</string>
<string name="error_smp_test_certificate">Possibly, certificate fingerprint in server address is incorrect</string>
<string name="smp_server_test_connect">Connect</string>
<string name="smp_server_test_create_queue">Create queue</string>
<string name="smp_server_test_secure_queue">Secure queue</string>
<string name="smp_server_test_delete_queue">Delete queue</string>
<string name="smp_server_test_disconnect">Disconnect</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Instant notifications</string>
@@ -129,16 +150,16 @@
<string name="auth_enable_simplex_lock">Enable SimpleX Lock</string>
<string name="auth_disable_simplex_lock">Disable SimpleX Lock</string>
<string name="auth_confirm_credential">Confirm your credential</string>
<string name="auth_error">Authentication error</string>
<string name="auth_error_w_desc">Authentication error: <xliff:g id="desc">%1$s</xliff:g></string>
<string name="auth_failed">Authentication failed</string>
<string name="auth_unavailable">Authentication unavailable</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</string>
<string name="auth_device_authentication_is_disabled_turning_off">Device authentication is disabled. Turning off SimpleX Lock.</string>
<string name="auth_retry">Retry</string>
<string name="auth_stop_chat">Stop chat</string>
<string name="auth_open_chat_console">Open chat console</string>
<!-- Chat Alerts - ChatItemView.kt -->
<string name="message_delivery_error_title">Message delivery error</string>
<string name="message_delivery_error_desc">Most likely this contact has deleted the connection with you.</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Reply</string>
<string name="share_verb">Share</string>
@@ -146,6 +167,7 @@
<string name="save_verb">Save</string>
<string name="edit_verb">Edit</string>
<string name="delete_verb">Delete</string>
<string name="allow_verb">Allow</string>
<string name="delete_message__question">Delete message?</string>
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
<string name="for_me_only">For me only</string>
@@ -183,6 +205,8 @@
<string name="icon_descr_cancel_file_preview">Cancel file preview</string>
<string name="images_limit_title">Too many images!</string>
<string name="images_limit_desc">Only 10 images can be sent at the same time</string>
<string name="image_decoding_exception_title">Decoding error</string>
<string name="image_decoding_exception_desc">The image cannot be decoded. Please, try a different image or contact developers.</string>
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Image</string>
@@ -204,6 +228,10 @@
<string name="file_not_found">File not found</string>
<string name="error_saving_file">Error saving file</string>
<!-- Voice messages -->
<string name="voice_message">Voice message</string>
<string name="voice_message_send_text">Voice message…</string>
<!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Notifications</string>
@@ -221,11 +249,18 @@
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Send Message</string>
<string name="icon_descr_record_voice_message">Record voice message</string>
<string name="allow_voice_messages_question">Allow voice messages?</string>
<string name="you_need_to_allow_to_send_voice">You need to allow your contact to send voice messages to be able to send them.</string>
<string name="voice_messages_prohibited">Voice messages prohibited!</string>
<string name="ask_your_contact_to_enable_voice">Please ask your contact to enable sending voice messages.</string>
<string name="only_group_owners_can_enable_voice">Only group owners can enable voice messages.</string>
<!-- General Actions / Responses -->
<string name="back">Back</string>
<string name="cancel_verb">Cancel</string>
<string name="confirm_verb">Confirm</string>
<string name="reset_verb">Reset</string>
<string name="ok">OK</string>
<string name="no_details">no details</string>
<string name="add_contact">One-time invitation link</string>
@@ -354,19 +389,39 @@
<string name="how_to_use_simplex_chat">How to use it</string>
<string name="markdown_help">Markdown help</string>
<string name="markdown_in_messages">Markdown in messages</string>
<string name="chat_with_the_founder">Connect to the developers</string>
<string name="chat_with_the_founder">Send questions and ideas</string>
<string name="send_us_an_email">Send us email</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Chat console</string>
<string name="smp_servers">SMP servers</string>
<string name="smp_servers_preset_address">Preset server address</string>
<string name="smp_servers_preset_add">Add preset servers</string>
<string name="smp_servers_add">Add server…</string>
<string name="smp_servers_test_server">Test server</string>
<string name="smp_servers_test_servers">Test servers</string>
<string name="smp_servers_save">Save servers</string>
<string name="smp_servers_test_failed">Server test failed!</string>
<string name="smp_servers_test_some_failed">Some servers failed the test:</string>
<string name="smp_servers_scan_qr">Scan server QR code</string>
<string name="smp_servers_enter_manually">Enter server manually</string>
<string name="smp_servers_preset_server">Preset server</string>
<string name="smp_servers_your_server">Your server</string>
<string name="smp_servers_your_server_address">Your server address</string>
<string name="smp_servers_use_server">Use server</string>
<string name="smp_servers_use_server_for_new_conn">Use for new connections</string>
<string name="smp_servers_add_to_another_device">Add to another device</string>
<string name="smp_servers_invalid_address">Invalid server address!</string>
<string name="smp_servers_check_address">Check server address and try again.</string>
<string name="smp_servers_delete_server">Delete server</string>
<string name="install_simplex_chat_for_terminal">Install <xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</string>
<string name="star_on_github">Star on GitHub</string>
<string name="contribute">Contribute</string>
<string name="rate_the_app">Rate the app</string>
<string name="use_simplex_chat_servers__question">Use <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers?</string>
<string name="saved_SMP_servers_will_be_removed">Saved SMP servers will be removed.</string>
<string name="your_SMP_servers">Your SMP servers</string>
<string name="configure_SMP_servers">Configure SMP servers</string>
<string name="using_simplex_chat_servers">Using <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers.</string>
<string name="enter_one_SMP_server_per_line">SMP servers (one per line)</string>
<string name="how_to">How to</string>
<string name="how_to_use_your_servers">How to use your servers</string>
<string name="saved_ICE_servers_will_be_removed">Saved WebRTC ICE servers will be removed.</string>
<string name="your_ICE_servers">Your ICE servers</string>
<string name="configure_ICE_servers">Configure ICE servers</string>
@@ -416,7 +471,9 @@
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts.\n\n<xliff:g id="appName">SimpleX</xliff:g> servers cannot see your profile.</string>
<string name="edit_image">Edit image</string>
<string name="delete_image">Delete image</string>
<string name="save_and_notify_contact">Save (and notify contact)</string>
<string name="save_and_notify_contacts">Save (and notify contacts)</string>
<string name="save_and_notify_group_members">Save (and notify group members)</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">You control your chat!</string>
@@ -552,14 +609,16 @@
<!-- Privacy settings -->
<string name="privacy_and_security">Privacy &amp; security</string>
<string name="your_privacy">Your privacy</string>
<string name="protect_app_screen">Protect app screen</string>
<string name="auto_accept_images">Auto-accept images</string>
<string name="transfer_images_faster">Transfer images faster (BETA)</string>
<string name="transfer_images_faster">Transfer images faster</string>
<string name="send_link_previews">Send link previews</string>
<!-- Settings sections -->
<string name="settings_section_title_you">YOU</string>
<string name="settings_section_title_settings">SETTINGS</string>
<string name="settings_section_title_help">HELP</string>
<string name="settings_section_title_support">SUPPORT SIMPLEX CHAT</string>
<string name="settings_section_title_develop">DEVELOP</string>
<string name="settings_section_title_device">DEVICE</string>
<string name="settings_section_title_chats">CHATS</string>
@@ -674,6 +733,7 @@
<string name="restore_database_alert_desc">Please enter the previous password after restoring database backup. This action can not be undone.</string>
<string name="restore_database_alert_confirm">Restore</string>
<string name="database_restore_error">Restore database error</string>
<string name="restore_passphrase_not_found_desc">Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please, contact developers.</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Chat is stopped</string>
@@ -791,6 +851,7 @@
<string name="all_group_members_will_remain_connected">All group members will remain connected.</string>
<string name="error_creating_link_for_group">Error creating group link</string>
<string name="error_deleting_link_for_group">Error deleting group link</string>
<string name="only_group_owners_can_change_prefs">Only group owners can change group preferences.</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">FOR CONSOLE</string>
@@ -822,7 +883,7 @@
<string name="receiving_via">Receiving via</string>
<string name="sending_via">Sending via</string>
<string name="network_status">Network status</string>
<string name="switch_receiving_address">Switch receiving address (BETA)</string>
<string name="switch_receiving_address">Switch receiving address</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Create secret group</string>
@@ -872,4 +933,48 @@
<string name="save_color">Save color</string>
<string name="reset_color">Reset colors</string>
<string name="color_primary">Accent</string>
<!-- Preferences.kt -->
<string name="chat_preferences_you_allow">You allow</string>
<string name="chat_preferences_contact_allows">Contact allows</string>
<string name="chat_preferences_default">default (%s)</string>
<string name="chat_preferences_yes">yes</string>
<string name="chat_preferences_no">no</string>
<string name="chat_preferences_always">always</string>
<string name="chat_preferences_on">on</string>
<string name="chat_preferences_off">off</string>
<string name="chat_preferences">Chat preferences</string>
<string name="contact_preferences">Contact preferences</string>
<string name="group_preferences">Group preferences</string>
<string name="your_preferences">Your preferences</string>
<string name="full_deletion">Full deletion</string>
<string name="voice_messages">Voice messages</string>
<string name="feature_enabled">enabled</string>
<string name="feature_enabled_for_you">enabled for you</string>
<string name="feature_enabled_for_contact">enabled for contact</string>
<string name="feature_off">off</string>
<string name="feature_received_prohibited">received, prohibited</string>
<string name="allow_your_contacts_irreversibly_delete">Allow your contacts to irreversibly delete sent messages.</string>
<string name="allow_irreversible_message_deletion_only_if">Allow irreversible message deletion only if your contact allows it to you.</string>
<string name="contacts_can_mark_messages_for_deletion">Contacts can mark messages for deletion; you will be able to view them.</string>
<string name="allow_your_contacts_to_send_voice_messages">Allow your contacts to send voice messages.</string>
<string name="allow_voice_messages_only_if">Allow voice messages only if your contact allows them.</string>
<string name="prohibit_sending_voice_messages">Prohibit sending voice messages.</string>
<string name="both_you_and_your_contacts_can_delete">Both you and your contact can irreversibly delete sent messages.</string>
<string name="only_you_can_delete_messages">Only you can irreversibly delete messages (your contact can mark them for deletion).</string>
<string name="only_your_contact_can_delete">Only your contact can irreversibly delete messages (you can mark them for deletion).</string>
<string name="message_deletion_prohibited">Irreversible message deletion is prohibited in this chat.</string>
<string name="both_you_and_your_contact_can_send_voice">Both you and your contact can send voice messages.</string>
<string name="only_you_can_send_voice">Only you can send voice messages.</string>
<string name="only_your_contact_can_send_voice">Only your contact can send voice messages.</string>
<string name="voice_prohibited_in_this_chat">Voice messages are prohibited in this chat.</string>
<string name="allow_to_delete_messages">Allow to irreversibly delete sent messages.</string>
<string name="prohibit_message_deletion">Prohibit irreversible message deletion.</string>
<string name="allow_to_send_voice">Allow to send voice messages.</string>
<string name="prohibit_sending_voice">Prohibit sending voice messages.</string>
<string name="group_members_can_delete">Group members can irreversibly delete sent messages.</string>
<string name="message_deletion_prohibited_in_chat">Irreversible message deletion is prohibited in this chat.</string>
<string name="group_members_can_send_voice">Group members can send voice messages.</string>
<string name="voice_messages_are_prohibited">Voice messages are prohibited in this chat.</string>
</resources>

View File

@@ -102,9 +102,11 @@ class AppDelegate: NSObject, UIApplicationDelegate {
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
var window: UIWindow?
var windowScene: UIWindowScene?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
self.windowScene = windowScene
window = windowScene.keyWindow
window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()

View File

@@ -17,6 +17,8 @@ struct ContentView: View {
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = true
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
var body: some View {
ZStack {
@@ -29,7 +31,7 @@ struct ContentView: View {
} else if let step = chatModel.onboardingStage {
if case .onboardingComplete = step,
chatModel.currentUser != nil {
mainView()
mainView().privacySensitive(protectScreen)
} else {
OnboardingView(onboarding: step)
}
@@ -46,9 +48,15 @@ struct ContentView: View {
ZStack(alignment: .top) {
ChatListView()
.onAppear {
NtfManager.shared.requestAuthorization(onDeny: {
alertManager.showAlert(notificationAlert())
})
NtfManager.shared.requestAuthorization(
onDeny: {
if (!notificationAlertShown) {
notificationAlertShown = true
alertManager.showAlert(notificationAlert())
}
},
onAuthorized: { notificationAlertShown = false }
)
// Local Authentication notice is to be shown on next start after onboarding is complete
if (!prefLANoticeShown && prefShowLANotice) {
prefLANoticeShown = true
@@ -70,6 +78,7 @@ struct ContentView: View {
dismissAllSheets(animated: false) {
justAuthenticate()
}
chatModel.chatId = nil
}
}

View File

@@ -0,0 +1,141 @@
//
// AudioRecPlay.swift
// SimpleX (iOS)
//
// Created by Evgeny on 19/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import AVFoundation
import SwiftUI
import SimpleXChat
class AudioRecorder {
var onTimer: ((TimeInterval) -> Void)?
var onFinishRecording: (() -> Void)?
var audioRecorder: AVAudioRecorder?
var recordingTimer: Timer?
init(onTimer: @escaping ((TimeInterval) -> Void), onFinishRecording: @escaping (() -> Void)) {
self.onTimer = onTimer
self.onFinishRecording = onFinishRecording
}
enum StartError {
case permission
case error(String)
}
func start(fileName: String) async -> StartError? {
let av = AVAudioSession.sharedInstance()
if !(await checkPermission()) { return .permission }
do {
try av.setCategory(AVAudioSession.Category.playAndRecord, options: .defaultToSpeaker)
try av.setActive(true)
let settings: [String : Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 12000,
AVEncoderBitRateKey: 12000,
AVNumberOfChannelsKey: 1
]
audioRecorder = try AVAudioRecorder(url: getAppFilePath(fileName), settings: settings)
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
await MainActor.run {
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
guard let time = self.audioRecorder?.currentTime else { return }
self.onTimer?(time)
if time >= MAX_VOICE_MESSAGE_LENGTH {
self.stop()
self.onFinishRecording?()
}
}
}
return nil
} catch let error {
logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)")
return .error(error.localizedDescription)
}
}
func stop() {
if let recorder = audioRecorder {
recorder.stop()
}
audioRecorder = nil
if let timer = recordingTimer {
timer.invalidate()
}
recordingTimer = nil
}
private func checkPermission() async -> Bool {
let av = AVAudioSession.sharedInstance()
switch av.recordPermission {
case .granted: return true
case .denied: return false
case .undetermined:
return await withCheckedContinuation { cont in
DispatchQueue.main.async {
av.requestRecordPermission { allowed in
cont.resume(returning: allowed)
}
}
}
@unknown default: return false
}
}
}
class AudioPlayer: NSObject, AVAudioPlayerDelegate {
var onTimer: ((TimeInterval) -> Void)?
var onFinishPlayback: (() -> Void)?
var audioPlayer: AVAudioPlayer?
var playbackTimer: Timer?
init(onTimer: @escaping ((TimeInterval) -> Void), onFinishPlayback: @escaping (() -> Void)) {
self.onTimer = onTimer
self.onFinishPlayback = onFinishPlayback
}
func start(fileName: String) {
audioPlayer = try? AVAudioPlayer(contentsOf: getAppFilePath(fileName))
audioPlayer?.delegate = self
audioPlayer?.prepareToPlay()
audioPlayer?.play()
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
if self.audioPlayer?.isPlaying ?? false {
guard let time = self.audioPlayer?.currentTime else { return }
self.onTimer?(time)
}
}
}
func pause() {
audioPlayer?.pause()
}
func play() {
audioPlayer?.play()
}
func stop() {
if let player = audioPlayer {
player.stop()
}
audioPlayer = nil
if let timer = playbackTimer {
timer.invalidate()
}
playbackTimer = nil
}
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
stop()
self.onFinishPlayback?()
}
}

View File

@@ -31,7 +31,8 @@ final class ChatModel: ObservableObject {
// items in the terminal view
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: UserContactLink?
@Published var userSMPServers: [String]?
@Published var userSMPServers: [ServerCfg]?
@Published var presetSMPServers: [String]?
@Published var chatItemTTL: ChatItemTTL = .none
@Published var appOpenUrl: URL?
@Published var deviceToken: DeviceToken?
@@ -51,6 +52,8 @@ final class ChatModel: ObservableObject {
@Published var showCallView = false
// currently showing QR code
@Published var connReqInv: String?
// audio recording and playback
@Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches
var callWebView: WKWebView?
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
@@ -386,6 +389,11 @@ final class ChatModel: ObservableObject {
}
func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool {
// user member was updated
if groupInfo.membership.groupMemberId == member.groupMemberId {
updateGroup(groupInfo)
return false
}
// update current chat
if chatId == groupInfo.id {
if let i = groupMembers.firstIndex(where: { $0.id == member.id }) {
@@ -393,10 +401,6 @@ final class ChatModel: ObservableObject {
self.groupMembers[i] = member
}
return false
} else if (groupInfo.membership.groupMemberId == member.groupMemberId) {
// Current user was updated (like his role, for example)
updateGroup(groupInfo)
return true
} else {
withAnimation { groupMembers.append(member) }
return true

View File

@@ -165,22 +165,23 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
])
}
func requestAuthorization(onDeny handler: (()-> Void)? = nil) {
func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil) {
logger.debug("NtfManager.requestAuthorization")
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
switch settings.authorizationStatus {
case .denied:
if let handler = handler { handler() }
return
denied?()
case .authorized:
self.granted = true
authorized?()
default:
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if let error = error {
logger.error("NtfManager.requestAuthorization error \(error.localizedDescription)")
} else {
self.granted = granted
authorized?()
}
}
}

View File

@@ -309,16 +309,27 @@ func apiDeleteToken(token: DeviceToken) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token))
}
func getUserSMPServers() throws -> [String] {
func getUserSMPServers() throws -> ([ServerCfg], [String]) {
let r = chatSendCmdSync(.getUserSMPServers)
if case let .userSMPServers(smpServers) = r { return smpServers }
if case let .userSMPServers(smpServers, presetServers) = r { return (smpServers, presetServers) }
throw r
}
func setUserSMPServers(smpServers: [String]) async throws {
func setUserSMPServers(smpServers: [ServerCfg]) async throws {
try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
}
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
let r = await chatSendCmd(.testSMPServer(smpServer: smpServer))
if case let .smpTestResult(testFailure) = r {
if let t = testFailure {
return .failure(t)
}
return .success(())
}
throw r
}
func getChatItemTTL() throws -> ChatItemTTL {
let r = chatSendCmdSync(.apiGetChatItemTTL)
if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
@@ -379,9 +390,13 @@ func apiConnect(connReq: String) async -> ConnReqType? {
case .sentConfirmation: return .invitation
case .sentInvitation: return .contact
case let .contactAlreadyExists(contact):
let m = ChatModel.shared
if let c = m.getContactChat(contact.contactId) {
await MainActor.run { m.chatId = c.id }
}
am.showAlertMsg(
title: "Contact already exists",
message: "You are already connected to \(contact.displayName) via this link."
message: "You are already connected to \(contact.displayName)."
)
return nil
case .chatCmdError(.error(.invalidConnReq)):
@@ -472,6 +487,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
}
}
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
if case let .contactPrefsUpdated(_, toContact) = r { return toContact }
throw r
}
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
if case let .contactAliasUpdated(toContact) = r { return toContact }
@@ -566,10 +587,15 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? {
)
} else if !networkErrorAlert(r) {
logger.error("apiReceiveFile error: \(String(describing: r))")
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(String(describing: r))"
)
switch r {
case .chatCmdError(.error(.fileAlreadyReceiving)):
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
default:
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(String(describing: r))"
)
}
}
return nil
}
@@ -822,7 +848,7 @@ func startChat() throws {
let justStarted = try apiStartChat()
if justStarted {
m.userAddress = try apiGetUserAddress()
m.userSMPServers = try getUserSMPServers()
(m.userSMPServers, m.presetSMPServers) = try getUserSMPServers()
m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats()
m.chats = chats.map { Chat.init($0) }
@@ -948,20 +974,28 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.addChatItem(cInfo, cItem)
if case .image = cItem.content.msgContent,
let file = cItem.file,
file.fileSize <= maxImageSize,
file.fileSize <= MAX_IMAGE_SIZE,
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
Task {
await receiveFile(fileId: file.fileId)
}
} else if case .voice = cItem.content.msgContent, // TODO check inlineFileMode != IFMSent
let file = cItem.file,
file.fileSize <= MAX_IMAGE_SIZE,
file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND,
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
Task {
await receiveFile(fileId: file.fileId)
}
}
if !cItem.chatDir.sent && !cItem.isCall() && !cItem.isMutedMemberEvent {
if !cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent {
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
case let .chatItemStatusUpdated(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
var res = false
if !cItem.isDeletedContent() {
if !cItem.isDeletedContent {
res = m.upsertChatItem(cInfo, cItem)
}
if res {
@@ -986,10 +1020,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
_ = m.upsertChatItem(cInfo, cItem)
}
case let .receivedGroupInvitation(groupInfo, _, _):
m.addChat(Chat(
chatInfo: .group(groupInfo: groupInfo),
chatItems: []
))
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
// NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation?
case let .userAcceptedGroupSent(groupInfo, hostContact):
m.updateGroup(groupInfo)
@@ -1024,9 +1055,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .sndFileComplete(aChatItem, _):
chatItemSimpleUpdate(aChatItem)
let cItem = aChatItem.chatItem
let mc = cItem.content.msgContent
if aChatItem.chatInfo.chatType == .direct,
let mc = cItem.content.msgContent,
mc.isFile(),
case .file = mc,
let fileName = cItem.file?.filePath {
removeFile(fileName)
}

View File

@@ -20,7 +20,7 @@ struct SimpleXApp: App {
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@State private var userAuthorized: Bool?
@State private var doAuthenticate = false
@State private var enteredBackground: Double? = nil
@State private var enteredBackground: TimeInterval? = nil
init() {
hs_init(0, nil)

View File

@@ -9,7 +9,7 @@
import SwiftUI
import SimpleXChat
func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
func infoRow<S>(_ title: S, _ value: String) -> some View where S: StringProtocol {
HStack {
Text(title)
Spacer()
@@ -53,7 +53,7 @@ struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
var contact: Contact
@State var contact: Contact
@Binding var connectionStats: ConnectionStats?
var customUserProfile: Profile?
@State var localAlias: String
@@ -99,15 +99,17 @@ struct ChatInfoView: View {
}
}
Section {
contactPreferencesButton()
}
Section("Servers") {
networkStatusRow()
.onTapGesture {
alert = .networkStatusAlert
}
if developerTools {
Button("Change receiving address (BETA)") {
alert = .switchAddressAlert
}
Button("Change receiving address") {
alert = .switchAddressAlert
}
if let connStats = connectionStats {
smpServers("Receiving via", connStats.rcvServers)
@@ -192,6 +194,20 @@ struct ChatInfoView: View {
}
}
func contactPreferencesButton() -> some View {
NavigationLink {
ContactPreferencesView(
contact: $contact,
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
)
.navigationBarTitle("Contact preferences")
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Contact preferences", systemImage: "switch.2")
}
}
func networkStatusRow() -> some View {
HStack {
Text("Network status")

View File

@@ -54,7 +54,7 @@ struct CICallItemView: View {
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
HStack {
Image(systemName: "phone.down")
Text(CICallStatus.durationText(duration)).foregroundColor(.secondary)
Text(durationText(duration)).foregroundColor(.secondary)
}
}

View File

@@ -0,0 +1,34 @@
//
// CIChatFeatureView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 21/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct CIChatFeatureView: View {
var chatItem: ChatItem
var feature: Feature
var iconColor: Color
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
Image(systemName: feature.icon + ".fill")
.foregroundColor(iconColor)
chatEventText(chatItem)
}
.padding(.leading, 6)
.padding(.bottom, 6)
.textSelection(.disabled)
}
}
struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: .fullDelete, iconColor: enabled.iconColor)
}
}

View File

@@ -20,27 +20,27 @@ struct CIEventView: View {
.foregroundColor(.secondary)
.fontWeight(.light)
+ Text(" ")
+ eventText()
+ chatEventText(chatItem)
} else {
eventText()
chatEventText(chatItem)
}
}
.padding(.leading, 6)
.padding(.bottom, 6)
.textSelection(.disabled)
}
}
func eventText() -> Text {
Text(chatItem.content.text)
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
+ Text(" ")
+ chatItem.timestampText
.font(.caption)
.foregroundColor(Color.secondary)
.fontWeight(.light)
}
func chatEventText(_ ci: ChatItem) -> Text {
Text(ci.content.text)
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
+ Text(" ")
+ ci.timestampText
.font(.caption)
.foregroundColor(Color.secondary)
.fontWeight(.light)
}
struct CIEventView_Previews: PreviewProvider {

View File

@@ -50,7 +50,7 @@ struct CIFileView: View {
func fileSizeValid() -> Bool {
if let file = file {
return file.fileSize <= maxFileSize
return file.fileSize <= MAX_FILE_SIZE
}
return false
}
@@ -66,7 +66,7 @@ struct CIFileView: View {
await receiveFile(fileId: file.fileId)
}
} else {
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
@@ -79,7 +79,7 @@ struct CIFileView: View {
)
case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete")
if let filePath = getLoadedFilePath(file){
if let filePath = getLoadedFilePath(file) {
let url = URL(fileURLWithPath: filePath)
showShareSheet(items: [url])
}
@@ -148,7 +148,7 @@ struct CIFileView_Previews: PreviewProvider {
quotedItem: nil,
file: nil
)
Group{
Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))

View File

@@ -15,7 +15,7 @@ struct CIMetaView: View {
var body: some View {
HStack(alignment: .center, spacing: 4) {
if !chatItem.isDeletedContent() {
if !chatItem.isDeletedContent {
if chatItem.meta.itemEdited {
statusImage("pencil", metaColor, 9)
}

View File

@@ -0,0 +1,246 @@
//
// CIVoiceView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 22.11.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct CIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval?
var body: some View {
VStack (
alignment: chatItem.chatDir.sent ? .trailing : .leading,
spacing: 6
) {
HStack {
if chatItem.chatDir.sent {
playerTime()
.frame(width: 50, alignment: .leading)
player()
} else {
player()
playerTime()
.frame(width: 50, alignment: .leading)
}
}
CIMetaView(chatItem: chatItem)
.padding(.leading, chatItem.chatDir.sent ? 0 : 12)
.padding(.trailing, chatItem.chatDir.sent ? 12 : 0)
}
.padding(.bottom, 8)
}
private func player() -> some View {
VoiceMessagePlayer(
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
showBackground: true,
playbackState: $playbackState,
playbackTime: $playbackTime
)
}
private func playerTime() -> some View {
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
playbackState: $playbackState,
playbackTime: $playbackTime
)
.foregroundColor(.secondary)
}
}
struct VoiceMessagePlayerTime: View {
var recordingTime: TimeInterval
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
var body: some View {
switch playbackState {
case .noPlayback:
Text(voiceMessageTime(recordingTime))
case .playing:
Text(voiceMessageTime_(playbackTime))
case .paused:
Text(voiceMessageTime_(playbackTime))
}
}
}
struct VoiceMessagePlayer: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var recordingFile: CIFile?
var recordingTime: TimeInterval
var showBackground: Bool
@State private var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var startingPlayback: Bool = false
var body: some View {
ZStack {
if let recordingFile = recordingFile {
switch recordingFile.fileStatus {
case .sndStored: playbackButton()
case .sndTransfer: playbackButton()
case .sndComplete: playbackButton()
case .sndCancelled: playbackButton()
case .rcvInvitation: loadingIcon()
case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon()
case .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
} else {
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
}
.onDisappear {
audioPlayer?.stop()
}
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
if !startingPlayback {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} else {
startingPlayback = false
}
}
}
@ViewBuilder private func playbackButton() -> some View {
switch playbackState {
case .noPlayback:
Button {
if let recordingFileName = getLoadedFileName(recordingFile) {
startPlayback(recordingFileName)
}
} label: {
playPauseIcon("play.fill")
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
} label: {
playPauseIcon("pause.fill")
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
} label: {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
ZStack {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle())
if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime)
.frame(width: 52, height: 52) // this + ProgressCircle lineWidth = background circle diameter
}
}
}
private struct ProgressCircle: View {
var length: TimeInterval
@Binding var progress: TimeInterval?
var body: some View {
Circle()
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
.stroke(
Color.accentColor,
style: StrokeStyle(lineWidth: 4)
)
.rotationEffect(.degrees(-90))
.animation(.linear, value: progress)
}
}
private func loadingIcon() -> some View {
ProgressView()
.frame(width: 30, height: 30)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle())
}
private func startPlayback(_ recordingFileName: String) {
startingPlayback = true
chatModel.stopPreviousRecPlay.toggle()
audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 },
onFinishPlayback: {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
)
audioPlayer?.start(fileName: recordingFileName)
playbackTime = TimeInterval(0)
playbackState = .playing
}
}
struct CIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)
)
let voiceMessageWtFile = ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "", .rcvRead, false, false, false),
content: .rcvMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: nil,
file: nil
)
Group {
CIVoiceView(
chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30,
playbackState: .playing,
playbackTime: TimeInterval(20)
)
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage)
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample())
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile)
.environmentObject(ChatModel())
}
.previewLayout(.fixed(width: 360, height: 360))
}
}

View File

@@ -0,0 +1,76 @@
//
// FramedCIVoiceView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 22.11.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SwiftUI
import SimpleXChat
struct FramedCIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval?
var body: some View {
HStack {
VoiceMessagePlayer(
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
showBackground: false,
playbackState: $playbackState,
playbackTime: $playbackTime
)
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
playbackState: $playbackState,
playbackTime: $playbackTime
)
.foregroundColor(.secondary)
.frame(width: 50, alignment: .leading)
}
.padding(.top, 6)
.padding(.leading, 6)
.padding(.trailing, 12)
.padding(.bottom, chatItem.content.text.isEmpty ? 10 : 0)
}
}
struct FramedCIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)
)
let voiceMessageWithQuote: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv),
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage)
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote)
.environmentObject(ChatModel())
}
.previewLayout(.fixed(width: 360, height: 360))
}
}

View File

@@ -67,15 +67,23 @@ struct FramedItemView: View {
} else {
ciMsgContentView (chatItem, showMember)
}
case let .file(text):
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
case let .voice(text, duration):
FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration)
.overlay(DetermineWidth())
if text != "" {
ciMsgContentView (chatItem, showMember)
}
case let .file(text):
ciFileView(chatItem, text)
case let .link(_, preview):
CILinkView(linkPreview: preview)
ciMsgContentView (chatItem, showMember)
case let .unknown(_, text: text):
if chatItem.file == nil {
ciMsgContentView (chatItem, showMember)
} else {
ciFileView(chatItem, text)
}
default:
ciMsgContentView (chatItem, showMember)
}
@@ -110,27 +118,29 @@ struct FramedItemView: View {
@ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View {
let v = ZStack(alignment: .topTrailing) {
if case let .image(_, image) = qi.content,
let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 68, height: 68)
.clipped()
} else if case .file = qi.content {
switch (qi.content) {
case let .image(_, image):
if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 68, height: 68)
.clipped()
} else {
ciQuotedMsgView(qi)
}
case .file:
ciQuotedMsgView(qi)
.padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
Image(systemName: "doc.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18, height: 18)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.padding(.top, 6)
.padding(.trailing, 4)
} else {
ciQuoteIconView("doc.fill")
case .voice:
ciQuotedMsgView(qi)
.padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
ciQuoteIconView("mic.fill")
default:
ciQuotedMsgView(qi)
}
}
@@ -161,6 +171,16 @@ struct FramedItemView: View {
.padding(.horizontal, 12)
}
private func ciQuoteIconView(_ image: String) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: 18, height: 18)
.padding(.top, 6)
.padding(.trailing, 6)
}
private func membership() -> GroupMember? {
switch chatInfo {
case let .group(groupInfo: groupInfo): return groupInfo.membership
@@ -191,6 +211,14 @@ struct FramedItemView: View {
v
}
}
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
.overlay(DetermineWidth())
if text != "" {
ciMsgContentView (chatItem, showMember)
}
}
}
func isRightToLeft(_ s: String) -> Bool {

View File

@@ -10,7 +10,6 @@ import SwiftUI
import SimpleXChat
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private let linkColor = Color(uiColor: uiLinkColor)
struct MsgContentView: View {
var text: String
@@ -70,6 +69,14 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
case .uri: return linkText(t, t, preview, prefix: "")
case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts):
switch privacySimplexLinkModeDefault.get() {
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
case .full: return linkText(t, simplexUri, preview, prefix: "")
case .browser: return trustedUri
? linkText(t, t, preview, prefix: "")
: linkText(t, t, preview, prefix: "", color: .red, uiColor: .red)
}
case .email: return linkText(t, t, preview, prefix: "mailto:")
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
}
@@ -78,16 +85,19 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
}
}
private func linkText(_ s: String, _ link: String,
_ preview: Bool, prefix: String) -> Text {
private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text {
preview
? Text(s).foregroundColor(linkColor).underline(color: linkColor)
? Text(s).foregroundColor(color).underline(color: color)
: Text(AttributedString(s, attributes: AttributeContainer([
.link: NSURL(string: prefix + link) as Any,
.foregroundColor: uiLinkColor as Any
.foregroundColor: uiColor as Any
]))).underline()
}
private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
}
struct MsgContentView_Previews: PreviewProvider {
static var previews: some View {
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")

View File

@@ -31,12 +31,20 @@ struct ChatItemView: View {
case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView()
case .sndConnEvent: eventItemView()
case let .rcvChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
case let .rcvGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
case let .sndGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
}
}
@ViewBuilder private func contentItemView() -> some View {
if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) {
EmojiItemView(chatItem: chatItem)
} else if chatItem.quotedItem == nil && chatItem.content.text.isEmpty,
case let .voice(_, duration) = chatItem.content.msgContent {
CIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration)
} else {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
}
@@ -57,6 +65,10 @@ struct ChatItemView: View {
private func eventItemView() -> some View {
CIEventView(chatItem: chatItem)
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
}
}
struct ChatItemView_Previews: PreviewProvider {

View File

@@ -103,7 +103,7 @@ struct ChatView: View {
} label: {
ChatInfoToolbar(chat: chat)
}
.sheet(isPresented: $showChatInfoSheet, onDismiss: {
.appSheet(isPresented: $showChatInfoSheet, onDismiss: {
connectionStats = nil
customUserProfile = nil
}) {
@@ -121,7 +121,7 @@ struct ChatView: View {
} label: {
ChatInfoToolbar(chat: chat)
}
.sheet(isPresented: $showChatInfoSheet) {
.appSheet(isPresented: $showChatInfoSheet) {
GroupChatInfoView(chat: chat, groupInfo: groupInfo)
}
}
@@ -152,7 +152,7 @@ struct ChatView: View {
.onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) }
} else {
addMembersButton()
.sheet(isPresented: $showAddMembersSheet) {
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
}
@@ -392,8 +392,11 @@ struct ChatView: View {
await MainActor.run { selectedMember = member }
}
}
.sheet(item: $selectedMember, onDismiss: { memberConnectionStats = nil }) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: $memberConnectionStats)
.appSheet(item: $selectedMember, onDismiss: {
selectedMember = nil
memberConnectionStats = nil
}) { _ in
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $memberConnectionStats)
}
} else {
Rectangle().fill(.clear)
@@ -411,14 +414,14 @@ struct ChatView: View {
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
var menu: [UIAction] = []
if ci.isMsgContent() {
if let mc = ci.content.msgContent {
menu.append(contentsOf: [
UIAction(
title: NSLocalizedString("Reply", comment: "chat item action"),
image: UIImage(systemName: "arrowshape.turn.up.left")
) { _ in
withAnimation {
if composeState.editing() {
if composeState.editing {
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
} else {
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
@@ -448,18 +451,31 @@ struct ChatView: View {
}
}
])
if case .image = ci.content.msgContent,
let image = getLoadedImage(ci.file) {
menu.append(
UIAction(
title: NSLocalizedString("Save", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.down")
) { _ in
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
)
if let filePath = getLoadedFilePath(ci.file) {
if case .image = ci.content.msgContent,
let image = UIImage(contentsOfFile: filePath) {
menu.append(
UIAction(
title: NSLocalizedString("Save", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.down")
) { _ in
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
)
} else {
menu.append(
UIAction(
title: NSLocalizedString("Save", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.down")
) { _ in
let fileURL = URL(fileURLWithPath: filePath)
showShareSheet(items: [fileURL])
}
)
}
}
if ci.meta.editable {
if ci.meta.editable,
!mc.isVoice {
menu.append(
UIAction(
title: NSLocalizedString("Edit", comment: "chat item action"),
@@ -481,7 +497,7 @@ struct ChatView: View {
deletingItem = ci
}
)
} else if ci.isDeletedContent() {
} else if ci.isDeletedContent {
menu.append(
UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"),

View File

@@ -13,6 +13,7 @@ enum ComposePreview {
case noPreview
case linkPreview(linkPreview: LinkPreview?)
case imagePreviews(imagePreviews: [String])
case voicePreview(recordingFileName: String, duration: Int)
case filePreview(fileName: String)
}
@@ -22,10 +23,17 @@ enum ComposeContextItem {
case editingItem(chatItem: ChatItem)
}
enum VoiceMessageRecordingState {
case noRecording
case recording
case finished
}
struct ComposeState {
var message: String
var preview: ComposePreview
var contextItem: ComposeContextItem
var voiceMessageRecordingState: VoiceMessageRecordingState
var inProgress = false
var disabled = false
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
@@ -33,66 +41,91 @@ struct ComposeState {
init(
message: String = "",
preview: ComposePreview = .noPreview,
contextItem: ComposeContextItem = .noContextItem
contextItem: ComposeContextItem = .noContextItem,
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
) {
self.message = message
self.preview = preview
self.contextItem = contextItem
self.voiceMessageRecordingState = voiceMessageRecordingState
}
init(editingItem: ChatItem) {
self.message = editingItem.content.text
self.preview = chatItemPreview(chatItem: editingItem)
self.contextItem = .editingItem(chatItem: editingItem)
if let emc = editingItem.content.msgContent,
case .voice = emc {
self.voiceMessageRecordingState = .finished
} else {
self.voiceMessageRecordingState = .noRecording
}
}
func copy(
message: String? = nil,
preview: ComposePreview? = nil,
contextItem: ComposeContextItem? = nil
contextItem: ComposeContextItem? = nil,
voiceMessageRecordingState: VoiceMessageRecordingState? = nil
) -> ComposeState {
ComposeState(
message: message ?? self.message,
preview: preview ?? self.preview,
contextItem: contextItem ?? self.contextItem
contextItem: contextItem ?? self.contextItem,
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState
)
}
func editing() -> Bool {
var editing: Bool {
switch contextItem {
case .editingItem: return true
default: return false
}
}
func sendEnabled() -> Bool {
var sendEnabled: Bool {
switch preview {
case .imagePreviews:
return true
case .filePreview:
return true
default:
return !message.isEmpty
case .imagePreviews: return true
case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true
default: return !message.isEmpty
}
}
func linkPreviewAllowed() -> Bool {
var linkPreviewAllowed: Bool {
switch preview {
case .imagePreviews:
return false
case .filePreview:
return false
default:
return useLinkPreviews
case .imagePreviews: return false
case .voicePreview: return false
case .filePreview: return false
default: return useLinkPreviews
}
}
func linkPreview() -> LinkPreview? {
var linkPreview: LinkPreview? {
switch preview {
case let .linkPreview(linkPreview):
return linkPreview
default:
return nil
case let .linkPreview(linkPreview): return linkPreview
default: return nil
}
}
var voiceMessageRecordingFileName: String? {
switch preview {
case let .voicePreview(recordingFileName: recordingFileName, _): return recordingFileName
default: return nil
}
}
var noPreview: Bool {
switch preview {
case .noPreview: return true
default: return false
}
}
var voicePreview: Bool {
switch preview {
case .voicePreview: return true
default: return false
}
}
}
@@ -104,8 +137,10 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
chatItemPreview = .noPreview
case let .link(_, preview: preview):
chatItemPreview = .linkPreview(linkPreview: preview)
case let .image(_, image: image):
case let .image(_, image):
chatItemPreview = .imagePreviews(imagePreviews: [image])
case let .voice(_, duration):
chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
case .file:
chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "")
default:
@@ -116,7 +151,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
struct ComposeView: View {
@EnvironmentObject var chatModel: ChatModel
let chat: Chat
@ObservedObject var chat: Chat
@Binding var composeState: ComposeState
@FocusState.Binding var keyboardVisible: Bool
@@ -131,12 +166,17 @@ struct ComposeView: View {
@State var chosenImages: [UIImage] = []
@State private var showFileImporter = false
@State var chosenFile: URL? = nil
@State private var audioRecorder: AudioRecorder?
@State private var voiceMessageRecordingTime: TimeInterval?
@State private var startingRecording: Bool = false
var body: some View {
VStack(spacing: 0) {
contextItemView()
switch (composeState.editing(), composeState.preview) {
switch (composeState.editing, composeState.preview) {
case (true, .filePreview): EmptyView()
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
default: previewView()
}
HStack (alignment: .bottom) {
@@ -146,7 +186,7 @@ struct ComposeView: View {
Image(systemName: "paperclip")
.resizable()
}
.disabled(composeState.editing())
.disabled(composeState.editing || composeState.voiceMessageRecordingState != .noRecording)
.frame(width: 25, height: 25)
.padding(.bottom, 12)
.padding(.leading, 12)
@@ -156,6 +196,15 @@ struct ComposeView: View {
sendMessage()
resetLinkPreview()
},
voiceMessageAllowed: chat.chatInfo.voiceMessageAllowed,
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
startVoiceMessageRecording: {
Task {
await startVoiceMessageRecording()
}
},
finishVoiceMessageRecording: { finishVoiceMessageRecording() },
allowVoiceMessagesToContact: { allowVoiceMessagesToContact() },
keyboardVisible: $keyboardVisible
)
.padding(.trailing, 12)
@@ -163,7 +212,7 @@ struct ComposeView: View {
}
}
.onChange(of: composeState.message) { _ in
if composeState.linkPreviewAllowed() {
if composeState.linkPreviewAllowed {
if composeState.message.count > 0 {
showLinkPreview(composeState.message)
} else {
@@ -193,7 +242,7 @@ struct ComposeView: View {
CameraImageListPicker(images: $chosenImages)
}
}
.sheet(isPresented: $showImagePicker) {
.appSheet(isPresented: $showImagePicker) {
LibraryImageListPicker(images: $chosenImages, selectionLimit: 10) { itemsSelected in
showImagePicker = false
if itemsSelected {
@@ -235,11 +284,11 @@ struct ComposeView: View {
}
fileURL.stopAccessingSecurityScopedResource()
if let fileSize = fileSize,
fileSize <= maxFileSize {
fileSize <= MAX_FILE_SIZE {
chosenFile = fileURL
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent))
} else {
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
@@ -250,6 +299,21 @@ struct ComposeView: View {
}
}
}
.onDisappear {
audioRecorder?.stop()
if let fileName = composeState.voiceMessageRecordingFileName {
cancelVoiceMessageRecording(fileName)
}
}
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
if !startingRecording {
if composeState.voiceMessageRecordingState == .recording {
finishVoiceMessageRecording()
}
} else {
startingRecording = false
}
}
}
@ViewBuilder func previewView() -> some View {
@@ -265,7 +329,15 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview)
chosenImages = []
},
cancelEnabled: !composeState.editing())
cancelEnabled: !composeState.editing)
case let .voicePreview(recordingFileName, _):
ComposeVoiceView(
recordingFileName: recordingFileName,
recordingTime: $voiceMessageRecordingTime,
recordingState: $composeState.voiceMessageRecordingState,
cancelVoiceMessage: { cancelVoiceMessageRecording($0) },
cancelEnabled: !composeState.editing
)
case let .filePreview(fileName: fileName):
ComposeFileView(
fileName: fileName,
@@ -273,7 +345,7 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview)
chosenFile = nil
},
cancelEnabled: !composeState.editing())
cancelEnabled: !composeState.editing)
}
}
@@ -354,6 +426,8 @@ struct ComposeView: View {
if !sent {
await send(.text(composeState.message), quoted: quoted)
}
case let .voicePreview(recordingFileName, duration):
await send(.voice(text: composeState.message, duration: duration), quoted: quoted, file: recordingFileName)
case .filePreview:
if let fileURL = chosenFile,
let savedFile = saveFileFromURL(fileURL) {
@@ -386,6 +460,86 @@ struct ComposeView: View {
}
}
private func startVoiceMessageRecording() async {
startingRecording = true
chatModel.stopPreviousRecPlay.toggle()
let fileName = generateNewFileName("voice", "m4a")
audioRecorder = AudioRecorder(
onTimer: { voiceMessageRecordingTime = $0 },
onFinishRecording: {
updateComposeVMRFinished()
if let fileSize = fileSize(getAppFilePath(fileName)) {
logger.debug("onFinishRecording recording file size = \(fileSize)")
}
}
)
if let recStartError = await audioRecorder?.start(fileName: fileName) {
switch recStartError {
case .permission:
AlertManager.shared.showAlertMsg(
title: "No permission to record voice message",
message: "To record voice message please grant permission to use Microphone."
)
case let .error(error):
AlertManager.shared.showAlertMsg(
title: "Unable to record voice message",
message: "Error: \(error)"
)
}
} else {
composeState = composeState.copy(
preview: .voicePreview(recordingFileName: fileName, duration: 0),
voiceMessageRecordingState: .recording
)
}
}
private func finishVoiceMessageRecording() {
audioRecorder?.stop()
audioRecorder = nil
updateComposeVMRFinished()
if let fileName = composeState.voiceMessageRecordingFileName,
let fileSize = fileSize(getAppFilePath(fileName)) {
logger.debug("finishVoiceMessageRecording recording file size = \(fileSize)")
}
}
private func allowVoiceMessagesToContact() {
if case let .direct(contact) = chat.chatInfo {
Task {
do {
var prefs = contactUserPreferencesToPreferences(contact.mergedPreferences)
prefs.voice = Preference(allow: .yes)
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
await MainActor.run {
chatModel.updateContact(toContact)
}
}
} catch {
logger.error("ComposeView allowVoiceMessagesToContact, apiSetContactPrefs error: \(responseError(error))")
}
}
}
}
// ? maybe we shouldn't have duration in ComposePreview.voicePreview
private func updateComposeVMRFinished() {
var preview = composeState.preview
if let recordingFileName = composeState.voiceMessageRecordingFileName,
let recordingTime = voiceMessageRecordingTime {
preview = .voicePreview(recordingFileName: recordingFileName, duration: Int(recordingTime.rounded()))
}
composeState = composeState.copy(
preview: preview,
voiceMessageRecordingState: .finished
)
}
private func cancelVoiceMessageRecording(_ fileName: String) {
removeFile(fileName)
clearState()
}
private func clearState() {
composeState = ComposeState()
linkUrl = nil
@@ -394,6 +548,9 @@ struct ComposeView: View {
cancelledLinks = []
chosenImages = []
chosenFile = nil
audioRecorder?.stop()
audioRecorder = nil
voiceMessageRecordingTime = nil
}
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
@@ -404,6 +561,8 @@ struct ComposeView: View {
return checkLinkPreview()
case .image(_, let image):
return .image(text: composeState.message, image: image)
case .voice(_, let duration):
return .voice(text: composeState.message, duration: duration)
case .file:
return .file(composeState.message)
case .unknown(let type, _):
@@ -415,7 +574,7 @@ struct ComposeView: View {
prevLinkUrl = linkUrl
linkUrl = parseMessage(s)
if let url = linkUrl {
if url != composeState.linkPreview()?.uri && url != pendingLinkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
if prevLinkUrl == url {
loadLinkPreview(url)
@@ -444,7 +603,7 @@ struct ComposeView: View {
}
private func cancelLinkPreview() {
if let uri = composeState.linkPreview()?.uri.absoluteString {
if let uri = composeState.linkPreview?.uri.absoluteString {
cancelledLinks.insert(uri)
}
pendingLinkUrl = nil
@@ -499,11 +658,13 @@ struct ComposeView_Previews: PreviewProvider {
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.environmentObject(ChatModel())
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.environmentObject(ChatModel())
}
}
}

View File

@@ -0,0 +1,190 @@
//
// ComposeVoiceView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 21.11.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum VoiceMessagePlaybackState {
case noPlayback
case playing
case paused
}
func voiceMessageTime(_ time: TimeInterval) -> String {
let min = Int(time / 60)
let sec = Int(time.truncatingRemainder(dividingBy: 60))
return String(format: "%02d:%02d", min, sec)
}
func voiceMessageTime_(_ time: TimeInterval?) -> String {
return voiceMessageTime(time ?? TimeInterval(0))
}
struct ComposeVoiceView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
var recordingFileName: String
@Binding var recordingTime: TimeInterval?
@Binding var recordingState: VoiceMessageRecordingState
let cancelVoiceMessage: ((String) -> Void)
let cancelEnabled: Bool
@State private var audioPlayer: AudioPlayer?
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
@State private var playbackTime: TimeInterval?
@State private var startingPlayback: Bool = false
private static let previewHeight: CGFloat = 50
var body: some View {
ZStack {
if recordingState != .finished {
recordingMode()
} else {
playbackMode()
}
}
.padding(.vertical, 1)
.frame(height: ComposeVoiceView.previewHeight)
.background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(maxWidth: .infinity)
.padding(.top, 8)
.onDisappear {
audioPlayer?.stop()
}
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
if !startingPlayback {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} else {
startingPlayback = false
}
}
}
private func recordingMode() -> some View {
ZStack {
HStack(alignment: .center, spacing: 8) {
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
Text(voiceMessageTime_(recordingTime))
Spacer()
if cancelEnabled {
cancelButton()
}
}
.padding(.trailing, 12)
ProgressBar(length: MAX_VOICE_MESSAGE_LENGTH, progress: $recordingTime)
}
}
private func playbackMode() -> some View {
ZStack {
HStack(alignment: .center, spacing: 8) {
switch playbackState {
case .noPlayback:
Button {
startPlayback()
} label: {
playPauseIcon("play.fill")
}
Text(voiceMessageTime_(recordingTime))
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
} label: {
playPauseIcon("pause.fill")
}
Text(voiceMessageTime_(playbackTime))
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
} label: {
playPauseIcon("play.fill")
}
Text(voiceMessageTime_(playbackTime))
}
Spacer()
if cancelEnabled {
cancelButton()
}
}
.padding(.trailing, 12)
if let recordingLength = recordingTime {
ProgressBar(length: recordingLength, progress: $playbackTime)
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundColor(color)
.padding(.leading, 12)
}
private func cancelButton() -> some View {
Button {
audioPlayer?.stop()
cancelVoiceMessage(recordingFileName)
} label: {
Image(systemName: "multiply")
}
}
private struct ProgressBar: View {
var length: TimeInterval
@Binding var progress: TimeInterval?
var body: some View {
GeometryReader { geometry in
ZStack {
Rectangle()
.fill(Color.accentColor)
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
.animation(.linear, value: progress)
}
.frame(height: ComposeVoiceView.previewHeight - 1, alignment: .bottom) // minus 1 is for the bottom padding
}
}
}
private func startPlayback() {
startingPlayback = true
chatModel.stopPreviousRecPlay.toggle()
audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 },
onFinishPlayback: {
playbackState = .noPlayback
playbackTime = recordingTime // animate progress bar to the end
}
)
audioPlayer?.start(fileName: recordingFileName)
playbackTime = TimeInterval(0)
playbackState = .playing
}
}
struct ComposeVoiceView_Previews: PreviewProvider {
static var previews: some View {
ComposeVoiceView(
recordingFileName: "voice.m4a",
recordingTime: Binding.constant(TimeInterval(20)),
recordingState: Binding.constant(VoiceMessageRecordingState.recording),
cancelVoiceMessage: { _ in },
cancelEnabled: true
)
.environmentObject(ChatModel())
}
}

View File

@@ -16,6 +16,10 @@ struct ContextItemView: View {
let cancelContextItem: () -> Void
var body: some View {
let bgColor = contextItem.chatDir.sent
? (colorScheme == .light ? sentColorLight : sentColorDark)
: Color(uiColor: .tertiarySystemGroupedBackground)
HStack {
Image(systemName: contextIcon)
.resizable()
@@ -41,7 +45,7 @@ struct ContextItemView: View {
.padding(12)
.frame(minHeight: 50)
.frame(maxWidth: .infinity)
.background(chatItemFrameColor(contextItem, colorScheme))
.background(bgColor)
.padding(.top, 8)
}
}

View File

@@ -12,6 +12,13 @@ import SimpleXChat
struct SendMessageView: View {
@Binding var composeState: ComposeState
var sendMessage: () -> Void
var showVoiceMessageButton: Bool = true
var voiceMessageAllowed: Bool = true
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
var startVoiceMessageRecording: (() -> Void)? = nil
var finishVoiceMessageRecording: (() -> Void)? = nil
var allowVoiceMessagesToContact: (() -> Void)? = nil
@State private var holdingVMR = false
@Namespace var namespace
@FocusState.Binding var keyboardVisible: Bool
@State private var teHeight: CGFloat = 42
@@ -23,24 +30,34 @@ struct SendMessageView: View {
ZStack {
HStack(alignment: .bottom) {
ZStack(alignment: .leading) {
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
Text(composeState.message)
.lineLimit(10)
.font(teFont)
.multilineTextAlignment(alignment)
.foregroundColor(.clear)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.matchedGeometryEffect(id: "te", in: namespace)
.background(GeometryReader(content: updateHeight))
TextEditor(text: $composeState.message)
.focused($keyboardVisible)
.font(teFont)
.textInputAutocapitalization(.sentences)
.multilineTextAlignment(alignment)
.padding(.horizontal, 5)
.allowsTightening(false)
.frame(height: teHeight)
if case .voicePreview = composeState.preview {
Text("Voice message")
.font(teFont.italic())
.multilineTextAlignment(.leading)
.foregroundColor(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
} else {
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
Text(composeState.message)
.lineLimit(10)
.font(teFont)
.multilineTextAlignment(alignment)
.foregroundColor(.clear)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.matchedGeometryEffect(id: "te", in: namespace)
.background(GeometryReader(content: updateHeight))
TextEditor(text: $composeState.message)
.focused($keyboardVisible)
.font(teFont)
.textInputAutocapitalization(.sentences)
.multilineTextAlignment(alignment)
.padding(.horizontal, 5)
.allowsTightening(false)
.frame(height: teHeight)
}
}
if (composeState.inProgress) {
@@ -49,14 +66,27 @@ struct SendMessageView: View {
.frame(width: 31, height: 31, alignment: .center)
.padding([.bottom, .trailing], 3)
} else {
Button(action: { sendMessage() }) {
Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill")
.resizable()
.foregroundColor(.accentColor)
let vmrs = composeState.voiceMessageRecordingState
if showVoiceMessageButton,
composeState.message.isEmpty,
!composeState.editing,
(composeState.noPreview && vmrs == .noRecording)
|| (vmrs == .recording && holdingVMR) {
if voiceMessageAllowed {
RecordVoiceMessageButton(
startVoiceMessageRecording: startVoiceMessageRecording,
finishVoiceMessageRecording: finishVoiceMessageRecording,
holdingVMR: $holdingVMR,
disabled: composeState.disabled
)
} else {
voiceMessageNotAllowedButton()
}
} else if vmrs == .recording && !holdingVMR {
finishVoiceMessageRecordingButton()
} else {
sendMessageButton()
}
.disabled(!composeState.sendEnabled() || composeState.disabled)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
}
@@ -67,14 +97,109 @@ struct SendMessageView: View {
.padding(.vertical, 8)
}
func updateHeight(_ g: GeometryProxy) -> Color {
private func sendMessageButton() -> some View {
Button(action: { sendMessage() }) {
Image(systemName: composeState.editing ? "checkmark.circle.fill" : "arrow.up.circle.fill")
.resizable()
.foregroundColor(.accentColor)
}
.disabled(
!composeState.sendEnabled ||
composeState.disabled ||
(!voiceMessageAllowed && composeState.voicePreview)
)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
private struct RecordVoiceMessageButton: View {
var startVoiceMessageRecording: (() -> Void)?
var finishVoiceMessageRecording: (() -> Void)?
@Binding var holdingVMR: Bool
var disabled: Bool
@State private var pressed: TimeInterval? = nil
var body: some View {
Button(action: {}) {
Image(systemName: "mic.fill")
.foregroundColor(.accentColor)
}
.disabled(disabled)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
._onButtonGesture { down in
if down {
holdingVMR = true
pressed = ProcessInfo.processInfo.systemUptime
startVoiceMessageRecording?()
} else {
let now = ProcessInfo.processInfo.systemUptime
if let pressed = pressed,
now - pressed >= 1 {
finishVoiceMessageRecording?()
}
holdingVMR = false
pressed = nil
}
} perform: {}
}
}
private func voiceMessageNotAllowedButton() -> some View {
Button(action: {
switch showEnableVoiceMessagesAlert {
case .userEnable:
AlertManager.shared.showAlert(Alert(
title: Text("Allow voice messages?"),
message: Text("You need to allow your contact to send voice messages to be able to send them."),
primaryButton: .default(Text("Allow")) {
allowVoiceMessagesToContact?()
},
secondaryButton: .cancel()
))
case .askContact:
AlertManager.shared.showAlertMsg(
title: "Voice messages prohibited!",
message: "Please ask your contact to enable sending voice messages."
)
case .groupOwnerCan:
AlertManager.shared.showAlertMsg(
title: "Voice messages prohibited!",
message: "Only group owners can enable voice messages."
)
case .other:
AlertManager.shared.showAlertMsg(
title: "Voice messages prohibited!",
message: "Please check yours and your contact preferences."
)
}
}) {
Image(systemName: "mic")
.foregroundColor(.secondary)
}
.disabled(composeState.disabled)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
private func finishVoiceMessageRecordingButton() -> some View {
Button(action: { finishVoiceMessageRecording?() }) {
Image(systemName: "stop.fill")
.foregroundColor(.accentColor)
}
.disabled(composeState.disabled)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
private func updateHeight(_ g: GeometryProxy) -> Color {
DispatchQueue.main.async {
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
teFont = isShortEmoji(composeState.message)
? composeState.message.count < 4
? largeEmojiFont
: mediumEmojiFont
: .body
? largeEmojiFont
: mediumEmojiFont
: .body
}
return Color.clear
}

View File

@@ -0,0 +1,86 @@
//
// ContactPreferencesView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 13/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContactPreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var contact: Contact
@State var featuresAllowed: ContactFeaturesAllowed
@State var currentFeaturesAllowed: ContactFeaturesAllowed
var body: some View {
let user: User = chatModel.currentUser!
VStack {
List {
// featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
Section {
Button("Reset") { featuresAllowed = currentFeaturesAllowed }
Button("Save (and notify contact)") { savePreferences() }
}
.disabled(currentFeaturesAllowed == featuresAllowed)
}
}
}
private func featureSection(_ feature: Feature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
let enabled = FeatureEnabled.enabled(
user: Preference(allow: allowFeature.wrappedValue.allowed),
contact: pref.contactPreference
)
return Section {
Picker("You allow", selection: allowFeature) {
ForEach(ContactFeatureAllowed.values(userDefault)) { allow in
Text(allow.text)
}
}
.frame(height: 36)
infoRow("Contact allows", pref.contactPreference.allow.text)
} header: {
HStack {
Image(systemName: "\(feature.icon).fill")
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
Text(feature.text)
}
} footer: {
Text(feature.enabledDescription(enabled))
.frame(height: 36, alignment: .topLeading)
}
}
private func savePreferences() {
Task {
do {
let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
await MainActor.run {
contact = toContact
chatModel.updateContact(toContact)
currentFeaturesAllowed = featuresAllowed
}
}
} catch {
logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
}
}
}
}
struct ContactPreferencesView_Previews: PreviewProvider {
static var previews: some View {
ContactPreferencesView(
contact: Binding.constant(Contact.sampleData),
featuresAllowed: ContactFeaturesAllowed.sampleData,
currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
)
}
}

View File

@@ -13,13 +13,12 @@ struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
var groupInfo: GroupInfo
@State var groupInfo: GroupInfo
@ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String?
@State private var showAddMembersSheet: Bool = false
@State private var selectedMember: GroupMember? = nil
@State private var showGroupProfile: Bool = false
@State private var connectionStats: ConnectionStats?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -42,6 +41,17 @@ struct GroupChatInfoView: View {
groupInfoHeader()
.listRowBackground(Color.clear)
Section {
if groupInfo.canEdit {
editGroupButton()
}
groupPreferencesButton()
} header: {
Text("")
} footer: {
Text("Only group owners can change group preferences.")
}
Section("\(members.count + 1) members") {
if groupInfo.canAddMembers {
groupLinkButton()
@@ -68,20 +78,17 @@ struct GroupChatInfoView: View {
} label: { memberView(member) }
}
}
.sheet(isPresented: $showAddMembersSheet) {
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
.sheet(item: $selectedMember, onDismiss: { connectionStats = nil }) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: $connectionStats)
}
.sheet(isPresented: $showGroupProfile) {
GroupProfileView(groupId: groupInfo.apiId, groupProfile: groupInfo.groupProfile)
.appSheet(item: $selectedMember, onDismiss: {
selectedMember = nil
connectionStats = nil
}) { _ in
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
}
Section {
if groupInfo.canEdit {
editGroupButton()
}
clearChatButton()
if groupInfo.canDelete {
deleteGroupButton()
@@ -186,16 +193,35 @@ struct GroupChatInfoView: View {
private func groupLinkButton() -> some View {
NavigationLink {
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitle("Group link")
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Group link", systemImage: "link")
.foregroundColor(.accentColor)
}
}
func groupPreferencesButton() -> some View {
NavigationLink {
GroupPreferencesView(
groupInfo: $groupInfo,
preferences: groupInfo.fullGroupPreferences,
currentPreferences: groupInfo.fullGroupPreferences
)
.navigationBarTitle("Group preferences")
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Group preferences", systemImage: "switch.2")
}
}
func editGroupButton() -> some View {
Button {
showGroupProfile = true
NavigationLink {
GroupProfileView(
groupInfo: $groupInfo,
groupProfile: groupInfo.groupProfile
)
.navigationBarTitle("Group profile")
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Edit group profile", systemImage: "pencil")
}

View File

@@ -16,7 +16,7 @@ struct GroupLinkView: View {
private enum GroupLinkAlert: Identifiable {
case deleteLink
case error(title: LocalizedStringKey, error: String = "")
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -29,10 +29,6 @@ struct GroupLinkView: View {
var body: some View {
ScrollView {
VStack (alignment: .leading) {
Text("Group link")
.font(.largeTitle)
.bold()
.padding(.bottom)
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
.padding(.bottom)
if let groupLink = groupLink {
@@ -59,7 +55,7 @@ struct GroupLinkView: View {
} catch let error {
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
let a = getErrorAlert(error, "Error creating group link")
alert = .error(title: a.title, error: "\(a.message)")
alert = .error(title: a.title, error: a.message)
}
}
} label: { Label("Create link", systemImage: "link.badge.plus") }
@@ -88,7 +84,7 @@ struct GroupLinkView: View {
}, secondaryButton: .cancel()
)
case let .error(title, error):
return Alert(title: Text(title), message: Text("\(error)"))
return Alert(title: Text(title), message: Text(error))
}
}
}

View File

@@ -13,22 +13,22 @@ struct GroupMemberInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var groupInfo: GroupInfo
@State var member: GroupMember
@Binding var member: GroupMember?
@Binding var connectionStats: ConnectionStats?
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum GroupMemberInfoViewAlert: Identifiable {
case removeMemberAlert
case changeMemberRoleAlert(role: GroupMemberRole)
case removeMemberAlert(mem: GroupMember)
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
case switchAddressAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String {
switch self {
case .removeMemberAlert: return "removeMemberAlert"
case let .changeMemberRoleAlert(role): return "changeMemberRoleAlert \(role.rawValue)"
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
case .switchAddressAlert: return "switchAddressAlert"
case let .error(title, _): return "error \(title)"
}
@@ -37,81 +37,75 @@ struct GroupMemberInfoView: View {
var body: some View {
NavigationView {
List {
groupMemberInfoHeader()
.listRowBackground(Color.clear)
if let member = member {
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
if let contactId = member.memberContactId {
Section {
openDirectChatButton(contactId)
if let contactId = member.memberContactId {
Section {
openDirectChatButton(contactId)
}
}
}
Section("Member") {
infoRow("Group", groupInfo.displayName)
Section("Member") {
infoRow("Group", groupInfo.displayName)
HStack {
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
.foregroundStyle(.secondary)
}
}
} else {
Text("Role")
Spacer()
Text(member.memberRole.text)
.foregroundStyle(.secondary)
infoRow("Role", member.memberRole.text)
}
}
.onAppear { newRole = member.memberRole }
.onChange(of: newRole) { _ in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(role: newRole)
// TODO invited by - need to get contact by contact id
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
}
// TODO invited by - need to get contact by contact id
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
}
Section("Servers") {
// TODO network connection status
if developerTools {
Button("Change receiving address (BETA)") {
Section("Servers") {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
}
if let connStats = connectionStats {
smpServers("Receiving via", connStats.rcvServers)
smpServers("Sending via", connStats.sndServers)
}
}
if let connStats = connectionStats {
smpServers("Receiving via", connStats.rcvServers)
smpServers("Sending via", connStats.sndServers)
if member.canBeRemoved(groupInfo: groupInfo) {
Section {
removeMemberButton(member)
}
}
if developerTools {
Section("For console") {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
}
}
}
if member.canBeRemoved(groupInfo: groupInfo) {
Section {
removeMemberButton()
}
}
if developerTools {
Section("For console") {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
.navigationBarHidden(true)
.onAppear { newRole = member.memberRole }
.onChange(of: newRole) { _ in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole)
}
}
}
.navigationBarHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in
switch(alertItem) {
case .removeMemberAlert: return removeMemberAlert()
case .changeMemberRoleAlert: return changeMemberRoleAlert()
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
@@ -135,25 +129,27 @@ struct GroupMemberInfoView: View {
}
if let chat = chat {
dismissAllSheets(animated: true)
chatModel.chatId = chat.id
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
private func groupMemberInfoHeader() -> some View {
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
VStack {
ProfileImage(imageStr: member.image, color: Color(uiColor: .tertiarySystemFill))
ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
.frame(width: 192, height: 192)
.padding(.top, 12)
.padding()
Text(member.displayName)
Text(mem.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
if member.fullName != "" && member.fullName != member.displayName {
Text(member.fullName)
if mem.fullName != "" && mem.fullName != mem.displayName {
Text(mem.fullName)
.font(.title2)
.lineLimit(2)
}
@@ -161,25 +157,25 @@ struct GroupMemberInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
func removeMemberButton() -> some View {
func removeMemberButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) {
alert = .removeMemberAlert
alert = .removeMemberAlert(mem: mem)
} label: {
Label("Remove member", systemImage: "trash")
.foregroundColor(Color.red)
}
}
private func removeMemberAlert() -> Alert {
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
Alert(
title: Text("Remove member?"),
message: Text("Member will be removed from group - this cannot be undone!"),
primaryButton: .destructive(Text("Remove")) {
Task {
do {
let member = try await apiRemoveMember(groupInfo.groupId, member.groupMemberId)
let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
await MainActor.run {
_ = chatModel.upsertGroupMember(groupInfo, member)
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
dismiss()
}
} catch let error {
@@ -193,20 +189,21 @@ struct GroupMemberInfoView: View {
)
}
private func changeMemberRoleAlert() -> Alert {
private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert {
Alert(
title: Text("Change member role?"),
message: member.memberCurrent ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.") : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation."),
message: mem.memberCurrent ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.") : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation."),
primaryButton: .default(Text("Change")) {
Task {
do {
let mem = try await apiMemberRole(groupInfo.groupId, member.groupMemberId, newRole)
let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
await MainActor.run {
member = mem
_ = chatModel.upsertGroupMember(groupInfo, mem)
member = updatedMember
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
}
} catch let error {
newRole = member.memberRole
newRole = mem.memberRole
logger.error("apiMemberRole error: \(responseError(error))")
let a = getErrorAlert(error, "Error changing role")
alert = .error(title: a.title, error: a.message)
@@ -214,7 +211,7 @@ struct GroupMemberInfoView: View {
}
},
secondaryButton: .cancel {
newRole = member.memberRole
newRole = mem.memberRole
}
)
}
@@ -222,7 +219,9 @@ struct GroupMemberInfoView: View {
private func switchMemberAddress() {
Task {
do {
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
if let member = member {
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
}
} catch let error {
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
let a = getErrorAlert(error, "Error changing address")
@@ -238,7 +237,7 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupMemberInfoView(
groupInfo: GroupInfo.sampleData,
member: GroupMember.sampleData,
member: Binding.constant(GroupMember.sampleData),
connectionStats: Binding.constant(nil)
)
}

View File

@@ -0,0 +1,84 @@
//
// GroupPreferencesView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 16.11.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct GroupPreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var groupInfo: GroupInfo
@State var preferences: FullGroupPreferences
@State var currentPreferences: FullGroupPreferences
var body: some View {
VStack {
List {
// featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.voice, $preferences.voice.enable)
if groupInfo.canEdit {
Section {
Button("Reset") { preferences = currentPreferences }
Button("Save (and notify group members)") { savePreferences() }
}
.disabled(currentPreferences == preferences)
}
}
}
}
private func featureSection(_ feature: Feature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
Section {
if (groupInfo.canEdit) {
settingsRow(feature.icon) {
Picker(feature.text, selection: enableFeature) {
ForEach(GroupFeatureEnabled.values) { enable in
Text(enable.text)
}
}
.frame(height: 36)
}
}
else {
settingsRow(feature.icon) {
infoRow(feature.text, enableFeature.wrappedValue.text)
}
}
} footer: {
Text(feature.enableGroupPrefDescription(enableFeature.wrappedValue, groupInfo.canEdit))
.frame(height: 36, alignment: .topLeading)
}
}
private func savePreferences() {
Task {
do {
var gp = groupInfo.groupProfile
gp.groupPreferences = toGroupPreferences(preferences)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
await MainActor.run {
groupInfo = gInfo
chatModel.updateGroup(gInfo)
currentPreferences = preferences
}
} catch {
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
}
}
}
}
struct GroupPreferencesView_Previews: PreviewProvider {
static var previews: some View {
GroupPreferencesView(
groupInfo: Binding.constant(GroupInfo.sampleData),
preferences: FullGroupPreferences.sampleData,
currentPreferences: FullGroupPreferences.sampleData
)
}
}

View File

@@ -12,7 +12,7 @@ import SimpleXChat
struct GroupProfileView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var groupId: Int64
@Binding var groupInfo: GroupInfo
@State var groupProfile: GroupProfile
@State private var showChooseSource = false
@State private var showImagePicker = false
@@ -82,7 +82,7 @@ struct GroupProfileView: View {
CameraImagePicker(image: $chosenImage)
}
}
.sheet(isPresented: $showImagePicker) {
.appSheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}
@@ -120,8 +120,9 @@ struct GroupProfileView: View {
func saveProfile() {
Task {
do {
let gInfo = try await apiUpdateGroup(groupId, groupProfile)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
await MainActor.run {
groupInfo = gInfo
chatModel.updateGroup(gInfo)
dismiss()
}
@@ -137,6 +138,6 @@ struct GroupProfileView: View {
struct GroupProfileView_Previews: PreviewProvider {
static var previews: some View {
GroupProfileView(groupId: 1, groupProfile: GroupProfile.sampleData)
GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
}
}

View File

@@ -66,7 +66,7 @@ struct ContactConnectionView: View {
Spacer()
}
.frame(maxHeight: .infinity)
.sheet(isPresented: $showContactConnectionInfo) {
.appSheet(isPresented: $showContactConnectionInfo) {
ContactConnectionInfo(contactConnection: contactConnection)
}
}

View File

@@ -17,7 +17,7 @@ enum DatabaseEncryptionAlert: Identifiable {
case changeDatabaseKey
case databaseEncrypted
case currentPassphraseError
case error(title: LocalizedStringKey, error: String = "")
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -155,7 +155,7 @@ struct DatabaseEncryptionView: View {
if case .chatCmdError(.errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
await operationEnded(.currentPassphraseError)
} else {
await operationEnded(.error(title: "Error encrypting database", error: responseError(error)))
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
}
}
}
@@ -232,7 +232,7 @@ struct DatabaseEncryptionView: View {
message: Text("Please enter correct current passphrase.")
)
case let .error(title, error):
return Alert(title: Text(title), message: Text("\(error)"))
return Alert(title: Text(title), message: Text(error))
}
}

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