Compare commits

..

285 Commits

Author SHA1 Message Date
JRoberts
6c4b92531f android: version 4.4-beta.3 (82) 2022-12-27 20:56:23 +04:00
JRoberts
46d6159da5 ios: version 4.4 beta (105) 2022-12-27 20:48:34 +04:00
JRoberts
aab6e1c52f ios, android: set ttl to 1 day when accepting timed messages w/t configured ttl (#1654) 2022-12-27 19:24:33 +04:00
Evgeny Poberezkin
c0a01318b5 mobile: French translations (#1655)
* mobile: items with feature offers (#1623)

* mobile: items with feature offers

* ios interactive contact/user preference change items

* android: interactive preference items

* Translated using Weblate (French)

Currently translated at 8.1% (68 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (784 of 784 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 10.4% (87 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 10.5% (88 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 14.2% (122 of 855 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 15.9% (137 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 18.2% (157 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 36.3% (312 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 54.0% (464 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 81.0% (695 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 82.1% (705 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 85.9% (756 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* import/export localizations

Co-authored-by: Ophiushi <ptlfr@pm.me>
2022-12-27 13:21:55 +00:00
Evgeny Poberezkin
13090ff6ed core: do not log TLS handshake errors by default, option to enable (#1652)
* core: do not log TLS handshake errors by default, option to enable

* update simplexmq
2022-12-27 12:05:13 +00:00
Evgeny Poberezkin
90a20cd52f mobile: change live message button to lightning bolt (#1653) 2022-12-27 09:18:20 +00:00
Evgeny Poberezkin
74245d3f2b core: agent stats (#1650) 2022-12-26 22:24:34 +00:00
JRoberts
e48452ccff android: show what is new in the latest version (#1651) 2022-12-26 21:46:56 +04:00
JRoberts
39370ba1ef ios: fix - restore Save button in voice message context menu (#1647) 2022-12-26 14:08:58 +00:00
Evgeny Poberezkin
a02cfb4f41 ios: show what is new in the latest version (#1644)
* ios: show what is new in the latest version

* add OK button to WhatsNew

* separate state for nav buttons
2022-12-26 14:08:01 +00:00
JRoberts
4370012b8a ios: fix navigation to member info view (#1648) 2022-12-26 13:45:02 +00:00
JRoberts
20c33aea72 android: show connect via url alert in chat list instead of notifications mode on fresh app (#1649) 2022-12-26 13:43:26 +00:00
Evgeny Poberezkin
c11a1aa0e6 ios: disallow editing text attributes 2022-12-25 13:58:07 +00:00
Evgeny Poberezkin
166b789f3c ios: v4.4 beta (104) 2022-12-25 09:48:25 +00:00
Evgeny Poberezkin
bbc26e272c v4.4-beta.2: android (81) 2022-12-24 21:59:55 +00:00
Evgeny Poberezkin
6c839f8075 android: fix voice recording in groups 2022-12-24 21:47:58 +00:00
Evgeny Poberezkin
be91f97c83 ios: disable screen protection by default 2022-12-24 15:41:31 +00:00
Evgeny Poberezkin
e085cb7350 4.4-beta.1: iOS 103, Android 80 2022-12-24 11:59:28 +00:00
Evgeny Poberezkin
12574bed96 ios: move image utils to app (#1642)
* ios: move image utils to app

* name in comments
2022-12-24 11:38:59 +00:00
Evgeny Poberezkin
2137893111 ios: change disappearing messages icon (#1641) 2022-12-24 09:48:27 +00:00
Evgeny Poberezkin
e552a28a4d ios: re-export French translations 2022-12-23 22:24:27 +00:00
Evgeny Poberezkin
b20031d875 ios: add French language (#1640) 2022-12-23 22:14:47 +00:00
Evgeny Poberezkin
558b3fa356 translations (#1639)
* Translated using Weblate (German)

Currently translated at 95.0% (783 of 824 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/

* Translated using Weblate (Russian)

Currently translated at 100.0% (824 of 824 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (French)

Currently translated at 100.0% (824 of 824 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/
2022-12-23 21:49:34 +00:00
Stanislav Dmitrenko
cb337cef10 ios: Download libs script (#1634) 2022-12-23 21:24:08 +00:00
Stanislav Dmitrenko
cd63f81292 ios: Animated images (GIF) support (#1636)
* ios: Animated images (GIF) support

* Moved from String path to UIImage param

* Aspect ratio

* Image frame

* gif image size

* refactor

* refactor

* fix fullscreen scroll animation

* rename UploadContent -> AnyImage

* refactor, allow using gifs in profiles

* rename back

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-23 21:22:12 +00:00
Evgeny Poberezkin
6205b03943 ios: localize ttl in disappearing messages, translations (#1638)
* ios: localize ttl in disappearing messages, translations

* more translation keys
2022-12-23 19:55:45 +00:00
Evgeny Poberezkin
82924ce8c6 translations (#1637)
* Translated using Weblate (German)

Currently translated at 93.5% (823 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (Russian)

Currently translated at 100.0% (880 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (German)

Currently translated at 96.2% (774 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/

* Translated using Weblate (Russian)

Currently translated at 100.0% (804 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (French)

Currently translated at 100.0% (880 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (880 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (804 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (804 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Russian)

Currently translated at 100.0% (880 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (804 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (880 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

Co-authored-by: J R <jr@simplex.chat>
Co-authored-by: Ophiushi <ptlfr@pm.me>
2022-12-23 18:55:14 +00:00
Evgeny Poberezkin
b1067c339c android: fix meta layout/reserved space (#1635) 2022-12-23 17:27:41 +00:00
Evgeny Poberezkin
0d6e4b48f6 translations (#1633)
* Translated using Weblate (French)

Currently translated at 97.6% (785 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 97.6% (785 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/
2022-12-23 14:50:23 +00:00
JRoberts
84d2c408ce core: optimize chat loading time - faster chat previews queries (item_status index for chat stats), fix live file transfers queries (#1630)
* core: optimize get chat previews queries (item_status index for chat stats)

* cleanup

* optimize chat loading time

* cleanup

* schema

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-23 18:37:02 +04:00
Evgeny Poberezkin
de434b730e weblate/translations (#1632)
* mobile: items with feature offers (#1623)

* mobile: items with feature offers

* ios interactive contact/user preference change items

* android: interactive preference items

* Translated using Weblate (French)

Currently translated at 8.1% (68 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (784 of 784 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 10.4% (87 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 10.5% (88 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 14.2% (122 of 855 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 15.9% (137 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 18.2% (157 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 36.3% (312 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 54.0% (464 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 81.0% (695 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 82.1% (705 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 85.9% (756 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* revert changes

Co-authored-by: Ophiushi <ptlfr@pm.me>
2022-12-23 14:11:12 +00:00
Evgeny Poberezkin
1251dbc4b0 ios: export localizations 2022-12-23 13:13:37 +00:00
sh
d115ad228b build-android: disable external repo (#1615) 2022-12-23 13:10:36 +00:00
Evgeny Poberezkin
28d6f62b74 mobile: better layout for feature/preference items (#1631)
* mobile: better layout for feature/preference items

* refactor
2022-12-23 13:10:00 +00:00
Evgeny Poberezkin
2b9238144b ios: fix double unread status (#1629) 2022-12-23 09:14:12 +00:00
Evgeny Poberezkin
a2e1b7ae0a v4.4: iOS build 102, android build 79 2022-12-23 08:33:57 +00:00
Evgeny Poberezkin
a00bb6d5ef android: fix enabling voice cancelling disappearing messages 2022-12-22 22:08:21 +00:00
Evgeny Poberezkin
da12b651e4 4.4.0 2022-12-22 21:31:07 +00:00
Evgeny Poberezkin
a936c14cf2 mobile: items with feature offers (#1627)
* mobile: items with feature offers

* ios interactive contact/user preference change items

* android: interactive preference items

* add missing view

* revert change
2022-12-22 21:01:29 +00:00
Stanislav Dmitrenko
e6aad24e5f android: Timed messages TTL logic in preferences (#1624)
* android: Timed messages TTL logic in preferences

* do not set ttl in global timed message prefs

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-22 15:39:24 +00:00
JRoberts
8dac96f415 core: wait chat started in deleteTimedItem threads (#1626) 2022-12-22 19:18:38 +04:00
Evgeny Poberezkin
aae0802ec8 core: chat items with offered feature (#1620)
* core: chat items with offered feature

* texts

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

* new preference items

* test

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-12-22 14:56:29 +00:00
JRoberts
74a20ef70c core: sort chat previews by chat ts (#1625) 2022-12-22 17:43:10 +04:00
Evgeny Poberezkin
a2a29628a7 ios: remove unused code (#1621) 2022-12-21 22:36:05 +00:00
Stanislav Dmitrenko
0b046315ac android: Disappearing messages (#1619)
* android: Disappearing messages

* remove unused func

* remove paren

* outlined timer in meta

* reserving space for meta takes into account ttl text

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-21 22:07:37 +00:00
Stanislav Dmitrenko
372d7ffaa9 ios: Better check for alpha channel existing (#1616)
* ios: Better check for PNG

* Renamed
2022-12-21 16:05:45 +00:00
JRoberts
ece928d57e core: update ttl in contact user preference on profile update, fix api, tests; fix global user preferences not being updated in controller state (#1617) 2022-12-21 19:54:44 +04:00
Evgeny Poberezkin
e1740a8be4 ios: disappearing messages (#1614)
* ios: disappearing messages

* show ttl in meta if different

* mark messages as disappearing when read

* previews
2022-12-21 12:59:45 +00:00
Evgeny Poberezkin
36eba01ef4 ios: fix padding in send button context menu (#1618) 2022-12-21 12:55:59 +00:00
JRoberts
9e045a44db Revert "core: confirm ttl change to ensure consistent setting (#1587)"
This reverts commit 34e08b2058.
2022-12-21 14:10:05 +04:00
Stanislav Dmitrenko
b7d42ef889 ios: Png images support with alpha (#1613)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-21 00:15:18 +00:00
Stanislav Dmitrenko
e55cd82ec3 android: Live messages (#1612)
* android: Live messages

* White color

* Spacer

* button sizes

* Do not show voice button in live mode

* Add text to the last image in a row

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-20 23:55:01 +00:00
JRoberts
34e08b2058 core: confirm ttl change to ensure consistent setting (#1587)
* core: confirm ttl change to ensure consistent setting

* wip

* confirm_pref_pending

* xInfo

* test api

* send confirmPrefProfile

* refactor

* don't return contact

* refactor profile update

* refactor further

* refactor further

* refactor xInfo

* refactor xInfo further

* refactor
2022-12-20 22:00:46 +04:00
Evgeny Poberezkin
5e9b7366cc core: refactor chat item updates (#1611)
* core: refactor chat item updates

* removed unused function

* refactor

* refactor
2022-12-20 12:58:15 +00:00
Evgeny Poberezkin
64fb1f0b85 core: do not start disappearing timer for live messages until they stop being live, and start it on item update instead, provided they are read (#1609)
* core: do not start disappearing timer for live messages until they stop being live, and start it on item update instead, provided they are read

* change delays in tests

* diffInSeconds
2022-12-20 10:17:29 +00:00
JRoberts
84e43c57f6 core: ttl in feature chat items, view responses (#1595)
* core: ttl in feature chat items, view responses

* fix tests

* fix test

* view

* refactor

* use prefChangedValue

* use groupPrefChangedValue

* use cupIntValue

* simplify types

* groupFeatureState

* groupPrefToText

* prefToText, view

* remove prefFeature

* rename intValue -> param

* int -> param

* timedTTLText

* remove pragma

* restore pragma

* simplify

* timedTTLText

* fix tests

* off, after

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-19 21:18:59 +04:00
Evgeny Poberezkin
ffa37b1684 terminal: use Ctrl-L to start/continue live message, as Alt-Enter is not always supported (#1607)
* terminal: use Ctrl-L to start/continue live message, as Alt-Enter is not always supported

* refactor
2022-12-19 13:05:54 +00:00
Evgeny Poberezkin
86271fe109 terminal: support live messages (#1597)
* terminal: toggle live message updates

* terminal: send live messages (#1599)

* terminal: send live messages

* show edited messages

* send and continue live message with Alt-Enter

* truncate live messages to full words

* remove comments

* refactor

* refactor to avoid clearing live message prompt and show it faster

* $

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

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

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-12-19 11:16:50 +00:00
JRoberts
5dab099b5c core: increase thread delays in timed messages tests 2022-12-19 11:21:51 +04:00
sh
199e61e5c6 docs/webrtc: add troubleshoot section (#1602) 2022-12-18 23:42:37 +00:00
Evgeny Poberezkin
76b4fd34c1 ios: fix live messages sending incomplete words, refactor (#1604) 2022-12-18 21:20:39 +00:00
Evgeny Poberezkin
b159496257 mobile: allow ending live message with an empty string (#1603) 2022-12-18 19:21:13 +00:00
Evgeny Poberezkin
c0fb29d5f7 ios: remove unused package from project (#1598) 2022-12-17 18:52:02 +00:00
Evgeny Poberezkin
4ab7e5e1c8 terminal: command to get last item ID (to reference it in the tests) (#1596)
* terminal: command to get last item ID (to reference it in the tests)

* lastItemId

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-12-17 19:33:58 +04:00
Evgeny Poberezkin
9e847c2e1f ios: live messages (#1569)
* ios: live messages

* remove comments

* remove conflict

* live message buttons and alert

* only send full words

* fix double sending

* typing indicator in live items

* add live parameter to API

* typing indication, pass live parameter to API

* refactor to support live messages with attachments

* disable attachments
2022-12-17 14:02:07 +00:00
Evgeny Poberezkin
d105e59655 core: set item that was live as 0, that was never live as NULL (Maybe Bool type) (#1594)
* core: set item that was live as 0, that was never live as NULL (Maybe Bool type)

* fix
2022-12-17 13:58:16 +00:00
JRoberts
f128ebac87 core: timed messages terminal api, tests (#1591) 2022-12-17 14:49:03 +04:00
Stanislav Dmitrenko
b4de9c266b ios: Ability to add stickers (#1593)
* ios: Ability to add stickers

* fix text alignment for correct input field height

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-17 09:01:49 +00:00
JRoberts
e410fc7736 core: fix mark read queries (#1592) 2022-12-16 14:32:37 +00:00
Evgeny Poberezkin
f5bd6eb4c3 core: do not mark live items as editied until it's no longer live (#1590)
* core: do not mark live items as editied

* Update src/Simplex/Chat/Store.hs

* mark item as edited when it stops being live
2022-12-16 12:31:35 +00:00
JRoberts
cee403c1ed core: simplify terminal mark messages read logic (#1589) 2022-12-16 15:56:16 +04:00
Evgeny Poberezkin
8786e2147a core, mobile: logic for enabling disappearing messages (#1588)
* core: logic for enabled for disappearing messages

* refactor

* update feature enabled in UI
2022-12-16 10:27:59 +00:00
Evgeny Poberezkin
6b8705e9f4 core: support for live messages (#1577) 2022-12-16 11:51:04 +04:00
Stanislav Dmitrenko
acfb98bd81 android: Optimized chats snapshotFlow (#1578)
* android: Optimized chats snapshotFlow

* Concurrency test

* Revert "Concurrency test"

This reverts commit 911dd0c2ef.

* Comment

* Better catch
2022-12-15 19:57:57 +00:00
Stanislav Dmitrenko
c240456b80 android: Progress indicator and group members loading (#1579)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-15 19:29:17 +00:00
Stanislav Dmitrenko
9e1641a154 android: Changed a path in script for downloading libs (#1580) 2022-12-15 19:25:49 +00:00
JRoberts
17cd3cdca4 core: make ttl optional in TimedMessagesPreference (#1583)
* core: make ttl Maybe in TimedMessagesPreference

* omitNothingFields
2022-12-15 18:11:08 +00:00
JRoberts
aa264690ab core: add ttl to XMsgUpdate (#1581) 2022-12-15 17:29:46 +04:00
JRoberts
0e837ae392 core: timed messages (#1561)
* docs: disappearing messages rfc

* change schema

* word

* wip

* wip

* todos

* todos

* remove cancel, refactor

* revert prefs

* CITimed

* schema

* time on send direct

* time on send group

* add ttl to msg container, refactor

* timed on receive

* time on read

* getTimedItems, fix tests

* mark read in terminal - view, input, output, fix tests

* refactor

* comment

* util

* insert atomically

* refactor

* use guards

* refactor startTimedItemThread

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-15 15:17:29 +04:00
Stanislav Dmitrenko
68525b4131 android: Show error instead of crashing after failed to parse chats (#1573)
* android: Show error instead of crashing after failed to parse chats

* Just for test

* update strings

* Revert "Just for test"

This reverts commit f9c9a20ab6.

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-15 10:56:20 +00:00
Stanislav Dmitrenko
8775db7c97 android: Create group link with one click (#1575)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-14 22:47:24 +00:00
Stanislav Dmitrenko
f266debd56 android: Verify connection security code (#1567)
* android: Verify connection security code

* Dividers

* Changes

* Padding

* Share connection code

* Share connection code

* Unused

* icon sizes

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-14 14:44:26 +00:00
Evgeny Poberezkin
044c7a8191 mobile: update types for timed messages preference (#1574) 2022-12-14 13:53:31 +00:00
Evgeny Poberezkin
677c6aeb2e core: types for timed and live messages (#1572)
* core: types for timed and live messages

* add protocol tests
2022-12-14 16:16:11 +04:00
Evgeny Poberezkin
7b8f5be821 core: type for group preference for timed messages (#1568)
* core: type for group preference for timed messages

* remove unused func
2022-12-14 12:30:24 +04:00
Evgeny Poberezkin
21765905a7 ios: create group link with one click (#1566)
* ios: create group link with one click

* line break

* move call
2022-12-13 17:15:45 +00:00
Evgeny Poberezkin
70a9c01477 translations (#1563)
* Translated using Weblate (Russian)

Currently translated at 100.0% (831 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (French)

Currently translated at 98.4% (772 of 784 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/
2022-12-13 14:53:06 +00:00
Evgeny Poberezkin
678dbec3e2 core: different types for chat preferences, to allow parameters (#1565) 2022-12-13 14:52:34 +00:00
Stanislav Dmitrenko
bd4c7dffbf android: Notification mode selection in onboarding stage (#1535)
* android: Notification mode selection in onboarding stage

* Change

* Different texts

* Disable service starting until on-boarding finishes

* refactor, change strings

* update layout

* update layout

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-12 19:27:28 +00:00
Stanislav Dmitrenko
1eb4030080 android: Fix crash on multiple selection of images (#1560)
* android: Fix crash on multiple selection of images

* Revert "android: Fix crash on multiple selection of images"

This reverts commit 11a6113b4f.

* Disable image selection when there are images already selected

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-12 17:27:14 +00:00
sh
bcc64442e9 nix: merge android and ios (#1551) 2022-12-12 15:44:49 +00:00
Stanislav Dmitrenko
1246b9e376 android: Chat auto-scrolling behaviour (#1556)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-12 15:34:26 +00:00
Evgeny Poberezkin
d6e9a87d58 ios: verify connection translations (#1558) 2022-12-12 12:50:15 +00:00
Evgeny Poberezkin
cddd3cd673 FR translations (#1559)
* Translated using Weblate (German)

Currently translated at 100.0% (822 of 822 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Added translation using Weblate (French)

* Added translation using Weblate (French)

* Translated using Weblate (French)

Currently translated at 0.0% (0 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 2.0% (16 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 2.0% (16 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 0.3% (3 of 822 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (773 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 0.9% (8 of 822 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (773 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Ophiushi <ptlfr@pm.me>
2022-12-12 12:33:50 +00:00
JRoberts
e00ef7c7da core: improve stability of file transfer handshake by using async agent commands (#1541) 2022-12-12 16:33:07 +04:00
Evgeny Poberezkin
1a201cfadf translations: corrections to DE, add FR (#1557)
* Translated using Weblate (German)

Currently translated at 100.0% (822 of 822 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Added translation using Weblate (French)

* Added translation using Weblate (French)

* Translated using Weblate (French)

Currently translated at 0.0% (0 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 2.0% (16 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 2.0% (16 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 0.3% (3 of 822 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (773 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Ophiushi <ptlfr@pm.me>
2022-12-12 11:49:08 +00:00
JRoberts
a4ecb41743 ios, android: show send direct message button only for active members (#1554) 2022-12-12 15:27:52 +04:00
Stanislav Dmitrenko
e347f5329c android: Different style for voice button when no permissions granted (#1555)
* android: Different style for voice button when no permissions granted

* linebreaks

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-12 10:25:49 +00:00
Stanislav Dmitrenko
741b3e8848 android: Voice messages refactoring (#1511)
* android: Voice messages refactoring

* Different way to block text field from editing while recording voice

* Limited voice record max duration

* Better end of recording when it reaches timeout

* New way of doing things

* Change

* Change

* Stop event refactor

* Stopped state

* Replaced some helpers

* Replaced calls in when()

* Comments

* Change

* Change

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

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-12 09:36:37 +00:00
Evgeny Poberezkin
7b4710d198 ios: verify connection security code (#1542)
* ios: verify connection security code

* verification in member sheet (still crashes)

* use navigation view for members list

* ios: show verified status in the lists

* update verification status in the list of members

* verified shield layout

* update icon, make add member navigation to right

* refactor chatPreviewTitle
2022-12-12 08:59:35 +00:00
sh
c77f6100c5 fdroid: fix metadata (#1550)
* fdroid: fix description

* add header

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-11 18:33:56 +00:00
Evgeny Poberezkin
138dc7fe8f 4.3.2: terminal, ios (101), android (78) 2022-12-11 15:51:23 +00:00
Evgeny Poberezkin
0535d84719 website: add F-Droid key hash to open SimpleX links in the app 2022-12-11 15:39:11 +00:00
Evgeny Poberezkin
f4447ffe89 readme: add BCH address for donations 2022-12-11 14:06:22 +00:00
Evgeny Poberezkin
146d5f99bc core: clear connection verification status (#1540) 2022-12-10 12:09:45 +00:00
Evgeny Poberezkin
73e5fff8f5 core: fix parser 2022-12-10 08:43:54 +00:00
Evgeny Poberezkin
33e7538172 core: group description (#1538)
* core: group description

* support multi-line welcome message

* fix
2022-12-10 08:27:32 +00:00
Stanislav Dmitrenko
49c9c501aa android: Fix for AddressAlreadyInUse exception (#1534)
* android: Fix for AddressAlreadyInUse exception

* simplify loop

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-09 22:15:50 +00:00
Evgeny Poberezkin
a177dc5a13 core: refactor parser (#1537)
* core: refactor parser

* fix
2022-12-09 21:50:01 +00:00
Evgeny Poberezkin
a4f207875f show /create link command when group is created (#1536) 2022-12-09 18:22:03 +00:00
JRoberts
bcca0998d5 core: optimize group deletion (#1529) 2022-12-09 20:01:31 +04:00
Evgeny Poberezkin
95cc9e1e55 core: verify connection (#1530)
* core: verify connection

* update commands

* api to get/set verification code/status

* add migration

* refactor

* change command / response names

* reset verified status if code from agent doesn't match
2022-12-09 15:26:43 +00:00
sh
ab5ae2d2cb build-android: add skip flag and update logic (#1525)
* build-android: add skip flag and update logic

* build-android: change equal
2022-12-08 08:55:37 +00:00
JRoberts
40a91a7273 android: version 4.3.1 (77) 2022-12-08 10:48:13 +04:00
JRoberts
1240b31df8 ios: version 4.3.1 (100) 2022-12-08 10:41:59 +04:00
Evgeny Poberezkin
ff14730738 mobile, core: fix voice message reception in groups (#1524) 2022-12-07 22:18:22 +00:00
JRoberts
0cba3a4bb3 4.3.1 2022-12-07 21:10:45 +04:00
JRoberts
208f8a3346 android: version 4.3.1 (76) 2022-12-07 21:09:52 +04:00
JRoberts
caa3efb9ed ios: version 4.3.1 (99) 2022-12-07 21:04:43 +04:00
JRoberts
4beb916754 ios: deleted item preview; android: refactor removeChatItem (#1523) 2022-12-07 20:46:38 +04:00
Stanislav Dmitrenko
c1ee04eed1 android: Cancel notification after message deletion (#1512)
* android: Cancel notification after message deletion

* Improve

* Temporary chat item

* Better

* Changes

* cInfo, cItem

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-12-07 19:49:17 +04:00
JRoberts
0ad3bc9993 android: show open direct chat button for direct contacts (#1521) 2022-12-07 19:07:31 +04:00
JRoberts
9893aa665a core: don't mark contacts as used on api get chat (#1522) 2022-12-07 19:05:32 +04:00
JRoberts
fda8836ab8 ios: show open direct chat button for direct contacts (#1518) 2022-12-07 17:30:15 +04:00
Evgeny Poberezkin
05fdd07409 website: add SHA256 of the key signing GitHub android APK to open links in the app 2022-12-07 10:27:31 +00:00
Evgeny Poberezkin
fb8f5facd0 terminal: only set contact/group as active for terminal input if it is not muted (#1514) 2022-12-07 09:58:01 +00:00
Stanislav Dmitrenko
8bdb784a14 android: Added rememberSaveable in pref screens (fix merge) (#1517) 2022-12-07 09:57:23 +00:00
Stanislav Dmitrenko
5d785aad2e android: Added rememberSaveable in pref screens (#1509) 2022-12-06 21:04:15 +00:00
Stanislav Dmitrenko
ce11d58a76 android: Saving prefs alert on exit with unsaved changes (#1508)
* android: Saving prefs alert on exit with unsaved changes

* DIfferent implementation for AlertDialog with long buttons

* Braces

* Change

* Alignment

* Rename

* small changes

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-06 20:48:15 +00:00
Evgeny Poberezkin
887b374bfc readme: add mastodon link 2022-12-06 19:53:08 +00:00
Evgeny Poberezkin
94dc967197 readme: update screenshots 2022-12-06 17:30:32 +00:00
JRoberts
4319a581ca core: more test cases checking deletion of unused contacts and incognito profiles (#1513) 2022-12-06 20:19:01 +04:00
JRoberts
fb05218558 core: delete unused contacts after deleting group (#1503) 2022-12-06 17:12:39 +04:00
Evgeny Poberezkin
edf2d02a0d blog: v4.3 release announcement (#1510)
* blog: v4.3 release announcement

* add images

* update image URIs

* update post

* typos

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

* correction

* website preview, readme update

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-12-06 12:53:14 +00:00
Evgeny Poberezkin
87ba429dfd Merge branch 'stable' 2022-12-05 20:47:16 +00:00
Evgeny Poberezkin
7af1a7cf76 docs: update f-droid store info (#1507) 2022-12-05 20:46:11 +00:00
sh
df619acdd4 build-android: update nix install (#1506) 2022-12-05 18:45:18 +00:00
Evgeny Poberezkin
503d0cd451 android: make backup disabled by default (#1505) 2022-12-05 15:05:56 +00:00
Stanislav Dmitrenko
1294a00ee7 android: Vibration pattern (#1504)
* android: Vibration pattern

* update pattern

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-05 14:56:37 +00:00
Stanislav Dmitrenko
0a8069ada2 android: Notification sound (#1468)
* android: fix full screen call notification (#1466)

* android: Closing call means canceling notification too

* show full screen call when screen is off OR locked

* make notification non-silent and set category

* remove call notification category

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

* android: Notification sound

* Log

* Ringtone channel

* rename call channel

* Non-hideable headsUp notification and reject button

* Removed LockScreenCallChannel

* call channel name

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-05 13:13:48 +00:00
Evgeny Poberezkin
c167f594b9 website: add .well-known folder to allow mobile apps process URLs 2022-12-05 11:28:22 +00:00
Evgeny Poberezkin
ce5124594d blog: permalink for v4.3 post (#1499) 2022-12-04 18:18:23 +00:00
Evgeny Poberezkin
5de96aa7c4 android: v4.3 (75) 2022-12-04 18:07:41 +00:00
Evgeny Poberezkin
cdbf8e2715 ios: v4.3 (98) 2022-12-04 17:36:41 +00:00
Evgeny Poberezkin
69b2f8f535 mobile: german translations (#1498) 2022-12-04 15:18:35 +00:00
Evgeny Poberezkin
ff17f89551 android: improve UX to create groups and UI of group preferences (#1496) 2022-12-04 15:16:41 +00:00
Evgeny Poberezkin
358712fa31 ios: translations (#1495) 2022-12-04 11:41:45 +00:00
Evgeny Poberezkin
75cad8a6bf ios: improve UX for contact/group preferences (#1494)
* ios: improve UX for contact/group preferences

* refactor
2022-12-04 11:30:51 +00:00
Evgeny Poberezkin
e5969e197a mobile: "delete for everyone" feature, translations (#1491) 2022-12-04 09:29:00 +00:00
Evgeny Poberezkin
a9ffe4e039 android: function to call api on background thread, use it for marking items read (#1493) 2022-12-04 08:36:19 +00:00
Stanislav Dmitrenko
bf2129c4ae android: Making full backup optional (#1477)
* android: Making full backup optional

* move to database settings

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-04 07:50:36 +00:00
Evgeny Poberezkin
04f10aede7 ios: fix screen protection in sheets, remove screen protection from settings and image pickers (#1492) 2022-12-03 21:42:12 +00:00
Evgeny Poberezkin
ffbff93374 ios: menu to hide revealed chat item (#1490) 2022-12-03 19:21:47 +00:00
JRoberts
f3630d934c android: marked deleted / reveal ui (#1488)
* android: marked deleted / reveal ui

* marked deleted, reveal

* fix ios

* different alerts
2022-12-03 18:21:32 +00:00
Evgeny Poberezkin
6f59df4e33 prohibit direct messages to group contacts unless group preferences allow them (#1476)
* prohibit direct messages to group contacts unless group preferences allow them

* tests

* refactor

* more test
2022-12-03 18:06:21 +00:00
Evgeny Poberezkin
e44e9a0940 mobile: broker error type (#1475)
* mobile: broker error type

* fix

* ios: update libraries

* change AgentErrorType to String
2022-12-03 18:05:32 +00:00
Evgeny Poberezkin
c43ba7bf23 ios: fix item deletion in groups (#1487) 2022-12-03 15:21:14 +00:00
JRoberts
9e48e1f74a android: refactor CIVoiceView usage in FramedItemView (latter accounts only for framed voice messages) (#1486) 2022-12-03 18:28:07 +04:00
JRoberts
0001885971 obsolete comment 2022-12-03 18:24:20 +04:00
Evgeny Poberezkin
e0c932c04e core: change AgentErrorType to String to preserve backward compatibility with stored errors (#1485) 2022-12-03 13:28:51 +00:00
JRoberts
01a86336c0 android: simplify logic for allowing voice messages on alert; fix non exhaustive when in SendMsgView (#1484) 2022-12-03 16:19:13 +04:00
JRoberts
48d24d3582 ios: simplify chat item context menu (#1483) 2022-12-03 15:53:46 +04:00
JRoberts
07ef6e4090 ios: marked deleted chat items, full deletion preference; android: types (#1473)
* ios: marked deleted chat items; full deletion preference

* text_, menu, backend

* android types

* more android types

* fix

* refactor ios

* restore previews

* box

* refactor menu

* revert unnecessary content.text changes

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

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

* revert layered framed items

* clever framed view

* improve look

* restore previews

* restore previews

* refactor

* refactoring, almost looks good

* look

* add previews

* more previews

* remove preview of legacy item

* ChatItemDeleted

* flip if

* remove text_

* refactor

* abstract pref property

* move marked deleted

* revert pref change

* undo menu

* fix - change to constants

* undo pref logic

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-03 15:40:31 +04:00
Evgeny Poberezkin
19163776e3 android: fix 2022-12-03 09:06:39 +00:00
Stanislav Dmitrenko
62b1f786f1 android: Remove runningAppProcesses check (#1478)
* android: Remove runningAppProcesses check

* simplify

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-03 06:29:52 +00:00
Stanislav Dmitrenko
d479e9b2bf android: Change of launchMode in an activity and different behavior of back button (#1480)
* android: Change of launchMode in an activity and different behavior of back button
- Android versions <= 10 are vulnerable to StrandHogg 1. This commit fixes the behavior of the app on affected versions of Android

* simplify condition

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-02 23:07:21 +00:00
Evgeny Poberezkin
0beb260b00 android: json parsing settings explicitNulls = false 2022-12-02 21:36:58 +00:00
Evgeny Poberezkin
bc28568c63 core: update broker error type (#1474)
* core: update broker error type

* fix test

* fix test
2022-12-02 15:01:26 +00:00
Stanislav Dmitrenko
a4dd520248 android: Better shared preference handling (#1471)
* android: Better shared preference handling

* To make sure we return real value, not untransformed one

* Revert "To make sure we return real value, not untransformed one"

This reverts commit 5a268e2cf4.
2022-11-30 22:20:08 +00:00
JRoberts
9ad29aa17e core: full deletion by sender based on preference; don't overwrite item content on "mark deleted" (#1470) 2022-11-30 19:42:33 +04:00
Stanislav Dmitrenko
6f24281671 android: prevent crash when decrypting DB after restore (#1469) 2022-11-30 12:20:49 +00:00
Evgeny Poberezkin
eb81b62892 terminal: allow trailing spaces in terminal commands (e.g., to drag and drop files) (#1467) 2022-11-30 08:25:42 +00:00
Stanislav Dmitrenko
ef1133ee98 android: fix full screen call notification (#1466)
* android: Closing call means canceling notification too

* show full screen call when screen is off OR locked

* make notification non-silent and set category

* remove call notification category

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-29 15:53:47 +00:00
Evgeny Poberezkin
1872744543 core, mobile: add group feature to allow direct messages (#1465)
* core, mobile: split group features to a separate type (to add directAllowed later)

* add directMessages group feature, update tests
2022-11-29 15:19:20 +00:00
Stanislav Dmitrenko
303aeaaba5 android: Instantly apply screen protection (#1464) 2022-11-29 14:21:41 +00:00
Stanislav Dmitrenko
c5359d698c android: Voice messages enhancements (#1451)
* android: Vocie messages enhancements

* Canceling voice record when it was disabled in prefs

* Quote placement in voice message chat item

* Ordering of checks

* Showing progress logic was changed

* Showing progress logic was changed

* Update group prefs without reenter

* Optimization of voice chat items

* Stop audio playing and recoring when in call
2022-11-29 13:41:04 +00:00
JRoberts
acd72fb269 ios: remove voice message preview and stop rec/play if preference change prohibits it; more robust logic to stop playback on send (#1463) 2022-11-29 15:23:54 +04:00
sh
8d096f469d docs: corrections (#1456)
* docs: corrections

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

* docs: onion instead of hostname2

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-29 09:17:26 +00:00
Stanislav Dmitrenko
b204d21d9e android: No crash when clicking on a link with unknown activity action (#1460)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-29 09:16:03 +00:00
JRoberts
c9620a594e ios: fix - stop voice message preview playback on send (#1461) 2022-11-29 13:06:45 +04:00
Stanislav Dmitrenko
538024de61 android: Show user's camera when system camera is unavailable (#1458)
* android: Show user's camera when system camera is unavailable

* Multiple places of camera launcher

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-29 08:50:54 +00:00
JRoberts
5c9a14fdb6 ios: show button for opening settings when asking for microphone permission to record voice message (#1459) 2022-11-29 12:41:48 +04:00
JRoberts
9295bdca3e ios, android: disable save and test buttons if all servers are disabled (#1457) 2022-11-29 12:28:26 +04:00
Evgeny Poberezkin
00466f4654 android: v4.3-beta.3 2022-11-28 18:05:25 +00:00
Evgeny Poberezkin
5d976d3c67 docs: SMP servers (#1387)
* docs: SMP servers

* docs: add server configuration

* docs: small fixes

* docs: fix markdown rendering

* docs: csv file instead of URL

* docs: small fixes

* docs: check if log exist

* docs: no i allowed

* docs: correction

* docs: no need to source profile

* docs: no calue allowed

* docs: systemd fix

* docs: small corrections

* docs: some more small fixes

* docs: expand monitoring

* docs: apply suggestions

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

* client configuration

* images

Co-authored-by: shum <shum@liber.li>
Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
2022-11-28 17:34:43 +00:00
JRoberts
7360bd098a ios: version 4.3 (97) 2022-11-28 20:46:06 +04:00
JRoberts
3a755286c1 ios, android: improve preference change chat items layout (#1454) 2022-11-28 20:03:39 +04:00
JRoberts
e5f07993a7 mobile: don't show notifications for certain chat items (#1453) 2022-11-28 19:11:26 +04:00
JRoberts
56a3f98dc0 core: create certain informational chat items as read (#1452) 2022-11-28 16:27:22 +04:00
JRoberts
9949ac073f ios: decrease voice message progress bar width (#1450)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-28 08:50:05 +00:00
Stanislav Dmitrenko
c102a884d1 android: Stickers fix (#1449)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-28 08:47:33 +00:00
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
271 changed files with 30943 additions and 5420 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

@@ -8,7 +8,7 @@ If you believe that some of the clauses in this document are not aligned with ou
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.html).
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

View File

@@ -5,8 +5,9 @@
[![build](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml/badge.svg?branch=stable)](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
[![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases)
[![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases)
[![Follow on Twitter](https://img.shields.io/twitter/follow/SimpleXChat?style=social)](https://twitter.com/SimpleXChat)
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat)
[![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@simplex)
[![Follow on Twitter](https://img.shields.io/twitter/follow/SimpleXChat?style=social)](https://twitter.com/SimpleXChat)
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
&nbsp;
@@ -42,6 +43,7 @@
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
- [For developers](#for-developers)
- [Roadmap](#roadmap)
- [Join a user group](#join-a-user-group)
- [Contribute](#contribute)
- [Help us with donations](#help-us-with-donations)
- [Disclaimers, Security contact, License](#disclaimers)
@@ -84,16 +86,14 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent updates:
[Dec 06, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration.](./blog/20221206-simplex-chat-v4.3-voice-messages.md)
[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)
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
[All updates](./blog)
## Make a private connection
@@ -102,7 +102,7 @@ You need to share a link or scan a QR code (in person or during a video call) to
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/conversation.png" alt="Make a private connection" width="594" height="360">
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
## :zap: Quick installation of a terminal app
@@ -186,20 +186,42 @@ If you are considering developing with SimpleX platform please get in touch for
- ✅ Chat database encryption.
- ✅ Automatic chat history deletion.
- ✅ Links to join groups and improve groups stability.
- 🏗 SMP queue redundancy and rotation.
- 🏗 Voice messages.
- Feeds/broadcasts.
- Disappearing messages, with mutual agreement.
- ✅ 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.
- ✅ Advanced server configuration.
- 🏗 SMP queue redundancy and rotation (manual is supported).
- 🏗 Contact verification via a separate out-of-band channel.
- 🏗 Ephemeral/disappearing/OTR conversations with the existing contacts.
- Optionally avoid re-using the same TCP session for multiple connections.
- Access password/pin (with optional alternative access password).
- 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.
- 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.
## Join a user group
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).
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).
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
@@ -224,8 +246,10 @@ It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- 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,
@@ -238,7 +262,7 @@ SimpleX Chat founder
[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.
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.html).
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.

View File

@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 68
versionName "4.2.1"
versionCode 82
versionName "4.4-beta.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {

View File

@@ -24,6 +24,8 @@
<application
android:name="SimplexApp"
android:allowBackup="true"
android:fullBackupOnly="true"
android:backupAgent="BackupAgent"
android:icon="@mipmap/icon"
android:label="${app_name}"
android:extractNativeLibs="${extract_native_libs}"
@@ -102,7 +104,9 @@
<activity android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true"/>
android:showOnLockScreen="true"
android:exported="false"
android:launchMode="singleTask"/>
<provider
android:name="androidx.core.content.FileProvider"

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

@@ -0,0 +1,18 @@
package chat.simplex.app
import android.app.backup.BackupAgentHelper
import android.app.backup.FullBackupDataOutput
import android.content.Context
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
class BackupAgent: BackupAgentHelper() {
override fun onFullBackup(data: FullBackupDataOutput?) {
if (applicationContext
.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
) {
super.onFullBackup(data)
}
}
}

View File

@@ -3,12 +3,15 @@ package chat.simplex.app
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.os.Build
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
@@ -19,7 +22,9 @@ 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
@@ -36,6 +41,8 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
class MainActivity: FragmentActivity() {
companion object {
@@ -67,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(
@@ -119,7 +133,15 @@ class MainActivity: FragmentActivity() {
}
override fun onBackPressed() {
super.onBackPressed()
if (
onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
|| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
|| isTaskRoot // there are still other tasks after we reach the main (home) activity
) {
// https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06
super.onBackPressed()
}
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
clearAuthState()
@@ -317,19 +339,62 @@ 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()
}
}
}
}
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
currentChatId?.let {
ChatView(it, chatModel, onComposed)
}
}
}
else ChatView(chatModel)
}
}
}
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
onboarding == OnboardingStage.Step3_SetNotificationsMode -> SetNotificationsMode(chatModel)
}
ModalManager.shared.showInView()
val invitation = chatModel.activeCallInvitation.value
@@ -413,7 +478,6 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
// TODO open from chat list view
chatModel.appOpenUrl.value = uri
} else {
withUriAction(uri) { linkType ->

View File

@@ -11,8 +11,7 @@ import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.*
import java.util.*
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
@@ -32,10 +31,13 @@ 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
var isAppOnForeground: Boolean = false
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
@@ -95,6 +97,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
withApi {
when (event) {
Lifecycle.Event.ON_START -> {
isAppOnForeground = true
if (chatModel.chatRunning.value == true) {
kotlin.runCatching {
val chats = chatController.apiGetChats()
@@ -103,6 +106,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
}
Lifecycle.Event.ON_RESUME -> {
isAppOnForeground = true
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
chatController.showBackgroundServiceNoticeIfNeeded()
}
@@ -111,10 +115,14 @@ class SimplexApp: Application(), LifecycleEventObserver {
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
* It can happen when app was started and a user enables battery optimization while app in background
* */
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
if (chatModel.chatRunning.value != false &&
chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
) {
SimplexService.start(applicationContext)
}
}
else -> {}
else -> isAppOnForeground = false
}
}
}
@@ -168,7 +176,18 @@ class SimplexApp: Application(), LifecycleEventObserver {
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d(TAG, "starting server")
val server = LocalServerSocket(socketName)
var server: LocalServerSocket? = null
for (i in 0..100) {
try {
server = LocalServerSocket(socketName + i)
break
} catch (e: IOException) {
Log.e(TAG, e.stackTraceToString())
}
}
if (server == null) {
throw Error("Unable to setup local server socket. Contact developers")
}
Log.d(TAG, "started server")
s.release()
val receiver = server.accept()

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

@@ -2,8 +2,13 @@ package chat.simplex.app.model
import android.net.Uri
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
@@ -20,7 +25,13 @@ import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
import java.io.File
import kotlin.time.*
/*
* 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 +50,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 +84,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) {
@@ -90,7 +107,7 @@ class ChatModel(val controller: ChatController) {
fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = !contact.isIndirectContact && !contact.viaGroupLink)
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed)
fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
@@ -204,6 +221,9 @@ class ChatModel(val controller: ChatController) {
}
fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
if (cItem.isRcvNew) {
decreaseCounterInChat(cInfo.id)
}
// update previews
val i = getChatIndex(cInfo.id)
val chat: Chat
@@ -211,13 +231,14 @@ class ChatModel(val controller: ChatController) {
chat = chats[i]
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
}
}
// remove from current chat
if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
AudioPlayer.stop(chatItems[itemIndex])
chatItems.removeAt(itemIndex)
}
}
@@ -261,7 +282,13 @@ class ChatModel(val controller: ChatController) {
while (i < chatItems.count()) {
val item = chatItems[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
chatItems[i] = item.withStatus(CIStatus.RcvRead())
val newItem = item.withStatus(CIStatus.RcvRead())
chatItems[i] = newItem
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
chatItems[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
)
}
markedRead++
}
i += 1
@@ -341,6 +368,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
@@ -354,6 +382,7 @@ data class User(
userContactId = 1,
localDisplayName = "alice",
profile = LocalProfile.sampleData,
fullPreferences = FullChatPreferences.sampleData,
activeUser = true
)
}
@@ -378,11 +407,14 @@ interface SomeChat {
val ready: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val incognito: Boolean
fun featureEnabled(feature: ChatFeature): Boolean
val timedMessagesTTL: Int?
val createdAt: Instant
val updatedAt: Instant
}
@Serializable
@Serializable @Stable
data class Chat (
val chatInfo: ChatInfo,
val chatItems: List<ChatItem>,
@@ -428,7 +460,6 @@ data class Chat (
@Serializable
sealed class ChatInfo: SomeChat, NamedChat {
abstract val incognito: Boolean
@Serializable @SerialName("direct")
data class Direct(val contact: Contact): ChatInfo() {
@@ -438,8 +469,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contact.apiId
override val ready get() = contact.ready
override val sendMsgEnabled get() = contact.sendMsgEnabled
override val ntfsEnabled get() = contact.chatSettings.enableNtfs
override val incognito get() = contact.contactConnIncognito
override val ntfsEnabled get() = contact.ntfsEnabled
override val incognito get() = contact.incognito
override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature)
override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL
override val createdAt get() = contact.createdAt
override val updatedAt get() = contact.updatedAt
override val displayName get() = contact.displayName
@@ -460,8 +493,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
override val ntfsEnabled get() = groupInfo.chatSettings.enableNtfs
override val incognito get() = groupInfo.membership.memberIncognito
override val ntfsEnabled get() = groupInfo.ntfsEnabled
override val incognito get() = groupInfo.incognito
override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature)
override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL
override val createdAt get() = groupInfo.createdAt
override val updatedAt get() = groupInfo.updatedAt
override val displayName get() = groupInfo.displayName
@@ -482,8 +517,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
override val ntfsEnabled get() = false
override val incognito get() = false
override val ntfsEnabled get() = contactRequest.ntfsEnabled
override val incognito get() = contactRequest.incognito
override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature)
override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL
override val createdAt get() = contactRequest.createdAt
override val updatedAt get() = contactRequest.updatedAt
override val displayName get() = contactRequest.displayName
@@ -504,8 +541,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
override val ntfsEnabled get() = false
override val ntfsEnabled get() = contactConnection.incognito
override val incognito get() = contactConnection.incognito
override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature)
override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL
override val createdAt get() = contactConnection.createdAt
override val updatedAt get() = contactConnection.updatedAt
override val displayName get() = contactConnection.displayName
@@ -527,9 +566,10 @@ data class Contact(
val profile: LocalProfile,
val activeConn: Connection,
val viaGroup: Long? = null,
val contactUsed: Boolean,
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 {
@@ -539,28 +579,47 @@ data class Contact(
override val ready get() = activeConn.connStatus == ConnStatus.Ready
override val sendMsgEnabled get() = true
override val ntfsEnabled get() = chatSettings.enableNtfs
override val incognito get() = contactConnIncognito
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
}
override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null }
override val displayName get() = localAlias.ifEmpty { profile.displayName }
override val fullName get() = profile.fullName
override val image get() = profile.image
override val localAlias get() = profile.localAlias
val verified get() = activeConn.connectionCode != null
val isIndirectContact: Boolean get() =
activeConn.connLevel > 0 || viaGroup != null
val viaGroupLink: Boolean get() =
activeConn.viaGroupLink
val directOrUsed: Boolean get() =
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
val contactConnIncognito =
activeConn.customUserProfileId != null
fun allowsFeature(feature: ChatFeature): Boolean = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
}
fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
}
companion object {
val sampleData = Contact(
contactId = 1,
localDisplayName = "alice",
profile = LocalProfile.sampleData,
activeConn = Connection.sampleData,
contactUsed = true,
chatSettings = ChatSettings(true),
userPreferences = ChatPreferences(),
userPreferences = ChatPreferences.sampleData,
mergedPreferences = ContactUserPreferences.sampleData,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
@@ -582,7 +641,14 @@ class ContactSubStatus(
)
@Serializable
class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val viaGroupLink: Boolean, val customUserProfileId: Long? = null) {
data class Connection(
val connId: Long,
val connStatus: ConnStatus,
val connLevel: Int,
val viaGroupLink: Boolean,
val customUserProfileId: Long? = null,
val connectionCode: SecurityCode? = null
) {
val id: ChatId get() = ":$connId"
companion object {
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null)
@@ -590,12 +656,14 @@ class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: In
}
@Serializable
class Profile(
data class SecurityCode(val securityCode: String, val verifiedAt: Instant)
@Serializable
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
@@ -603,7 +671,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(
@@ -620,18 +688,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 = ""
)
}
@@ -648,10 +716,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 {
@@ -661,6 +729,13 @@ data class GroupInfo (
override val ready get() = membership.memberActive
override val sendMsgEnabled get() = membership.memberActive
override val ntfsEnabled get() = chatSettings.enableNtfs
override val incognito get() = membership.memberIncognito
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
ChatFeature.Voice -> fullGroupPreferences.voice.on
}
override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
override val displayName get() = groupProfile.displayName
override val fullName get() = groupProfile.fullName
override val image get() = groupProfile.image
@@ -680,6 +755,7 @@ data class GroupInfo (
groupId = 1,
localDisplayName = "team",
groupProfile = GroupProfile.sampleData,
fullGroupPreferences = FullGroupPreferences.sampleData,
membership = GroupMember.sampleData,
hostConnCustomUserProfileId = null,
chatSettings = ChatSettings(true),
@@ -690,11 +766,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(
@@ -723,6 +800,7 @@ data class GroupMember (
val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName }
val fullName: String get() = memberProfile.fullName
val image: String? get() = memberProfile.image
val verified get() = activeConn?.connectionCode != null
val chatViewName: String
get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
@@ -902,6 +980,9 @@ class UserContactRequest (
override val ready get() = true
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = false
override fun featureEnabled(feature: ChatFeature) = false
override val timedMessagesTTL: Int? get() = null
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
override val image get() = profile.image
@@ -937,6 +1018,9 @@ class PendingContactConnection(
override val ready get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = customUserProfileId != null
override fun featureEnabled(feature: ChatFeature) = false
override val timedMessagesTTL: Int? get() = null
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
override val displayName: String get() {
if (localAlias.isNotEmpty()) return localAlias
@@ -956,8 +1040,6 @@ class PendingContactConnection(
val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
val incognito = customUserProfileId != null
val description: String get() {
val initiated = pccConnStatus.initiated
return if (initiated == null) "" else generalGetString(
@@ -1015,7 +1097,7 @@ class AChatItem (
val chatItem: ChatItem
)
@Serializable
@Serializable @Stable
data class ChatItem (
val chatDir: CIDirection,
val meta: CIMeta,
@@ -1027,13 +1109,16 @@ data class ChatItem (
val id: Long get() = meta.itemId
val timestampText: String get() = meta.timestampText
val text: String get() =
when {
val text: String get() {
val mc = content.msgContent
return when {
content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(R.string.voice_message_with_duration), durationText(mc.duration))
content.text == "" && file != null -> file.fileName
else -> content.text
}
}
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
val isRcvNew: Boolean get() = meta.isRcvNew
val memberDisplayName: String? get() =
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
@@ -1046,30 +1131,42 @@ data class ChatItem (
else -> false
}
val isCall: Boolean get() =
when (content) {
is CIContent.SndCall -> true
is CIContent.RcvCall -> true
else -> false
}
private val showNtfDir: Boolean get() = !chatDir.sent
val isMutedMemberEvent: Boolean get() =
val showNotification: Boolean get() =
when (content) {
is CIContent.RcvGroupEventContent ->
when (content.rcvGroupEvent) {
is RcvGroupEvent.GroupUpdated -> true
is RcvGroupEvent.MemberConnected -> true
is RcvGroupEvent.UserDeleted -> false
is RcvGroupEvent.GroupDeleted -> false
is RcvGroupEvent.MemberAdded -> false
is RcvGroupEvent.MemberLeft -> false
is RcvGroupEvent.MemberRole -> true
is RcvGroupEvent.UserRole -> false
is RcvGroupEvent.MemberDeleted -> false
is RcvGroupEvent.InvitedViaGroupLink -> false
}
is CIContent.SndGroupEventContent -> true
else -> false
is CIContent.SndMsgContent -> showNtfDir
is CIContent.RcvMsgContent -> showNtfDir
is CIContent.SndDeleted -> showNtfDir
is CIContent.RcvDeleted -> showNtfDir
is CIContent.SndCall -> showNtfDir
is CIContent.RcvCall -> false // notification is shown on CallInvitation instead
is CIContent.RcvIntegrityError -> showNtfDir
is CIContent.RcvGroupInvitation -> showNtfDir
is CIContent.SndGroupInvitation -> showNtfDir
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.MemberAdded -> false
is RcvGroupEvent.MemberConnected -> false
is RcvGroupEvent.MemberLeft -> false
is RcvGroupEvent.MemberRole -> false
is RcvGroupEvent.UserRole -> showNtfDir
is RcvGroupEvent.MemberDeleted -> false
is RcvGroupEvent.UserDeleted -> showNtfDir
is RcvGroupEvent.GroupDeleted -> showNtfDir
is RcvGroupEvent.GroupUpdated -> false
is RcvGroupEvent.InvitedViaGroupLink -> false
}
is CIContent.SndGroupEventContent -> showNtfDir
is CIContent.RcvConnEventContent -> false
is CIContent.SndConnEventContent -> showNtfDir
is CIContent.RcvChatFeature -> false
is CIContent.SndChatFeature -> showNtfDir
is CIContent.RcvChatPreference -> false
is CIContent.SndChatPreference -> showNtfDir
is CIContent.RcvGroupFeature -> false
is CIContent.SndGroupFeature -> showNtfDir
is CIContent.RcvChatFeatureRejected -> showNtfDir
is CIContent.RcvGroupFeatureRejected -> showNtfDir
}
fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))
@@ -1085,11 +1182,12 @@ data class ChatItem (
file: CIFile? = null,
itemDeleted: Boolean = false,
itemEdited: Boolean = false,
itemTimed: CITimed? = null,
editable: Boolean = true
) =
ChatItem(
chatDir = dir,
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, null, editable),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
quotedItem = quotedItem,
file = file
@@ -1142,6 +1240,40 @@ data class ChatItem (
quotedItem = null,
file = null
)
fun getChatFeatureSample(feature: ChatFeature, enabled: FeatureEnabled): ChatItem {
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled, param = null)
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
)
}
private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
val deletedItemDummy: ChatItem
get() = ChatItem(
chatDir = CIDirection.DirectRcv(),
meta = CIMeta(
itemId = TEMP_DELETED_CHAT_ITEM_ID,
itemTs = Clock.System.now(),
itemText = generalGetString(R.string.deleted_description),
itemStatus = CIStatus.RcvRead(),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
itemDeleted = false,
itemEdited = false,
itemTimed = null,
itemLive = false,
editable = false
),
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
quotedItem = null,
file = null
)
}
}
@@ -1167,16 +1299,33 @@ data class CIMeta (
val itemText: String,
val itemStatus: CIStatus,
val createdAt: Instant,
val updatedAt: Instant,
val itemDeleted: Boolean,
val itemEdited: Boolean,
val itemTimed: CITimed?,
val itemLive: Boolean?,
val editable: Boolean
) {
val timestampText: String get() = getTimestampText(itemTs)
val recent: Boolean get() = updatedAt + 10.toDuration(DurationUnit.SECONDS) > Clock.System.now()
val isLive: Boolean get() = itemLive == true
val disappearing: Boolean get() = !isRcvNew && itemTimed?.deleteAt != null
val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew
fun statusIcon(primaryColor: Color, metaColor: Color = HighOrLowlight): Pair<ImageVector, Color>? =
when (itemStatus) {
is CIStatus.SndSent -> Icons.Filled.Check to metaColor
is CIStatus.SndErrorAuth -> Icons.Filled.Close to Color.Red
is CIStatus.SndError -> Icons.Filled.WarningAmber to WarningYellow
is CIStatus.RcvNew -> Icons.Filled.Circle to primaryColor
else -> null
}
companion object {
fun getSample(
id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true
itemDeleted: Boolean = false, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, editable: Boolean = true
): CIMeta =
CIMeta(
itemId = id,
@@ -1184,13 +1333,22 @@ data class CIMeta (
itemText = text,
itemStatus = status,
createdAt = ts,
updatedAt = ts,
itemDeleted = itemDeleted,
itemEdited = itemEdited,
itemTimed = itemTimed,
itemLive = itemLive,
editable = editable
)
}
}
@Serializable
data class CITimed(
val ttl: Int,
val deleteAt: Instant?
)
fun getTimestampText(t: Instant): String {
val tz = TimeZone.currentSystemDefault()
val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
@@ -1206,7 +1364,7 @@ sealed class CIStatus {
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
@Serializable @SerialName("sndSent") class SndSent: CIStatus()
@Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
@Serializable @SerialName("sndError") class SndError(val agentError: AgentErrorType): CIStatus()
@Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus()
@Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
}
@@ -1238,21 +1396,55 @@ 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: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatPreference") class RcvChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndChatPreference") class SndChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): 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 -> featureText(feature, enabled.text, param)
is SndChatFeature -> featureText(feature, enabled.text, param)
is RcvChatPreference -> preferenceText(feature, allowed, param)
is SndChatPreference -> preferenceText(feature, allowed, param)
is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param)
is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param)
is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
}
companion object {
fun featureText(feature: Feature, enabled: String, param: Int?): String =
if (feature.hasParam) {
"${feature.text}: ${TimedMessagesPreference.ttlText(param)}"
} else {
"${feature.text}: $enabled"
}
fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when {
allowed != FeatureAllowed.NO && feature.hasParam && param != null ->
"offered ${feature.text}: ${TimedMessagesPreference.ttlText(param)}"
allowed != FeatureAllowed.NO ->
"offered ${feature.text}"
else ->
"cancelled ${feature.text}"
}
}
}
@@ -1265,7 +1457,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.text == "" && content is MsgContent.MCVoice)
durationText(content.duration)
else
content.text
}
fun sender(membership: GroupMember?): String? = when (chatDir) {
is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun)
@@ -1334,16 +1532,12 @@ 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()
val cmdString: String get() = when (this) {
is MCText -> "text $text"
is MCLink -> "json ${json.encodeToString(this)}"
is MCImage -> "json ${json.encodeToString(this)}"
is MCFile -> "json ${json.encodeToString(this)}"
is MCUnknown -> "json $json"
}
val cmdString: String get() =
if (this is MCUnknown) "json $json" else "json ${json.encodeToString(this)}"
}
@Serializable
@@ -1414,6 +1608,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)
}
@@ -1445,6 +1643,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")
@@ -1458,12 +1662,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
@@ -1475,6 +1688,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()
@@ -1486,6 +1700,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
}
@@ -1495,6 +1710,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"),
@@ -1510,7 +1738,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
@@ -1545,13 +1773,13 @@ 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), durationText(sec))
Error -> generalGetString(R.string.callstatus_error)
}
fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
}
fun durationText(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
@Serializable
sealed class MsgErrorType() {
@Serializable @SerialName("msgSkipped") class MsgSkipped(val fromMsgId: Long, val toMsgId: Long): MsgErrorType()

View File

@@ -3,9 +3,11 @@ package chat.simplex.app.model
import android.app.*
import android.content.*
import android.graphics.BitmapFactory
import android.hardware.display.DisplayManager
import android.media.AudioAttributes
import android.net.Uri
import android.util.Log
import android.view.Display
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
@@ -23,9 +25,9 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
// DO NOT change notification channel settings / names
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1"
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
const val CallNotificationId: Int = -1
private const val ChatIdKey: String = "chatId"
@@ -37,24 +39,29 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
init {
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(callNotificationChannel())
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
// Remove old channels since they can't be edited
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
}
enum class NotificationAction {
ACCEPT_CONTACT_REQUEST
}
private fun callNotificationChannel(): NotificationChannel {
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
val attrs = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build()
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
Log.d(TAG,"callNotificationChannel sound: $soundUri")
callChannel.setSound(soundUri, attrs)
callChannel.enableVibration(true)
// the numbers below are explained here: https://developer.android.com/reference/android/os/Vibrator
// (wait, vibration duration, wait till off, wait till on again = ringtone mp3 duration - vibration duration - ~50ms lost somewhere)
callChannel.vibrationPattern = longArrayOf(250, 250, 0, 2600)
return callChannel
}
@@ -151,24 +158,34 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
}
fun notifyCallInvitation(invitation: RcvCallInvitation) {
if (isAppOnForeground(context)) return
val keyguardManager = getKeyguardManager(context)
Log.d(TAG,
"notifyCallInvitation pre-requests: " +
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
"onForeground ${SimplexApp.context.isAppOnForeground}"
)
if (SimplexApp.context.isAppOnForeground) return
val contactId = invitation.contact.id
Log.d(TAG, "notifyCallInvitation $contactId")
val keyguardManager = getKeyguardManager(context)
val image = invitation.contact.image
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
var ntfBuilder =
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, LockScreenCallChannel)
NotificationCompat.Builder(context, CallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setSilent(true)
} else {
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CallChannel)
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, contactId, true))
.setFullScreenIntent(fullScreenPendingIntent, true)
.setSound(soundUri)
}
val text = generalGetString(
@@ -197,8 +214,11 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
.setLargeIcon(largeIcon)
.setColor(0x88FFFF)
.setAutoCancel(true)
val notification = ntfBuilder.build()
// This makes notification sound and vibration repeat endlessly
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
with(NotificationManagerCompat.from(context)) {
notify(CallNotificationId, ntfBuilder.build())
notify(CallNotificationId, notification)
}
}
@@ -206,33 +226,35 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
manager.cancel(CallNotificationId)
}
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText
return if (md == null) {
if (cItem.content.text != "") {
cItem.content.text
} else {
cItem.file?.fileName ?: ""
}
} else {
return if (md != null) {
var res = ""
for (ft in md) {
res += if (ft.format is Format.Secret) "..." else ft.text
}
res
} else {
cItem.text
}
}
private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
private fun chatPendingIntent(intentAction: String, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
Log.d(TAG, "chatPendingIntent for $intentAction")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
var intent = Intent(context, MainActivity::class.java)
var intent = Intent(context, if (!broadcast) MainActivity::class.java else NtfActionReceiver::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.setAction(intentAction)
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
return if (!broadcast) {
TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
}
} else {
PendingIntent.getBroadcast(SimplexApp.context, uniqueInt, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
}
@@ -250,6 +272,12 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
}
RejectCallAction -> {
val invitation = SimplexApp.context.chatModel.callInvitations[chatId]
if (invitation != null) {
SimplexApp.context.chatModel.callManager.endCall(invitation = invitation)
}
}
else -> {
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
}

View File

@@ -135,7 +135,21 @@ fun TerminalLayout(
topBar = { CloseSheetBar(close) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
SendMsgView(
composeState = composeState,
showVoiceRecordIcon = false,
recState = mutableStateOf(RecordingState.NotStarted),
isDirectChat = false,
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = false,
allowVoiceToContact = {},
sendMessage = sendCommand,
sendLiveMessage = null,
updateLiveMessage = null,
::onMessageChange,
textStyle
)
}
},
modifier = Modifier.navigationBarsWithImePadding()

View File

@@ -111,9 +111,7 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
Profile(displayName, fullName, null)
)
chatModel.controller.startChat(user)
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
SimplexService.start(chatModel.controller.appContext)
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
}
}

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

@@ -1,5 +1,6 @@
package chat.simplex.app.views.call
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
@@ -43,8 +44,7 @@ class IncomingCallActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val activity = this
setContent { IncomingCallActivityView(vm.chatModel, activity) }
setContent { IncomingCallActivityView(vm.chatModel) }
unlockForIncomingCall()
}
@@ -83,11 +83,12 @@ fun getKeyguardManager(context: Context): KeyguardManager =
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
@Composable
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
fun IncomingCallActivityView(m: ChatModel) {
val switchingCall = m.switchingCall.value
val invitation = m.activeCallInvitation.value
val call = m.activeCall.value
val showCallView = m.showCallView.value
val activity = LocalContext.current as Activity
LaunchedEffect(invitation, call, switchingCall, showCallView) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
Log.d(TAG, "IncomingCallActivityView: finishing activity")
@@ -105,36 +106,41 @@ fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
if (invitation != null) IncomingCallAlertView(invitation, m)
}
} else if (invitation != null) {
IncomingCallLockScreenAlert(invitation, m, activity)
IncomingCallLockScreenAlert(invitation, m)
}
}
}
}
@Composable
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
val callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
val context = LocalContext.current
DisposableEffect(Unit) {
onDispose {
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
chatModel.controller.ntfManager.cancelCallNotification()
}
}
IncomingCallLockScreenAlertLayout(
invitation,
callOnLockScreen,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = { chatModel.activeCallInvitation.value = null },
ignoreCall = {
chatModel.activeCallInvitation.value = null
chatModel.controller.ntfManager.cancelCallNotification()
},
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
openApp = {
SoundPlayer.shared.stop()
var intent = Intent(activity, MainActivity::class.java)
val intent = Intent(context, MainActivity::class.java)
.setAction(OpenChatAction)
.putExtra("chatId", invitation.contact.id)
activity.startActivity(intent)
activity.finish()
context.startActivity(intent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getKeyguardManager(activity).requestDismissKeyguard(activity, null)
getKeyguardManager(context).requestDismissKeyguard((context as Activity), null)
}
(context as Activity).finish()
}
)
}

View File

@@ -33,7 +33,10 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
IncomingCallAlertLayout(
invitation,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = { chatModel.activeCallInvitation.value = null },
ignoreCall = {
chatModel.activeCallInvitation.value = null
chatModel.controller.ntfManager.cancelCallNotification()
},
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
)
}

View File

@@ -35,9 +35,10 @@ 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.*
import kotlinx.datetime.Clock
@Composable
fun ChatInfoView(
@@ -46,6 +47,7 @@ fun ChatInfoView(
connStats: ConnectionStats?,
customUserProfile: Profile?,
localAlias: String,
connectionCode: String?,
close: () -> Unit,
) {
BackHandler(onBack = close)
@@ -58,14 +60,48 @@ fun ChatInfoView(
connStats,
customUserProfile,
localAlias,
connectionCode,
developerTools,
onLocalAliasChanged = {
setContactAlias(chat.chatInfo.apiId, it, chatModel)
},
openPreferences = {
ModalManager.shared.showCustomModal { close ->
val user = chatModel.currentUser.value
if (user != null) {
ContactPreferencesView(chatModel, user, contact.contactId, close)
}
}
},
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
switchContactAddress = {
showSwitchContactAddressAlert(chatModel, contact.contactId)
},
verifyClicked = {
ModalManager.shared.showModalCloseable { close ->
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
VerifyCodeView(
ct.displayName,
connectionCode,
ct.verified,
verify = { code ->
chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r ->
val (verified, existingCode) = r
chatModel.updateContact(
ct.copy(
activeConn = ct.activeConn.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
)
r
}
},
close,
)
}
}
}
)
}
@@ -115,11 +151,14 @@ fun ChatInfoLayout(
connStats: ConnectionStats?,
customUserProfile: Profile?,
localAlias: String,
connectionCode: String?,
developerTools: Boolean,
onLocalAliasChanged: (String) -> Unit,
openPreferences: () -> Unit,
deleteContact: () -> Unit,
clearChat: () -> Unit,
switchContactAddress: () -> Unit,
verifyClicked: () -> Unit,
) {
Column(
Modifier
@@ -144,12 +183,19 @@ fun ChatInfoLayout(
}
SectionSpacer()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
if (developerTools) {
SwitchAddressButton(switchContactAddress)
SectionView {
if (connectionCode != null) {
VerifyCodeButton(contact.verified, verifyClicked)
SectionDivider()
}
ContactPreferencesButton(openPreferences)
}
SectionSpacer()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SwitchAddressButton(switchContactAddress)
SectionDivider()
if (connStats != null) {
SectionItemView({
AlertManager.shared.showAlertMsg(
@@ -196,13 +242,17 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
if (contact.verified) {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
}
Text(
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
@@ -264,7 +314,7 @@ fun LocalAliasEditor(
}
@Composable
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
Row(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -296,7 +346,7 @@ fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
}
@Composable
fun ServerImage(networkStatus: Chat.NetworkStatus) {
private fun ServerImage(networkStatus: Chat.NetworkStatus) {
Box(Modifier.size(18.dp)) {
when (networkStatus) {
is Chat.NetworkStatus.Connected ->
@@ -327,6 +377,25 @@ fun SwitchAddressButton(onClick: () -> Unit) {
}
}
@Composable
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
SettingsActionItem(
if (contactVerified) Icons.Outlined.VerifiedUser else Icons.Outlined.Shield,
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
click = onClick,
iconColor = HighOrLowlight,
)
}
@Composable
private fun ContactPreferencesButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.contact_preferences),
click = onClick
)
}
@Composable
fun ClearChatButton(onClick: () -> Unit) {
SettingsActionItem(
@@ -339,7 +408,7 @@ fun ClearChatButton(onClick: () -> Unit) {
}
@Composable
fun DeleteContactButton(onClick: () -> Unit) {
private fun DeleteContactButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Delete,
stringResource(R.string.button_delete_contact),
@@ -382,13 +451,16 @@ fun PreviewChatInfoLayout() {
),
Contact.sampleData,
localAlias = "",
connectionCode = "123",
developerTools = false,
connStats = null,
onLocalAliasChanged = {},
customUserProfile = null,
openPreferences = {},
deleteContact = {},
clearChat = {},
switchContactAddress = {},
verifyClicked = {},
)
}
}

View File

@@ -1,10 +1,9 @@
package chat.simplex.app.views.chat
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.view.inputmethod.InputMethodManager
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
@@ -14,8 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
@@ -52,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()
@@ -63,7 +61,6 @@ 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
@@ -80,16 +77,23 @@ fun ChatView(chatModel: 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
}}
snapshotFlow {
/**
* It's possible that in some cases concurrent modification can happen on [ChatModel.chats] list.
* In this case only error log will be printed here (no crash).
* TODO: Re-write [ChatModel.chats] logic to a new list assignment instead of changing content of mutableList to prevent that
* */
try {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
} catch (e: ConcurrentModificationException) {
Log.e(TAG, e.stackTraceToString())
null
}
}
.distinctUntilChanged()
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
.filter { it?.chatInfo != activeChat.value?.chatInfo && it != null }
.collect { activeChat.value = it }
}
}
val view = LocalView.current
@@ -97,17 +101,15 @@ fun ChatView(chatModel: ChatModel) {
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,
@@ -120,29 +122,33 @@ fun ChatView(chatModel: ChatModel) {
}
},
attachmentOption,
scope,
attachmentBottomSheetState,
chatModel.chatItems,
searchText,
useLinkPreviews = useLinkPreviews,
linkMode = chatModel.simplexLinkMode.value,
chatModelIncognito = chatModel.incognito.value,
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)
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
ModalManager.shared.showModalCloseable(true) { close ->
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
}
}
} else if (cInfo is ChatInfo.Group) {
setGroupMembers(cInfo.groupInfo, chatModel)
} else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
var groupLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
ModalManager.shared.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, close)
GroupChatInfoView(chatModel, groupLink, { groupLink = it }, close)
}
}
}
@@ -151,8 +157,21 @@ fun ChatView(chatModel: ChatModel) {
hideKeyboard(view)
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
val (_, code) = if (member.memberActive) {
try {
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
member to null
}
} else {
member to null
}
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
}
}
}
},
@@ -168,13 +187,20 @@ fun ChatView(chatModel: ChatModel) {
deleteMessage = { itemId, mode ->
withApi {
val cInfo = chat.chatInfo
val toItem = chatModel.controller.apiDeleteChatItem(
val r = chatModel.controller.apiDeleteChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = itemId,
mode = mode
)
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
if (r != null) {
val toChatItem = r.toChatItem
if (toChatItem == null) {
chatModel.removeChatItem(cInfo, r.deletedChatItem.chatItem)
} else {
chatModel.upsertChatItem(cInfo, toChatItem.chatItem)
}
}
}
},
receiveFile = { fileId ->
@@ -200,19 +226,24 @@ fun ChatView(chatModel: ChatModel) {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
},
acceptFeature = { contact, feature, param ->
withApi {
chatModel.controller.allowFeatureToContact(contact, feature, param)
}
},
addMembers = { groupInfo ->
hideKeyboard(view)
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, chatModel, close)
AddGroupMembersView(groupInfo, false, chatModel, close)
}
}
},
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withApi {
withBGApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
@@ -228,24 +259,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,
@@ -256,12 +287,15 @@ fun ChatLayout(
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
addMembers: (GroupInfo) -> Unit,
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)
@@ -292,9 +326,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, acceptFeature, markRead, setFloatingButton, onComposed,
)
}
}
@@ -417,10 +451,15 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
Row(verticalAlignment = Alignment.CenterVertically) {
if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) {
ContactVerifiedShield()
}
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
Text(
cInfo.fullName,
@@ -431,6 +470,11 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
}
}
@Composable
private fun ContactVerifiedShield() {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
}
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
val CIListStateSaver = run {
@@ -445,13 +489,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,
@@ -459,14 +503,14 @@ fun BoxWithConstraintsScope.ChatItemsList(
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> 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)
ScrollToBottom(chat.id, listState, chatItems)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
LaunchedEffect(searchValue.value.isEmpty()) {
@@ -493,8 +537,18 @@ 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 ->
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
@@ -555,11 +609,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, acceptFeature = acceptFeature, 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, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
}
}
} else { // direct message
@@ -570,7 +624,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, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
}
}
@@ -589,7 +643,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
@Composable
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List<ChatItem>) {
val scope = rememberCoroutineScope()
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
@@ -601,6 +655,23 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
// Don't autoscroll next time until it will be needed
shouldAutoScroll = false to chatId
}
val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() }
/*
* Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves.
* When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise
* */
LaunchedEffect(Unit) {
snapshotFlow { chatItems.lastOrNull()?.id }
.distinctUntilChanged()
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
.collect {
if (listState.firstVisibleItemIndex == 0) {
listState.animateScrollToItem(0)
} else {
listState.animateScrollBy(scrollDistance)
}
}
}
}
@Composable
@@ -717,7 +788,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
@@ -931,7 +1002,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,
@@ -941,11 +1011,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 = {},
@@ -956,10 +1026,12 @@ fun PreviewChatLayout() {
joinGroup = {},
startCall = {},
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
onComposed = {},
)
}
}
@@ -989,7 +1061,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,
@@ -999,11 +1070,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 = {},
@@ -1014,10 +1085,12 @@ fun PreviewGroupChatLayout() {
joinGroup = {},
startCall = {},
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
onComposed = {},
)
}
}

View File

@@ -1,7 +1,9 @@
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
@@ -14,14 +16,11 @@ import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
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.filled.*
import androidx.compose.material.icons.outlined.Reply
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
@@ -29,26 +28,31 @@ 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.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
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import java.io.File
@Serializable
sealed class ComposePreview {
@Serializable object NoPreview: ComposePreview()
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
@Serializable class FilePreview(val fileName: String): ComposePreview()
}
@@ -59,16 +63,25 @@ sealed class ComposeContextItem {
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
}
@Serializable
data class LiveMessage(
val chatItem: ChatItem,
val typedMsg: String,
val sentMsg: String
)
@Serializable
data class ComposeState(
val message: String = "",
val liveMessage: LiveMessage? = null,
val preview: ComposePreview = ComposePreview.NoPreview,
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
val inProgress: Boolean = false,
val useLinkPreviews: Boolean
) {
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this(
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
editingItem.content.text,
liveMessage,
chatItemPreview(editingItem),
ComposeContextItem.EditingItem(editingItem),
useLinkPreviews = useLinkPreviews
@@ -84,8 +97,9 @@ data class ComposeState(
get() = {
val hasContent = when (preview) {
is ComposePreview.ImagePreview -> true
is ComposePreview.VoicePreview -> true
is ComposePreview.FilePreview -> true
else -> message.isNotEmpty()
else -> message.isNotEmpty() || liveMessage != null
}
hasContent && !inProgress
}
@@ -93,6 +107,7 @@ data class ComposeState(
get() =
when (preview) {
is ComposePreview.ImagePreview -> false
is ComposePreview.VoicePreview -> false
is ComposePreview.FilePreview -> false
else -> useLinkPreviews
}
@@ -103,6 +118,16 @@ data class ComposeState(
else -> null
}
val attachmentDisabled: Boolean
get() {
if (editing || liveMessage != null) return true
return when (preview) {
ComposePreview.NoPreview -> false
is ComposePreview.CLinkPreview -> false
else -> true
}
}
companion object {
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
save = { json.encodeToString(serializer(), it.value) },
@@ -113,16 +138,26 @@ data class ComposeState(
}
}
sealed class RecordingState {
object NotStarted: RecordingState()
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
class Finished(val filePath: String, val durationMs: Int): RecordingState()
val filePathNullable: String?
get() = (this as? Started)?.filePath
}
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
return when (val mc = chatItem.content.msgContent) {
is MsgContent.MCText -> ComposePreview.NoPreview
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
is MsgContent.MCImage -> ComposePreview.ImagePreview(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
}
}
@@ -144,6 +179,11 @@ fun ComposeView(
val textStyle = remember { mutableStateOf(smallFont) }
// attachments
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) {
@@ -156,7 +196,7 @@ fun ComposeView(
}
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launch(null)
cameraLauncher.launchWithFallback()
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
@@ -222,13 +262,14 @@ fun ComposeView(
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
AttachmentOption.TakePhoto -> {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launch(null)
cameraLauncher.launchWithFallback()
}
else -> {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
@@ -299,117 +340,157 @@ fun ComposeView(
cancelledLinks.clear()
}
fun checkLinkPreview(): MsgContent {
val cs = composeState.value
return when (val composePreview = cs.preview) {
is ComposePreview.CLinkPreview -> {
val url = parseMessage(cs.message)
val lp = composePreview.linkPreview
if (lp != null && url == lp.uri) {
MsgContent.MCLink(cs.message, preview = lp)
} else {
MsgContent.MCText(cs.message)
}
}
else -> MsgContent.MCText(cs.message)
fun clearState(live: Boolean = false) {
if (live) {
composeState.value = composeState.value.copy(inProgress = false)
} else {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
resetLinkPreview()
}
}
fun updateMsgContent(msgContent: MsgContent): MsgContent {
val cs = composeState.value
return when (msgContent) {
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
}
}
fun clearState() {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
recState.value = RecordingState.NotStarted
textStyle.value = smallFont
chosenContent.value = emptyList()
chosenAudio.value = null
chosenFile.value = null
linkUrl.value = null
prevLinkUrl.value = null
pendingLinkUrl.value = null
cancelledLinks.clear()
}
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
file = file,
quotedItemId = quoted,
mc = mc,
live = live
)
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
return aChatItem?.chatItem
}
suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? {
val cInfo = chat.chatInfo
val cs = composeState.value
var sent: ChatItem?
val msgText = text ?: cs.message
fun sending() {
composeState.value = composeState.value.copy(inProgress = true)
}
fun checkLinkPreview(): MsgContent {
return when (val composePreview = cs.preview) {
is ComposePreview.CLinkPreview -> {
val url = parseMessage(msgText)
val lp = composePreview.linkPreview
if (lp != null && url == lp.uri) {
MsgContent.MCLink(msgText, preview = lp)
} else {
MsgContent.MCText(msgText)
}
}
else -> MsgContent.MCText(msgText)
}
}
fun updateMsgContent(msgContent: MsgContent): MsgContent {
return when (msgContent) {
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
is MsgContent.MCFile -> MsgContent.MCFile(msgText)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
}
}
suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? {
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
mc = updateMsgContent(oldMsgContent),
live = live
)
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
return updatedItem?.chatItem
}
return null
}
if (!live) {
sending()
}
if (cs.contextItem is ComposeContextItem.EditingItem) {
val ei = cs.contextItem.chatItem
sent = updateMessage(ei, cInfo, live)
} else if (cs.liveMessage != null) {
sent = updateMessage(cs.liveMessage.chatItem, cInfo, live)
} else {
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<String> = ArrayList()
when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.ImagePreview -> {
chosenContent.value.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(context, it.uri)
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
}
if (file != null) {
files.add(file)
msgs.add(MsgContent.MCImage(if (chosenContent.value.lastIndex == index) msgText else "", preview.images[index]))
}
}
}
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())
AudioPlayer.stop(chosenAudioVal.first.toFile().absolutePath)
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", chosenAudioVal.second / 1000))
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
val file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
}
}
}
}
val quotedItemId: Long? = when (cs.contextItem) {
is ComposeContextItem.QuotedItem -> cs.contextItem.chatItem.id
else -> null
}
sent = null
msgs.forEachIndexed { index, content ->
if (index > 0) delay(100)
sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index),
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
)
}
if (sent == null && chosenContent.value.isNotEmpty()) {
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
}
}
clearState(live)
return sent
}
fun sendMessage() {
composeState.value = composeState.value.copy(inProgress = true)
val cInfo = chat.chatInfo
val cs = composeState.value
when (val contextItem = cs.contextItem) {
is ComposeContextItem.EditingItem -> {
val ei = contextItem.chatItem
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
withApi {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
mc = updateMsgContent(oldMsgContent)
)
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
clearState()
}
}
}
else -> {
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<String> = ArrayList()
when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.ImagePreview -> {
chosenContent.value.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(context, it.uri)
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
}
if (file != null) {
files.add(file)
msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index]))
}
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
val file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else ""))
}
}
}
}
val quotedItemId: Long? = when (contextItem) {
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
else -> null
}
if (msgs.isNotEmpty()) {
withApi {
msgs.forEachIndexed { index, content ->
if (index > 0) delay(100)
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
file = files.getOrNull(index),
quotedItemId = if (index == 0) quotedItemId else null,
mc = content
)
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
}
clearState()
}
} else {
clearState()
}
}
withBGApi {
sendMessageAsync(null, false)
}
}
@@ -426,6 +507,20 @@ 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
withApi {
chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice)
}
}
fun cancelLinkPreview() {
val uri = composeState.value.linkPreview?.uri
if (uri != null) {
@@ -440,11 +535,69 @@ fun ComposeView(
chosenContent.value = emptyList()
}
fun cancelVoice() {
val filePath = recState.value.filePathNullable
recState.value = RecordingState.NotStarted
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
withBGApi {
RecorderNative.stopRecording?.invoke()
AudioPlayer.stop(filePath)
filePath?.let { File(it).delete() }
}
chosenAudio.value = null
}
fun cancelFile() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenFile.value = null
}
fun truncateToWords(s: String): String {
var acc = ""
val word = StringBuilder()
for (c in s) {
if (c.isLetter() || c.isDigit()) {
word.append(c)
} else {
acc = acc + word.toString() + c
word.clear()
}
}
return acc
}
suspend fun sendLiveMessage() {
val typedMsg = composeState.value.message
val sentMsg = truncateToWords(typedMsg)
if (composeState.value.liveMessage == null) {
val ci = sendMessageAsync(sentMsg, live = true)
if (ci != null) {
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg))
}
}
}
fun liveMessageToSend(lm: LiveMessage, t: String): String? {
val s = if (t != lm.typedMsg) truncateToWords(t) else t
return if (s != lm.sentMsg) s else null
}
suspend fun updateLiveMessage() {
val typedMsg = composeState.value.message
val liveMessage = composeState.value.liveMessage
if (liveMessage != null) {
val sentMsg = liveMessageToSend(liveMessage, typedMsg)
if (sentMsg != null) {
val ci = sendMessageAsync(sentMsg, live = true)
if (ci != null) {
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg))
}
} else if (liveMessage.typedMsg != typedMsg) {
composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg))
}
}
}
@Composable
fun previewView() {
when (val preview = composeState.value.preview) {
@@ -455,6 +608,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,
@@ -477,6 +637,9 @@ fun ComposeView(
}
LaunchedEffect(chatModel.sharedContent.value) {
// Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
if (chatModel.chatId.value == null) return@LaunchedEffect
when (val shared = chatModel.sharedContent.value) {
is SharedContent.Text -> onMessageChange(shared.text)
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
@@ -489,38 +652,77 @@ 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)) {
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) {
Icon(
Icons.Filled.AttachFile,
contentDescription = stringResource(R.string.attach),
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
tint = if (!composeState.value.attachmentDisabled) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.clickable {
if (attachEnabled) {
showChooseAttachment()
}
}
)
}
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && chosenAudio.value != null) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
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
}
}
LaunchedEffect(Unit) {
snapshotFlow { recState.value }
.distinctUntilChanged()
.collect {
when(it) {
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true)
is RecordingState.NotStarted -> {}
}
}
}
val activity = LocalContext.current as Activity
DisposableEffect(Unit) {
val orientation = activity.resources.configuration.orientation
onDispose {
if (orientation == activity.resources.configuration.orientation && composeState.value.liveMessage != null) {
sendMessage()
resetLinkPreview()
}
}
}
SendMsgView(
composeState,
showVoiceRecordIcon = true,
recState,
chat.chatInfo is ChatInfo.Direct,
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
needToAllowVoiceToContact,
allowedVoiceByPrefs,
allowVoiceToContact = ::allowVoiceToContact,
sendMessage = {
sendMessage()
resetLinkPreview()
},
::onMessageChange,
textStyle
sendLiveMessage = ::sendLiveMessage,
updateLiveMessage = ::updateLiveMessage,
onMessageChange = ::onMessageChange,
textStyle = textStyle
)
}
}

View File

@@ -0,0 +1,133 @@
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.model.durationText
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 {
finishedRecording -> progress.value
else -> recordedDurationMs
}
val endTime = when {
finishedRecording -> duration.value
audioPlaying.value -> recordedDurationMs
else -> MAX_VOICE_MILLIS_FOR_SENDING
}
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, false)
} 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 {
when {
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
finishedRecording -> progress.value / 1000
else -> recordedDurationMs / 1000
}
}
}
Text(
durationText(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,232 @@
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.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.PreferenceToggle
@Composable
fun ContactPreferencesView(
m: ChatModel,
user: User,
contactId: Long,
close: () -> Unit,
) {
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
val ct = contact.value ?: return
var featuresAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
var currentFeaturesAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(featuresAllowed) }
fun savePrefs(afterSave: () -> Unit = {}) {
withApi {
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
if (toContact != null) {
m.updateContact(toContact)
currentFeaturesAllowed = featuresAllowed
}
afterSave()
}
}
ModalView(
close = {
if (featuresAllowed == currentFeaturesAllowed) close()
else showUnsavedChangesAlert({ savePrefs(close) }, close)
},
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
ContactPreferencesLayout(
featuresAllowed,
currentFeaturesAllowed,
user,
ct,
applyPrefs = { prefs ->
featuresAllowed = prefs
},
reset = {
featuresAllowed = currentFeaturesAllowed
},
savePrefs = ::savePrefs,
)
}
}
@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 timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
val onTTLUpdated = { ttl: Int? ->
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl ?: 86400))
}
TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl ->
applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL))
}
SectionSpacer()
val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
FeatureSection(ChatFeature.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(ChatFeature.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: ChatFeature,
userDefault: FeatureAllowed,
pref: ContactUserPreference,
allowFeature: State<ContactFeatureAllowed>,
onSelected: (ContactFeatureAllowed) -> Unit
) {
val enabled = FeatureEnabled.enabled(
feature.asymmetric,
user = SimpleChatPreference(allow = allowFeature.value.allowed),
contact = pref.contactPreference
)
SectionView(
feature.text.uppercase(),
icon = feature.iconFilled,
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 TimedMessagesFeatureSection(
featuresAllowed: ContactFeaturesAllowed,
pref: ContactUserPreferenceTimed,
allowFeature: State<Boolean>,
onTTLUpdated: (Int?) -> Unit,
onSelected: (Boolean, Int?) -> Unit
) {
val enabled = FeatureEnabled.enabled(
ChatFeature.TimedMessages.asymmetric,
user = TimedMessagesPreference(allow = if (allowFeature.value) FeatureAllowed.YES else FeatureAllowed.NO),
contact = pref.contactPreference
)
SectionView(
ChatFeature.TimedMessages.text.uppercase(),
icon = ChatFeature.TimedMessages.iconFilled,
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
leadingIcon = true,
) {
SectionItemView {
PreferenceToggle(
generalGetString(R.string.chat_preferences_you_allow),
checked = allowFeature.value,
) { allow ->
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
}
}
SectionDivider()
InfoRow(
generalGetString(R.string.chat_preferences_contact_allows),
pref.contactPreference.allow.text
)
SectionDivider()
if (featuresAllowed.timedMessagesAllowed) {
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
TimedMessagesTTLPicker(ttl, onTTLUpdated)
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
}
}
SectionTextFooter(ChatFeature.TimedMessages.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)
}
}
}
@Composable
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
val ttlValues = TimedMessagesPreference.ttlValues
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
SectionItemView {
ExposedDropDownSettingRow(
generalGetString(R.string.delete_after),
values.map { it to TimedMessagesPreference.ttlText(it) },
selection,
onSelected = onSelected
)
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(R.string.save_preferences_question),
confirmText = generalGetString(R.string.save_and_notify_contact),
dismissText = generalGetString(R.string.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}

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

@@ -0,0 +1,53 @@
package chat.simplex.app.views.chat
import android.Manifest
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
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 ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
ScanCodeLayout(verifyCode, close)
}
@Composable
private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.scan_code), false)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
.padding(bottom = DEFAULT_PADDING)
) {
QRCodeScanner { text ->
verifyCode(text) {
if (it) {
close()
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.incorrect_code)
)
}
}
}
}
Text(stringResource(R.string.scan_code_from_contacts_app))
}
}

View File

@@ -1,157 +1,507 @@
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
import android.view.inputmethod.*
import android.widget.EditText
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
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.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
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.unit.*
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.model.*
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.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.*
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
showVoiceRecordIcon: Boolean,
recState: MutableState<RecordingState>,
isDirectChat: Boolean,
liveMessageAlertShown: SharedPreference<Boolean>,
needToAllowVoiceToContact: Boolean,
allowedVoiceByPrefs: Boolean,
allowVoiceToContact: () -> Unit,
sendMessage: () -> Unit,
sendLiveMessage: ( suspend () -> Unit)? = null,
updateLiveMessage: (suspend () -> Unit)? = null,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
val cs = composeState.value
var showKeyboard by remember { mutableStateOf(false) }
LaunchedEffect(cs.contextItem) {
when (cs.contextItem) {
is ComposeContextItem.QuotedItem -> {
delay(100)
showKeyboard = true
Box(Modifier.padding(vertical = 8.dp)) {
val cs = composeState.value
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
NativeKeyboard(composeState, textStyle, onMessageChange)
// Disable clicks on text field
if (cs.preview is ComposePreview.VoicePreview) {
Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { }))
}
Box(Modifier.align(Alignment.BottomEnd)) {
val sendButtonSize = remember { Animatable(36f) }
val sendButtonAlpha = remember { Animatable(1f) }
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Making LiveMessage alive when screen orientation was changed
if (cs.liveMessage != null && sendLiveMessage != null && updateLiveMessage != null) {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
}
}
is ComposeContextItem.EditingItem -> {
// Keyboard will not show up if we try to show it too fast
delay(300)
showKeyboard = true
when {
showProgress -> ProgressIndicator()
showVoiceButton -> {
Row(verticalAlignment = Alignment.CenterVertically) {
val stopRecOnNextClick = remember { mutableStateOf(false) }
when {
needToAllowVoiceToContact || !allowedVoiceByPrefs -> {
DisallowedVoiceButton {
if (needToAllowVoiceToContact) {
showNeedToAllowVoiceAlert(allowVoiceToContact)
} else {
showDisabledVoiceAlert(isDirectChat)
}
}
}
!permissionsState.allPermissionsGranted ->
VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
else ->
RecordVoiceView(recState, stopRecOnNextClick)
}
if (sendLiveMessage != null && updateLiveMessage != null && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)) {
Spacer(Modifier.width(10.dp))
StartLiveMessageButton {
if (composeState.value.preview is ComposePreview.NoPreview) {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
}
}
}
}
}
else -> {
val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (composeState.value.liveMessage == null &&
cs.preview !is ComposePreview.VoicePreview && !cs.editing &&
sendLiveMessage != null && updateLiveMessage != null
) {
var showDropdown by rememberSaveable { mutableStateOf(false) }
SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) { showDropdown = true }
DropdownMenu(
expanded = showDropdown,
onDismissRequest = { showDropdown = false },
Modifier.width(220.dp),
) {
ItemAction(
generalGetString(R.string.send_live_message),
Icons.Filled.Bolt,
onClick = {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
showDropdown = false
}
)
}
} else {
SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage)
}
}
}
}
}
}
@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() }
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
}
var showKeyboard by remember { mutableStateOf(false) }
LaunchedEffect(cs.contextItem) {
if (cs.contextItem is ComposeContextItem.QuotedItem) {
delay(100)
showKeyboard = true
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
// Keyboard will not show up if we try to show it too fast
delay(300)
showKeyboard = true
}
}
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)
)
}
}
@Composable
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
DisposableEffect(Unit) { onDispose { rec.stop() } }
val stopRecordingAndAddAudio: () -> Unit = {
recState.value.filePathNullable?.let {
recState.value = RecordingState.Finished(it, rec.stop())
}
}
if (stopRecOnNextClick.value) {
LaunchedEffect(recState.value) {
if (recState.value is RecordingState.NotStarted) {
stopRecOnNextClick.value = false
}
}
// Lock orientation to current orientation because screen rotation will break the recording
LockToCurrentOrientationUntilDispose()
StopRecordButton(stopRecordingAndAddAudio)
} else {
val startRecording: () -> Unit = {
recState.value = RecordingState.Started(
filePath = rec.start { progress: Int?, finished: Boolean ->
val state = recState.value
if (state is RecordingState.Started && progress != null) {
recState.value = if (!finished)
RecordingState.Started(state.filePath, progress)
else
RecordingState.Finished(state.filePath, progress)
}
},
)
}
val interactionSource = interactionSourceWithTapDetection(
onPress = { if (recState.value is RecordingState.NotStarted) startRecording() },
onClick = {
if (stopRecOnNextClick.value) {
stopRecordingAndAddAudio()
} else {
// tapped and didn't hold a finger
stopRecOnNextClick.value = true
}
},
onCancel = stopRecordingAndAddAudio,
onRelease = stopRecordingAndAddAudio
)
RecordVoiceButton(interactionSource)
}
}
@Composable
private fun DisallowedVoiceButton(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Outlined.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = HighOrLowlight,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Filled.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(34.dp)
.padding(4.dp)
)
}
}
@Composable
private fun LockToCurrentOrientationUntilDispose() {
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context as Activity
activity.requestedOrientation = when (activity.display?.rotation) {
android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
}
@Composable
private fun StopRecordButton(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Filled.Stop,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
Icon(
Icons.Filled.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(34.dp)
.padding(4.dp)
)
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
}
@Composable
private fun SendTextButton(
icon: ImageVector,
backgroundColor: Color,
sizeDp: Animatable<Float, AnimationVector1D>,
alpha: Animatable<Float, AnimationVector1D>,
enabled: Boolean,
sendMessage: () -> Unit,
onLongClick: (() -> Unit)? = null
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier.requiredSize(36.dp)
.combinedClickable(
onClick = sendMessage,
onLongClick = onLongClick,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(sizeDp.value.dp)
.padding(4.dp)
.alpha(alpha.value)
.clip(CircleShape)
.background(backgroundColor)
.padding(3.dp)
)
}
}
@Composable
private fun StartLiveMessageButton(onClick: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier.requiredSize(36.dp)
.clickable(
onClick = onClick,
enabled = true,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.Bolt,
stringResource(R.string.icon_descr_send_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
private fun startLiveMessage(
scope: CoroutineScope,
send: suspend () -> Unit,
update: suspend () -> Unit,
sendButtonSize: Animatable<Float, AnimationVector1D>,
sendButtonAlpha: Animatable<Float, AnimationVector1D>,
composeState: MutableState<ComposeState>,
liveMessageAlertShown: SharedPreference<Boolean>
) {
fun run() {
scope.launch {
while (composeState.value.liveMessage != null) {
sendButtonSize.animateTo(if (sendButtonSize.value == 36f) 32f else 36f, tween(700, 50))
}
sendButtonSize.snapTo(36f)
}
scope.launch {
while (composeState.value.liveMessage != null) {
sendButtonAlpha.animateTo(if (sendButtonAlpha.value == 1f) 0.75f else 1f, tween(700, 50))
}
sendButtonAlpha.snapTo(1f)
}
scope.launch {
while (composeState.value.liveMessage != null) {
delay(3000)
update()
}
}
}
fun start() = withBGApi {
if (composeState.value.liveMessage == null) {
send()
}
run()
}
if (liveMessageAlertShown.state.value) {
start()
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.live_message),
text = generalGetString(R.string.send_live_message_desc),
confirmText = generalGetString(R.string.send_verb),
onConfirm = {
liveMessageAlertShown.set(true)
start()
})
}
}
private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
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 = onConfirm,
)
}
private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.voice_messages_prohibited),
text = generalGetString(
if (isDirectChat)
R.string.ask_your_contact_to_enable_voice
else
R.string.only_group_owners_can_enable_voice
)
)
}
@Preview(showBackground = true)
@@ -167,6 +517,13 @@ fun PreviewSendMsgView() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
showVoiceRecordIcon = false,
recState = mutableStateOf(RecordingState.NotStarted),
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
@@ -188,6 +545,13 @@ fun PreviewSendMsgViewEditing() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateEditing) },
showVoiceRecordIcon = false,
recState = mutableStateOf(RecordingState.NotStarted),
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
@@ -209,6 +573,13 @@ fun PreviewSendMsgViewInProgress() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) },
showVoiceRecordIcon = false,
recState = mutableStateOf(RecordingState.NotStarted),
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle

View File

@@ -0,0 +1,137 @@
package chat.simplex.app.views.chat
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
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.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun VerifyCodeView(
displayName: String,
connectionCode: String?,
connectionVerified: Boolean,
verify: suspend (String?) -> Pair<Boolean, String>?,
close: () -> Unit,
) {
if (connectionCode != null) {
VerifyCodeLayout(
displayName,
connectionCode,
connectionVerified,
verifyCode = { newCode, cb ->
withBGApi {
val res = verify(newCode)
if (res != null) {
val (verified) = res
cb(verified)
if (verified) close()
}
}
}
)
}
}
@Composable
private fun VerifyCodeLayout(
displayName: String,
connectionCode: String,
connectionVerified: Boolean,
verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit,
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.security_code), false)
val splitCode = splitToParts(connectionCode, 24)
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
if (connectionVerified) {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 4.dp).size(22.dp), tint = HighOrLowlight)
Text(String.format(stringResource(R.string.is_verified), displayName))
} else {
Text(String.format(stringResource(R.string.is_not_verified), displayName))
}
}
SectionView {
QRCode(connectionCode, Modifier.aspectRatio(1f))
}
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.weight(2f))
SelectionContainer(Modifier.padding(vertical = DEFAULT_PADDING_HALF, horizontal = DEFAULT_PADDING_HALF)) {
Text(
splitCode,
fontFamily = FontFamily.Monospace,
fontSize = 18.sp,
maxLines = 20
)
}
val context = LocalContext.current
Box(Modifier.weight(1f)) {
IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
Icon(Icons.Filled.Share, null, tint = MaterialTheme.colors.primary)
}
}
Spacer(Modifier.weight(1f))
}
Text(
generalGetString(R.string.to_verify_compare),
Modifier.padding(bottom = DEFAULT_PADDING)
)
Row(
Modifier.padding(bottom = DEFAULT_PADDING).align(Alignment.CenterHorizontally),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (connectionVerified) {
SimpleButton(generalGetString(R.string.clear_verification), Icons.Outlined.Shield) {
verifyCode(null) {}
}
} else {
SimpleButton(generalGetString(R.string.scan_code), Icons.Outlined.QrCode) {
ModalManager.shared.showModal {
ScanCodeView(verifyCode) { }
}
}
SimpleButton(generalGetString(R.string.mark_code_verified), Icons.Outlined.VerifiedUser) {
verifyCode(connectionCode) { verified ->
if (!verified) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.incorrect_code)
)
}
}
}
}
}
}
}
private fun splitToParts(s: String, length: Int): String {
if (length >= s.length) return s
return (0..(s.length - 1) / length)
.map { s.drop(it * length).take(length) }
.joinToString(separator = "\n")
}

View File

@@ -31,17 +31,23 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
@Composable
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, 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,
creatingGroup = creatingGroup,
contactsToAdd = getContactsToAdd(chatModel),
selectedContacts = selectedContacts,
selectedRole = selectedRole,
allowModifyMembers = allowModifyMembers,
openPreferences = {
ModalManager.shared.showCustomModal { close ->
GroupPreferencesView(chatModel, groupInfo.id, close)
}
},
inviteMembers = {
allowModifyMembers = false
withApi {
@@ -59,6 +65,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () ->
clearSelection = { selectedContacts.clear() },
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
close = close,
)
}
@@ -79,14 +86,17 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
@Composable
fun AddGroupMembersLayout(
groupInfo: GroupInfo,
creatingGroup: Boolean,
contactsToAdd: List<Contact>,
selectedContacts: List<Long>,
selectedRole: MutableState<GroupMemberRole>,
allowModifyMembers: Boolean,
openPreferences: () -> Unit,
inviteMembers: () -> Unit,
clearSelection: () -> Unit,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit,
close: () -> Unit,
) {
Column(
Modifier
@@ -120,18 +130,28 @@ fun AddGroupMembersLayout(
}
} else {
SectionView {
if (creatingGroup) {
SectionItemView(openPreferences) {
Text(stringResource(R.string.set_group_preferences))
}
SectionDivider()
}
SectionItemView {
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
}
SectionDivider()
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
if (creatingGroup && selectedContacts.isEmpty()) {
SkipInvitingButton(close)
} else {
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
}
}
SectionCustomFooter {
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
}
SectionSpacer()
SectionView {
SectionView(stringResource(R.string.select_contacts)) {
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
}
SectionSpacer()
@@ -170,6 +190,17 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
)
}
@Composable
fun SkipInvitingButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Check,
stringResource(R.string.skip_inviting_button),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
)
}
@Composable
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
Row(
@@ -288,14 +319,17 @@ fun PreviewAddGroupMembersLayout() {
SimpleXTheme {
AddGroupMembersLayout(
groupInfo = GroupInfo.sampleData,
creatingGroup = false,
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
selectedContacts = remember { mutableStateListOf() },
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
allowModifyMembers = true,
openPreferences = {},
inviteMembers = {},
clearSelection = {},
addContact = {},
removeContact = {}
removeContact = {},
close = {},
)
}
}

View File

@@ -4,7 +4,9 @@ import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -22,15 +24,17 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
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) {
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdated: (String?) -> Unit, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
@@ -43,32 +47,56 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedBy { it.displayName.lowercase() },
developerTools,
groupLink,
addMembers = {
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, chatModel, close)
AddGroupMembersView(groupInfo, false, chatModel, close)
}
}
},
showMemberInfo = { member ->
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
val (_, code) = if (member.memberActive) {
try {
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
member to null
}
} else {
member to null
}
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
closeCurrent()
close()
}
}
}
}
},
editGroupProfile = {
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
openPreferences = {
ModalManager.shared.showCustomModal { close ->
GroupPreferencesView(
chatModel,
chat.id,
close
)
}
},
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
manageGroupLink = {
withApi {
val groupLink = chatModel.controller.apiGetGroupLink(groupInfo.groupId)
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink) }
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, onGroupLinkUpdated) }
}
}
)
@@ -117,9 +145,11 @@ fun GroupChatInfoLayout(
groupInfo: GroupInfo,
members: List<GroupMember>,
developerTools: Boolean,
groupLink: String?,
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
openPreferences: () -> Unit,
deleteGroup: () -> Unit,
clearChat: () -> Unit,
leaveGroup: () -> Unit,
@@ -139,9 +169,25 @@ 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() }
SectionItemView(manageGroupLink) {
if (groupLink == null) {
CreateGroupLinkButton()
} else {
GroupLinkButton()
}
}
SectionDivider()
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
SectionItemView(onAddMembersClick) {
@@ -160,10 +206,6 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
SectionDivider()
}
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
SectionDivider()
@@ -188,7 +230,7 @@ fun GroupChatInfoLayout(
}
@Composable
fun GroupChatInfoHeader(cInfo: ChatInfo) {
private fun GroupChatInfoHeader(cInfo: ChatInfo) {
Column(
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
@@ -212,7 +254,16 @@ fun GroupChatInfoHeader(cInfo: ChatInfo) {
}
@Composable
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
private fun GroupPreferencesButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.group_preferences),
click = onClick
)
}
@Composable
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
Row(
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
@@ -228,7 +279,7 @@ fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
}
@Composable
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
Column {
members.forEachIndexed { index, member ->
SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
@@ -242,7 +293,7 @@ fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Uni
}
@Composable
fun MemberRow(member: GroupMember, user: Boolean = false) {
private fun MemberRow(member: GroupMember, user: Boolean = false) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -254,10 +305,15 @@ fun MemberRow(member: GroupMember, user: Boolean = false) {
) {
ProfileImage(size = 46.dp, member.image)
Column {
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.verified) {
MemberVerifiedShield()
}
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
}
val s = member.memberStatus.shortText
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
Text(
@@ -277,7 +333,12 @@ fun MemberRow(member: GroupMember, user: Boolean = false) {
}
@Composable
fun GroupLinkButton() {
private fun MemberVerifiedShield() {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 3.dp).size(16.dp), tint = HighOrLowlight)
}
@Composable
private fun GroupLinkButton() {
Row(
Modifier
.fillMaxSize(),
@@ -286,10 +347,27 @@ 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))
}
}
@Composable
private fun CreateGroupLinkButton() {
Row(
Modifier
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.AddLink,
stringResource(R.string.create_group_link),
tint = HighOrLowlight
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.create_group_link))
}
}
@@ -303,15 +381,15 @@ 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))
}
}
@Composable
fun LeaveGroupButton() {
private fun LeaveGroupButton() {
Row(
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
@@ -327,7 +405,7 @@ fun LeaveGroupButton() {
}
@Composable
fun DeleteGroupButton() {
private fun DeleteGroupButton() {
Row(
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
@@ -355,7 +433,8 @@ fun PreviewGroupChatInfoLayout() {
groupInfo = GroupInfo.sampleData,
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
groupLink = null,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
)
}
}

View File

@@ -1,10 +1,12 @@
package chat.simplex.app.views.chat.group
import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Text
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
@@ -15,22 +17,32 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.GroupInfo
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?) {
var groupLink by remember { mutableStateOf(connReqContact) }
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, onGroupLinkUpdated: (String?) -> Unit) {
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
var creatingLink by rememberSaveable { mutableStateOf(false) }
val cxt = LocalContext.current
fun createLink() {
creatingLink = true
withApi {
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
onGroupLinkUpdated(groupLink)
creatingLink = false
}
}
LaunchedEffect(Unit) {
if (groupLink == null && !creatingLink) {
createLink()
}
}
GroupLinkLayout(
groupLink = groupLink,
createLink = {
withApi {
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
}
},
creatingLink,
createLink = ::createLink,
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
deleteLink = {
AlertManager.shared.showAlertMsg(
@@ -42,17 +54,22 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
if (r) {
groupLink = null
onGroupLinkUpdated(null)
}
}
}
)
}
)
if (creatingLink) {
ProgressIndicator()
}
}
@Composable
fun GroupLinkLayout(
groupLink: String?,
creatingLink: Boolean,
createLink: () -> Unit,
share: () -> Unit,
deleteLink: () -> Unit
@@ -74,7 +91,7 @@ fun GroupLinkLayout(
verticalArrangement = Arrangement.SpaceEvenly
) {
if (groupLink == null) {
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, click = createLink)
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink)
} else {
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f))
Row(
@@ -99,3 +116,18 @@ fun GroupLinkLayout(
}
}
@Composable
fun ProgressIndicator() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}

View File

@@ -5,6 +5,7 @@ import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -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
@@ -21,19 +23,20 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.SimplexServers
import chat.simplex.app.views.chat.SwitchAddressButton
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import kotlinx.datetime.Clock
@Composable
fun GroupMemberInfoView(
groupInfo: GroupInfo,
member: GroupMember,
connStats: ConnectionStats?,
connectionCode: String?,
chatModel: ChatModel,
close: () -> Unit,
closeAll: () -> Unit, // Close all open windows up to ChatView
@@ -49,20 +52,27 @@ fun GroupMemberInfoView(
connStats,
newRole,
developerTools,
openDirectChat = {
connectionCode,
getContactChat = { chatModel.getContactChat(it) },
knownDirectChat = {
withApi {
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
if (oldChat != null) {
openChat(oldChat.chatInfo, chatModel)
} else {
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
chatModel.chatItems.clear()
chatModel.chatItems.addAll(it.chatItems)
chatModel.chatId.value = it.chatInfo.id
closeAll()
}
},
newDirectChat = {
withApi {
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
if (c != null) {
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
newChat = newChat.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
val newChat = c.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
chatModel.addChat(newChat)
chatModel.chatItems.clear()
chatModel.chatId.value = newChat.id
closeAll()
}
closeAll()
}
},
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
@@ -85,6 +95,32 @@ fun GroupMemberInfoView(
},
switchMemberAddress = {
switchMemberAddress(chatModel, groupInfo, member)
},
verifyClicked = {
ModalManager.shared.showModalCloseable { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
connectionCode,
mem.verified,
verify = { code ->
chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r ->
val (verified, existingCode) = r
chatModel.upsertGroupMember(
groupInfo,
mem.copy(
activeConn = mem.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
)
r
}
},
close,
)
}
}
}
)
}
@@ -114,10 +150,14 @@ fun GroupMemberInfoLayout(
connStats: ConnectionStats?,
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
openDirectChat: () -> Unit,
connectionCode: String?,
getContactChat: (Long) -> Chat?,
knownDirectChat: (Chat) -> Unit,
newDirectChat: (Long) -> Unit,
removeMember: () -> Unit,
onRoleSelected: (GroupMemberRole) -> Unit,
switchMemberAddress: () -> Unit,
verifyClicked: () -> Unit,
) {
Column(
Modifier
@@ -133,10 +173,29 @@ fun GroupMemberInfoLayout(
}
SectionSpacer()
SectionView {
OpenChatButton(openDirectChat)
if (member.memberActive) {
val contactId = member.memberContactId
if (contactId != null) {
SectionView {
val chat = getContactChat(contactId)
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
OpenChatButton(onClick = { knownDirectChat(chat) })
if (connectionCode != null) {
SectionDivider()
}
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
OpenChatButton(onClick = { newDirectChat(contactId) })
if (connectionCode != null) {
SectionDivider()
}
}
if (connectionCode != null) {
VerifyCodeButton(member.verified, verifyClicked)
}
}
SectionSpacer()
}
}
SectionSpacer()
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
@@ -159,12 +218,10 @@ fun GroupMemberInfoLayout(
}
}
SectionSpacer()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
if (developerTools) {
SwitchAddressButton(switchMemberAddress)
SectionDivider()
}
if (connStats != null) {
if (connStats != null) {
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SwitchAddressButton(switchMemberAddress)
SectionDivider()
val rcvServers = connStats.rcvServers
val sndServers = connStats.sndServers
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
@@ -179,8 +236,8 @@ fun GroupMemberInfoLayout(
}
}
}
SectionSpacer()
}
SectionSpacer()
if (member.canBeRemoved(groupInfo)) {
SectionView {
@@ -207,12 +264,17 @@ fun GroupMemberInfoHeader(member: GroupMember) {
horizontalAlignment = Alignment.CenterHorizontally
) {
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.verified) {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
}
Text(
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (member.fullName != "" && member.fullName != member.displayName) {
Text(
member.fullName, style = MaterialTheme.typography.h2,
@@ -302,10 +364,14 @@ fun PreviewGroupMemberInfoLayout() {
connStats = null,
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
openDirectChat = {},
connectionCode = "123",
getContactChat = { Chat.sampleData },
knownDirectChat = {},
newDirectChat = {},
removeMember = {},
onRoleSelected = {},
switchMemberAddress = {},
verifyClicked = {},
)
}
}

View File

@@ -0,0 +1,183 @@
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.runtime.saveable.rememberSaveable
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.chat.TimedMessagesTTLPicker
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
@Composable
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } }
val gInfo = groupInfo.value ?: return
var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) }
fun savePrefs(afterSave: () -> Unit = {}) {
withApi {
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
if (gInfo != null) {
m.updateGroup(gInfo)
currentPreferences = preferences
}
afterSave()
}
}
ModalView(
close = {
if (preferences == currentPreferences) close()
else showUnsavedChangesAlert({ savePrefs(close) }, close)
},
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupPreferencesLayout(
preferences,
currentPreferences,
gInfo,
applyPrefs = { prefs ->
preferences = prefs
},
reset = {
preferences = currentPreferences
},
savePrefs = ::savePrefs,
)
}
}
@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 timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
val onTTLUpdated = { ttl: Int? ->
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl ?: 86400)))
}
FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
if (enable == GroupFeatureEnabled.ON) {
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400)))
} else {
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl)))
}
}
SectionSpacer()
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
}
SectionSpacer()
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
}
SectionSpacer()
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
}
if (groupInfo.canEdit) {
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = preferences == currentPreferences
)
}
}
}
@Composable
private fun FeatureSection(
feature: GroupFeature,
enableFeature: State<GroupFeatureEnabled>,
groupInfo: GroupInfo,
preferences: FullGroupPreferences,
onTTLUpdated: (Int?) -> Unit,
onSelected: (GroupFeatureEnabled) -> Unit
) {
SectionView {
val on = enableFeature.value == GroupFeatureEnabled.ON
val icon = if (on) feature.iconFilled else feature.icon
val iconTint = if (on) SimplexGreen else HighOrLowlight
val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON
if (groupInfo.canEdit) {
SectionItemView {
PreferenceToggleWithIcon(
feature.text,
icon,
iconTint,
enableFeature.value == GroupFeatureEnabled.ON,
) { checked ->
onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF)
}
}
if (timedOn) {
SectionDivider()
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
TimedMessagesTTLPicker(ttl, onTTLUpdated)
}
} else {
InfoRow(
feature.text,
enableFeature.value.text,
icon = icon,
iconTint = iconTint,
)
if (timedOn) {
SectionDivider()
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
}
}
}
SectionTextFooter(feature.enableDescription(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)
}
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(R.string.save_preferences_question),
confirmText = generalGetString(R.string.save_and_notify_group_members),
dismissText = generalGetString(R.string.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}

View File

@@ -24,7 +24,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 +38,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(durationText(duration), color = HighOrLowlight)
}
CICallStatus.Error -> {}
}

View File

@@ -0,0 +1,34 @@
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.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
@Composable
fun CIChatFeatureView(
chatItem: ChatItem,
feature: Feature,
iconColor: Color,
icon: ImageVector? = null
) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(icon ?: feature.iconFilled, feature.text, Modifier.size(18.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,38 @@ 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) }
@Composable
fun chatEventTextView(text: AnnotatedString) {
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
}
Surface {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.Bottom
verticalAlignment = Alignment.CenterVertically
) {
Text(
buildAnnotatedString {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
withGroupEventStyle(this, memberDisplayName)
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
chatEventTextView(
buildAnnotatedString {
withStyle(chatEventStyle) { append(memberDisplayName) }
append(" ")
}
withGroupEventStyle(this, ci.content.text)
append(" ")
withGroupEventStyle(this, ci.timestampText)
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
)
}.plus(chatEventText(ci))
)
} else {
chatEventTextView(chatEventText(ci))
}
}
}
}
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
fun chatEventText(ci: ChatItem): AnnotatedString =
buildAnnotatedString {
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,

View File

@@ -0,0 +1,54 @@
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.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun CIFeaturePreferenceView(
chatItem: ChatItem,
contact: Contact?,
feature: ChatFeature,
allowed: FeatureAllowed,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = HighOrLowlight)
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
val acceptTextId = if (setParam) R.string.accept_feature_set_1_day else R.string.accept_feature
val param = if (setParam) 86400 else null
val annotatedText = buildAnnotatedString {
withStyle(chatEventStyle) { append(chatItem.content.text + " ") }
withAnnotation(tag = "Accept", annotation = "Accept") {
withStyle(acceptStyle) { append(generalGetString(acceptTextId) + " ") }
}
withStyle(chatEventStyle) { append(chatItem.timestampText) }
}
fun accept(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Accept", start = offset, end = offset).isNotEmpty()
ClickableText(
annotatedText,
onClick = { if (accept(it)) { acceptFeature(contact, feature, param) } },
shouldConsumeEvent = ::accept
)
} else {
Text(chatItem.content.text + " " + chatItem.timestampText,
fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
}
}
}

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

@@ -3,61 +3,85 @@ package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import kotlinx.datetime.Clock
@Composable
fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!chatItem.isDeletedContent) {
if (chatItem.meta.itemEdited) {
Icon(
Icons.Filled.Edit,
modifier = Modifier.height(12.dp).padding(end = 1.dp),
contentDescription = stringResource(R.string.icon_descr_edited),
tint = metaColor,
)
}
CIStatusView(chatItem.meta.itemStatus, metaColor)
fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = HighOrLowlight) {
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
if (chatItem.isDeletedContent) {
Text(
chatItem.timestampText,
color = metaColor,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
} else {
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor)
}
Text(
chatItem.timestampText,
color = metaColor,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
}
}
@Composable
// changing this function requires updating reserveSpaceForMeta
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
if (meta.itemEdited) {
StatusIconText(Icons.Outlined.Edit, color)
Spacer(Modifier.width(3.dp))
}
if (meta.disappearing) {
StatusIconText(Icons.Outlined.Timer, color)
val ttl = meta.itemTimed?.ttl
if (ttl != chatTTL) {
Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
}
Spacer(Modifier.width(4.dp))
}
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
if (statusIcon != null) {
val (icon, statusColor) = statusIcon
StatusIconText(icon, statusColor)
Spacer(Modifier.width(4.dp))
} else if (!meta.disappearing) {
StatusIconText(Icons.Filled.Circle, Color.Transparent)
Spacer(Modifier.width(4.dp))
}
Text(meta.timestampText, color = color, fontSize = 13.sp)
}
// the conditions in this function should match CIMetaText
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
val iconSpace = " "
var res = ""
if (meta.itemEdited) res += iconSpace
if (meta.itemTimed != null) {
res += iconSpace
val ttl = meta.itemTimed?.ttl
if (ttl != chatTTL) {
res += TimedMessagesPreference.shortTtlText(ttl)
}
}
if (meta.statusIcon(HighOrLowlight) != null || !meta.disappearing) {
res += iconSpace
}
return res + meta.timestampText
}
@Composable
fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
when (status) {
is CIStatus.SndSent -> {
Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
}
is CIStatus.SndErrorAuth -> {
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
}
is CIStatus.SndError -> {
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
}
is CIStatus.RcvNew -> {
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
}
else -> {}
}
private fun StatusIconText(icon: ImageVector, color: Color) {
Icon(icon, null, Modifier.height(12.dp), tint = color)
}
@Preview
@@ -66,7 +90,8 @@ fun PreviewCIMetaView() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
),
null
)
}
@@ -77,7 +102,8 @@ fun PreviewCIMetaViewUnread() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.RcvNew()
)
),
null
)
}
@@ -87,8 +113,9 @@ fun PreviewCIMetaViewSendFailed() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX()))
)
status = CIStatus.SndError("CMD SYNTAX")
),
null
)
}
@@ -98,7 +125,8 @@ fun PreviewCIMetaViewSendNoAuth() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
)
),
null
)
}
@@ -108,7 +136,8 @@ fun PreviewCIMetaViewSendSent() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
)
),
null
)
}
@@ -119,7 +148,8 @@ fun PreviewCIMetaViewEdited() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true
)
),
null
)
}
@@ -131,7 +161,8 @@ fun PreviewCIMetaViewEditedUnread() {
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
itemEdited = true,
status=CIStatus.RcvNew()
)
),
null
)
}
@@ -143,7 +174,8 @@ fun PreviewCIMetaViewEditedSent() {
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true,
status=CIStatus.SndSent()
)
),
null
)
}
@@ -151,6 +183,7 @@ fun PreviewCIMetaViewEditedSent() {
@Composable
fun PreviewCIMetaViewDeletedContent() {
CIMetaView(
chatItem = ChatItem.getDeletedContentSampleData()
chatItem = ChatItem.getDeletedContentSampleData(),
null
)
}

View File

@@ -0,0 +1,260 @@
package chat.simplex.app.views.chat.item
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.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
@Composable
fun CIVoiceView(
providedDurationSec: Int,
file: CIFile?,
edited: Boolean,
sent: Boolean,
hasText: Boolean,
ci: ChatItem,
timedMessagesTTL: Int?,
longClick: () -> Unit,
) {
Row(
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 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 text = remember {
derivedStateOf {
val time = when {
audioPlaying.value || progress.value != 0 -> progress.value
else -> duration.value
}
durationText(time / 1000)
}
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick)
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
val metaReserve = if (edited)
" "
else
" "
Text(metaReserve)
}
}
}
@Composable
private fun VoiceLayout(
file: CIFile,
ci: ChatItem,
text: State<String>,
audioPlaying: State<Boolean>,
progress: State<Int>,
duration: State<Int>,
brokenAudio: Boolean,
sent: Boolean,
hasText: Boolean,
timedMessagesTTL: Int?,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
) {
when {
hasText -> {
Spacer(Modifier.width(6.dp))
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
DurationText(text, PaddingValues(start = 12.dp))
}
sent -> {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
DurationText(text, PaddingValues(end = 12.dp))
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
}
}
}
}
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, timedMessagesTTL)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
DurationText(text, PaddingValues(start = 12.dp))
Spacer(Modifier.height(56.dp))
}
}
}
}
}
@Composable
private fun DurationText(text: State<String>, padding: PaddingValues) {
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
Text(
text.value,
Modifier
.padding(padding)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
}
@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.size(56.dp).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
@@ -21,35 +20,41 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ComposeContextItem
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
@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,
acceptCall: (Contact) -> Unit,
scrollToItem: (Long) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> 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) }
val revealed = remember { mutableStateOf(false) }
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
val onLinkLongClick = { _: String -> showMenu.value = true }
Box(
modifier = Modifier
.padding(bottom = 4.dp)
@@ -62,7 +67,7 @@ fun ChatItemView(
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
}
is CIStatus.SndError -> {
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError.string}")
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
}
else -> {}
}
@@ -70,87 +75,134 @@ fun ChatItemView(
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable fun ContentItem() {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
fun deleteMessageQuestionText(): String {
return if (fullDeleteAllowed) {
generalGetString(R.string.delete_message_cannot_be_undone_warning)
} else {
val onLinkLongClick = { _: String -> showMenu.value = true }
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem)
generalGetString(R.string.delete_message_mark_deleted_warning)
}
}
@Composable
fun MsgContentItemDropdownMenu() {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
if (!cItem.meta.itemDeleted) {
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
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
})
}
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
if (cItem.meta.itemDeleted && revealed.value) {
ItemAction(
stringResource(R.string.hide_verb),
Icons.Outlined.VisibilityOff,
onClick = {
revealed.value = false
showMenu.value = false
}
)
}
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, showMember = showMember)
@Composable
fun MarkedDeletedItemDropdownMenu() {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
stringResource(R.string.reveal_verb),
Icons.Outlined.Visibility,
onClick = {
revealed.value = true
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
}
)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted && !revealed.value) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemDropdownMenu()
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted && !cItem.meta.isLive) {
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem, cInfo.timedMessagesTTL)
MsgContentItemDropdownMenu()
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
MsgContentItemDropdownMenu()
} else {
framedItemView()
MsgContentItemDropdownMenu()
}
} else {
framedItemView()
MsgContentItemDropdownMenu()
}
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@@ -165,18 +217,47 @@ fun ChatItemView(
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
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.RcvChatPreference -> {
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
}
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, HighOrLowlight, icon = c.feature.icon,)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
}
}
}
}
@Composable
fun DeleteItemAction(
cItem: ChatItem,
showMenu: MutableState<Boolean>,
questionText: String,
deleteMessage: (Long, CIDeleteMode) -> Unit
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
@Composable
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
DropdownMenuItem(onClick) {
@@ -194,10 +275,10 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
}
}
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(R.string.delete_message__question),
text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
text = questionText,
buttons = {
Row(
Modifier
@@ -233,20 +314,19 @@ private fun showMsgDeliveryErrorAlert(description: String) {
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 = {},
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> }
)
}
}
@@ -256,18 +336,17 @@ 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 = {},
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> }
)
}
}

View File

@@ -18,7 +18,7 @@ import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
Surface(
shape = RoundedCornerShape(18.dp),
color = ReceivedColorLight,
@@ -35,7 +35,7 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci)
CIMetaView(ci, timedMessagesTTL)
}
}
}
@@ -49,7 +49,8 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
fun PreviewDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getDeletedContentSampleData()
ChatItem.getDeletedContentSampleData(),
null
)
}
}

View File

@@ -15,13 +15,13 @@ val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
@Composable
fun EmojiItemView(chatItem: ChatItem) {
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
Column(
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(chatItem.content.text)
CIMetaView(chatItem)
CIMetaView(chatItem, timedMessagesTTL)
}
}

View File

@@ -5,7 +5,8 @@ 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.material.icons.outlined.Delete
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -13,18 +14,19 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.fastMap
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ChatItemLinkView
import chat.simplex.app.views.helpers.base64ToBitmap
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
val SentColorLight = Color(0x1E45B8FF)
@@ -39,12 +41,14 @@ fun FramedItemView(
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
linkMode: SimplexLinkMode,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
onLinkLongClick: (link: String) -> Unit = {},
scrollToItem: (Long) -> Unit = {},
) {
val sent = ci.chatDir.sent
val chatTTL = chatInfo.timedMessagesTTL
fun membership(): GroupMember? {
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
@@ -58,7 +62,39 @@ 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
)
}
}
@Composable
fun FramedItemHeader(caption: String, italic: Boolean, icon: ImageVector? = null) {
Row(
Modifier
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
.fillMaxWidth()
.padding(start = 8.dp)
.padding(end = 12.dp)
.padding(top = 6.dp)
.padding(bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (icon != null) {
Icon(
icon,
caption,
Modifier.size(18.dp),
tint = if (isInDarkTheme()) FileDark else FileLight
)
}
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = HighOrLowlight)) {
append(caption)
}
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
)
}
}
@@ -87,13 +123,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 +141,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 != "" || ci.meta.isLive) {
CIMarkdownText(ci, chatTTL, showMember, linkMode = linkMode, uriHandler)
}
}
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
Box(Modifier
.clip(RoundedCornerShape(18.dp))
.background(
@@ -119,8 +164,13 @@ fun FramedItemView(
Box(contentAlignment = Alignment.BottomEnd) {
Column(Modifier.width(IntrinsicSize.Max)) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
if (ci.meta.itemDeleted) {
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete)
} else if (ci.meta.isLive) {
FramedItemHeader(stringResource(R.string.live), false)
}
ci.quotedItem?.let { ciQuoteView(it) }
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
Column(
Modifier
@@ -136,29 +186,36 @@ fun FramedItemView(
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
if (mc.text == "") {
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
CIMarkdownText(ci, showMember, uriHandler)
CIMarkdownText(ci, chatTTL, 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, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
if (mc.text != "") {
CIMarkdownText(ci, showMember, uriHandler)
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
}
}
is MsgContent.MCFile -> ciFileView(ci, mc.text)
is MsgContent.MCUnknown ->
if (ci.file == null) {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
} else {
ciFileView(ci, mc.text)
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
}
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
}
}
}
}
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
CIMetaView(ci, chatTTL, metaColor)
}
}
}
@@ -167,14 +224,17 @@ fun FramedItemView(
@Composable
fun CIMarkdownText(
ci: ChatItem,
chatTTL: Int?,
showMember: Boolean,
linkMode: SimplexLinkMode,
uriHandler: UriHandler?,
onLinkLongClick: (link: String) -> Unit = {}
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
MarkdownText(
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
metaText = ci.timestampText, edited = ci.meta.itemEdited,
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null,
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
)
}
@@ -225,6 +285,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 +302,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 +323,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 +345,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 +367,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 +396,7 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -359,6 +425,7 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -386,6 +453,7 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)

View File

@@ -77,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

@@ -22,7 +22,7 @@ import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
Surface(
Modifier.clickable(onClick = {
AlertManager.shared.showAlertMsg(
@@ -45,7 +45,7 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci)
CIMetaView(ci, timedMessagesTTL)
}
}
}
@@ -59,7 +59,8 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
fun IntegrityErrorItemViewPreview() {
SimpleXTheme {
IntegrityErrorItemView(
ChatItem.getDeletedContentSampleData()
ChatItem.getDeletedContentSampleData(),
null
)
}
}

View File

@@ -0,0 +1,58 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
Surface(
shape = RoundedCornerShape(18.dp),
color = if (ci.chatDir.sent) SentColorLight else ReceivedColorLight,
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
buildAnnotatedString {
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci, timedMessagesTTL)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun PreviewMarkedDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getSampleData(itemDeleted = true),
null
)
}
}

View File

@@ -1,20 +1,30 @@
package chat.simplex.app.views.chat.item
import android.app.Activity
import android.content.ActivityNotFoundException
import android.util.Log
import androidx.annotation.IntRange
import androidx.compose.foundation.text.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
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
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.detectGesture
import kotlinx.coroutines.*
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
@@ -36,40 +46,97 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea
}
}
private val noTyping: AnnotatedString = AnnotatedString(" ")
private val typingIndicators: List<AnnotatedString> = listOf(
typing(FontWeight.Black) + typing() + typing(),
typing(FontWeight.Bold) + typing(FontWeight.Black) + typing(),
typing() + typing(FontWeight.Bold) + typing(FontWeight.Black),
typing() + typing() + typing(FontWeight.Bold)
)
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
pushStyle(SpanStyle(color = HighOrLowlight, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
append(if (recent) typingIndicators[typingIdx] else noTyping)
}
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
AnnotatedString(".", SpanStyle(fontWeight = w))
@Composable
fun MarkdownText (
text: String,
formattedText: List<FormattedText>? = null,
sender: String? = null,
metaText: String? = null,
edited: Boolean = false,
meta: CIMeta? = null,
chatTTL: Int? = null,
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
uriHandler: UriHandler? = null,
senderBold: Boolean = false,
modifier: Modifier = Modifier,
linkMode: SimplexLinkMode,
onLinkLongClick: (link: String) -> Unit = {}
) {
val textLayoutDirection = remember (text) {
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
}
val reserve = when {
textLayoutDirection != LocalLayoutDirection.current && metaText != null -> "\n"
edited -> " "
else -> " "
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
"\n"
} else if (meta != null) {
reserveSpaceForMeta(meta, chatTTL)
} else {
" "
}
val scope = rememberCoroutineScope()
CompositionLocalProvider(
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current)
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
else
LocalLayoutDirection.current
) {
var timer: Job? by remember { mutableStateOf(null) }
var typingIdx by rememberSaveable { mutableStateOf(0) }
fun stopTyping() {
timer?.cancel()
timer = null
}
fun switchTyping() {
if (meta != null && meta.isLive && meta.recent) {
timer = timer ?: scope.launch {
while (isActive) {
typingIdx = (typingIdx + 1) % typingIndicators.size
delay(250)
}
}
} else {
stopTyping()
}
}
if (meta?.isLive == true) {
val activity = LocalContext.current as Activity
LaunchedEffect(meta.recent, meta.isLive) {
switchTyping()
}
DisposableEffect(Unit) {
val orientation = activity.resources.configuration.orientation
onDispose {
if (orientation == activity.resources.configuration.orientation) {
stopTyping()
}
}
}
}
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
append(text)
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
} else {
@@ -79,22 +146,29 @@ 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) }
}
}
}
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
// With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good
/*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl)
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
else */if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
@@ -104,7 +178,15 @@ fun MarkdownText (
},
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
.firstOrNull()?.let { annotation ->
try {
uriHandler.openUri(annotation.item)
} catch (e: ActivityNotFoundException) {
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
}
}
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()

View File

@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.annotatedStringResource
import chat.simplex.app.views.usersettings.MarkdownHelpView
import chat.simplex.app.views.usersettings.simplexTeamUri
val bold = SpanStyle(fontWeight = FontWeight.Bold)
@@ -76,6 +77,15 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
}
Column(
Modifier.padding(vertical = 24.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(stringResource(R.string.markdown_in_messages), style = MaterialTheme.typography.h2)
MarkdownHelpView()
}
}
}

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 by remember { chatModel.controller.appPrefs.simplexLinkMode.state }
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

@@ -22,12 +22,16 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.connectIfOpenedViaUri
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.onboarding.WhatsNewView
import chat.simplex.app.views.onboarding.shouldShowWhatsNew
import chat.simplex.app.views.usersettings.SettingsView
import chat.simplex.app.views.usersettings.simplexTeamUri
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@@ -41,9 +45,22 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
if (animated) newChatSheetState.value = NewChatSheetState.HIDING
else newChatSheetState.value = NewChatSheetState.GONE
}
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
ModalManager.shared.showCustomModal { close -> WhatsNewView(close = close) }
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
}
LaunchedEffect(chatModel.appOpenUrl.value) {
val url = chatModel.appOpenUrl.value
if (url != null) {
chatModel.appOpenUrl.value = null
connectIfOpenedViaUri(url, chatModel)
}
}
var searchInList by rememberSaveable { mutableStateOf("") }
val scaffoldState = rememberScaffoldState()
Scaffold(

View File

@@ -6,8 +6,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -26,7 +25,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
@@ -63,11 +62,21 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
)
}
@Composable
fun VerifiedIcon() {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
}
@Composable
fun chatPreviewTitle() {
when (cInfo) {
is ChatInfo.Direct ->
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
Row(verticalAlignment = Alignment.CenterVertically) {
if (cInfo.contact.verified) {
VerifiedIcon()
}
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
}
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
@@ -83,11 +92,11 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
MarkdownText(
ci.text,
ci.formattedText,
if (!ci.meta.itemDeleted) ci.text else generalGetString(R.string.marked_deleted_description),
if (!ci.meta.itemDeleted) ci.formattedText else null,
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
linkMode = linkMode,
senderBold = true,
metaText = null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
@@ -232,6 +241,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

@@ -84,6 +84,7 @@ fun DatabaseView(
chatArchiveTime,
chatLastStart,
chatDbDeleted.value,
m.controller.appPrefs.privacyFullBackup,
appFilesCountAndSize,
chatItemTTL,
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
@@ -126,12 +127,13 @@ fun DatabaseLayout(
chatDbChanged: Boolean,
useKeyChain: Boolean,
chatDbEncrypted: Boolean?,
initialRandomDBPassphrase: Preference<Boolean>,
initialRandomDBPassphrase: SharedPreference<Boolean>,
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
chatDbDeleted: Boolean,
privacyFullBackup: SharedPreference<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
chatItemTTL: MutableState<ChatItemTTL>,
startChat: () -> Unit,
@@ -165,6 +167,8 @@ fun DatabaseLayout(
disabled = operationsDisabled
)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.Backup, stringResource(R.string.full_backup), privacyFullBackup)
SectionDivider()
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.export_database),
@@ -409,7 +413,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,12 +687,13 @@ 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()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
chatDbDeleted = false,
privacyFullBackup = SharedPreference({ true }, {}),
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
startChat = {},

View File

@@ -1,11 +1,18 @@
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.*
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 alertViews = mutableStateListOf<(@Composable () -> Unit)>()
@@ -35,6 +42,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,
@@ -67,6 +94,41 @@ class AlertManager {
}
}
fun showAlertDialogStacked(
title: String,
text: String? = null,
confirmText: String = generalGetString(R.string.ok),
onConfirm: (() -> Unit)? = null,
dismissText: String = generalGetString(R.string.cancel_verb),
onDismiss: (() -> Unit)? = null,
onDismissRequest: (() -> Unit)? = null,
destructive: Boolean = false
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
title = { Text(title) },
text = alertText,
buttons = {
Column(
Modifier.fillMaxWidth().padding(horizontal = 8.dp).padding(top = 16.dp, bottom = 2.dp),
horizontalAlignment = Alignment.End
) {
TextButton(onClick = {
onDismiss?.invoke()
hideAlert()
}) { Text(dismissText) }
TextButton(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
}
},
)
}
}
fun showAlertMsg(
title: String, text: String? = null,
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
@@ -102,4 +164,4 @@ class AlertManager {
companion object {
val shared = AlertManager()
}
}
}

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

@@ -30,7 +30,7 @@ fun CloseSheetBar(close: () -> Unit) {
@Composable
fun AppBarTitle(title: String, withPadding: Boolean = true) {
val padding = if (withPadding)
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING )
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
else
PaddingValues(bottom = DEFAULT_PADDING)
Text(

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(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
val interactionSource = remember { 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

@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.graphics.*
import android.net.Uri
@@ -32,11 +33,9 @@ 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
@@ -177,6 +176,25 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
try {
launch(null)
} catch (e: ActivityNotFoundException) {
// No Activity found to handle Intent android.media.action.IMAGE_CAPTURE
// Means, no system camera app (Android 11+ requirement)
// https://developer.android.com/about/versions/11/behavior-changes-11#media-capture
Log.e(TAG, "Camera launcher: " + e.stackTraceToString())
try {
// Try to open any camera just to capture an image, will not be returned like with previous intent
SimplexApp.context.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) })
} catch (e: ActivityNotFoundException) {
// No camera apps available at all
Log.e(TAG, "Camera launcher2: " + e.stackTraceToString())
}
}
}
@Composable
fun GetImageBottomSheet(
imageBitmap: MutableState<Uri?>,
@@ -204,7 +222,7 @@ fun GetImageBottomSheet(
}
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launch(null)
cameraLauncher.launchWithFallback()
hideBottomSheet()
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
@@ -228,7 +246,7 @@ fun GetImageBottomSheet(
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launch(null)
cameraLauncher.launchWithFallback()
hideBottomSheet()
}
else -> {

View File

@@ -0,0 +1,285 @@
package chat.simplex.app.views.helpers
import android.content.Context
import android.media.*
import android.media.AudioManager.AudioPlaybackCallback
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
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 chat.simplex.app.views.helpers.AudioPlayer.duration
import kotlinx.coroutines.*
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
interface Recorder {
fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String
fun stop(): Int
}
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
}
private var recorder: MediaRecorder? = null
private var progressJob: Job? = null
private var filePath: String? = null
private var recStartedAt: Long? = null
private fun initRecorder() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(SimplexApp.context)
} else {
MediaRecorder()
}
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
AudioPlayer.stop()
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(MAX_VOICE_MILLIS_FOR_SENDING)
rec.setMaxFileSize(recordedBytesLimit)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val path = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
filePath = path
rec.setOutputFile(path)
rec.prepare()
rec.start()
recStartedAt = System.currentTimeMillis()
progressJob = CoroutineScope(Dispatchers.Default).launch {
while(isActive) {
onProgressUpdate(progress(), false)
delay(50)
}
}.apply {
invokeOnCompletion {
onProgressUpdate(realDuration(path), true)
}
}
rec.setOnInfoListener { _, what, _ ->
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED || what == MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) {
stop()
}
}
stopRecording = { stop() }
return path
}
override fun stop(): Int {
val path = filePath ?: return 0
stopRecording = null
runCatching {
recorder?.stop()
}
runCatching {
recorder?.reset()
}
runCatching {
recorder?.release()
}
// Await coroutine finishes in order to send real duration to it's listener
runBlocking {
progressJob?.cancelAndJoin()
}
progressJob = null
filePath = null
recorder = null
return (realDuration(path) ?: 0).also { recStartedAt = null }
}
private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() }
/**
* Return real duration from [AudioPlayer] if it's possible (should always be possible).
* As a fallback, return internally counted duration
* */
private fun realDuration(path: String): Int? = duration(path) ?: progress()
}
object AudioPlayer {
private val player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
(SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
.registerAudioPlaybackCallback(object: AudioPlaybackCallback() {
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
if (configs?.any { it.audioAttributes.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION } == true) {
// In a process of making a call
RecorderNative.stopRecording?.invoke()
stop()
}
super.onPlaybackConfigChanged(configs)
}
}, null)
}
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
}
// Filepath: String, onProgressUpdate
private val currentlyPlaying: MutableState<Pair<String, (position: Int?, state: TrackState) -> Unit>?> = mutableStateOf(null)
private var progressJob: Job? = null
enum class TrackState {
PLAYING, PAUSED, REPLACED
}
// Returns real duration of the track
private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> 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, TrackState.PLAYING)
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, TrackState.PLAYING)
break
}
delay(50)
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
}
/*
* 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, TrackState.PAUSED)
}
onProgressUpdate(null, TrackState.PAUSED)
}
return player.duration
}
private fun pause(): Int {
progressJob?.cancel()
progressJob = null
player.pause()
return player.currentPosition
}
fun stop() {
if (currentlyPlaying.value == null) 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() {
val afterCoroutineCancel: CompletionHandler = {
// Notify prev audio listener about stop
currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED)
currentlyPlaying.value = null
}
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.REPLACED] (in this order)
* */
if (progressJob != null) {
progressJob?.invokeOnCompletion(afterCoroutineCancel)
} else {
afterCoroutineCancel(null)
}
progressJob?.cancel()
progressJob = null
}
fun play(
filePath: String?,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
resetOnEnd: Boolean,
) {
if (progress.value == duration.value) {
progress.value = 0
}
val realDuration = start(filePath ?: return, progress.value) { pro, state ->
if (pro != null) {
progress.value = pro
}
if (pro == null || pro == duration.value) {
audioPlaying.value = false
if (pro == duration.value) {
progress.value = if (resetOnEnd) 0 else duration.value
} else if (state == TrackState.REPLACED) {
progress.value = 0
}
}
}
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: Int? = null
kotlin.runCatching {
helperPlayer.setDataSource(filePath)
helperPlayer.prepare()
helperPlayer.start()
helperPlayer.stop()
res = helperPlayer.duration
helperPlayer.reset()
}
return res
}
}

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)
@@ -158,9 +186,13 @@ fun SectionSpacer() {
}
@Composable
fun InfoRow(title: String, value: String) {
fun InfoRow(title: String, value: String, icon: ImageVector? = null, iconTint: Color? = null) {
SectionItemViewSpaceBetween {
Text(title)
Row {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: HighOrLowlight)
Text(title)
}
Text(value, color = HighOrLowlight)
}
}

View File

@@ -28,6 +28,22 @@ fun SimpleButton(text: String, icon: ImageVector,
}
}
@Composable
fun SimpleButton(
text: String, icon: ImageVector,
color: Color = MaterialTheme.colors.primary,
disabled: Boolean,
click: () -> Unit
) {
SimpleButtonFrame(click, disabled = disabled) {
Icon(
icon, text, tint = if (disabled) HighOrLowlight else color,
modifier = Modifier.padding(end = 8.dp)
)
Text(text, style = MaterialTheme.typography.caption, color = if (disabled) HighOrLowlight else color)
}
}
@Composable
fun SimpleButtonIconEnded(
text: String,

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.*
@@ -19,6 +17,7 @@ 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.*
@@ -30,7 +29,10 @@ import androidx.core.content.FileProvider
import androidx.core.text.HtmlCompat
import chat.simplex.app.*
import chat.simplex.app.model.CIFile
import chat.simplex.app.model.json
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
@@ -41,6 +43,9 @@ fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalSc
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
scope.launch { withContext(Dispatchers.Main, action) }
fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job =
CoroutineScope(Dispatchers.Default).launch(block = action)
enum class KeyboardState {
Opened, Closed
}
@@ -220,6 +225,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: Int = 43_000
const val MAX_FILE_SIZE: Long = 8000000
fun getFilesDirectory(context: Context): String {
@@ -449,3 +459,15 @@ fun Color.darker(factor: Float = 0.1f): Color =
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) }
)
/* Make sure that T class has @Serializable annotation */
inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
save = { json.encodeToString(it) },
restore = { json.decodeFromString(it) }
)

View File

@@ -47,7 +47,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
setGroupMembers(groupInfo, chatModel)
close.invoke()
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, chatModel, close)
AddGroupMembersView(groupInfo, true, chatModel, close)
}
}
}

View File

@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
enum class OnboardingStage {
Step1_SimpleXInfo,
Step2_CreateProfile,
Step3_SetNotificationsMode,
OnboardingComplete
}
@@ -34,6 +35,9 @@ fun CreateProfile(chatModel: ChatModel) {
.padding(20.dp)
) {
CreateProfilePanel(chatModel)
LaunchedEffect(Unit) {
setLastVersionDefault(chatModel)
}
if (savedKeyboardState != keyboardState) {
LaunchedEffect(keyboardState) {
scope.launch {

View File

@@ -0,0 +1,68 @@
package chat.simplex.app.views.onboarding
import androidx.annotation.StringRes
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.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
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.*
import chat.simplex.app.views.usersettings.NotificationsMode
import chat.simplex.app.views.usersettings.changeNotificationsMode
@Composable
fun SetNotificationsMode(m: ChatModel) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp)
) {
AppBarTitle(stringResource(R.string.onboarding_notifications_mode_title), false)
val currentMode = rememberSaveable { mutableStateOf(NotificationsMode.default) }
Text(stringResource(R.string.onboarding_notifications_mode_subtitle))
Spacer(Modifier.padding(DEFAULT_PADDING_HALF))
NotificationButton(currentMode, NotificationsMode.OFF, R.string.onboarding_notifications_mode_off, R.string.onboarding_notifications_mode_off_desc)
NotificationButton(currentMode, NotificationsMode.PERIODIC, R.string.onboarding_notifications_mode_periodic, R.string.onboarding_notifications_mode_periodic_desc)
NotificationButton(currentMode, NotificationsMode.SERVICE, R.string.onboarding_notifications_mode_service, R.string.onboarding_notifications_mode_service_desc)
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
OnboardingActionButton(R.string.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage) {
changeNotificationsMode(currentMode.value, m)
}
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}
}
@Composable
private fun NotificationButton(currentMode: MutableState<NotificationsMode>, mode: NotificationsMode, @StringRes title: Int, @StringRes description: Int) {
TextButton(
onClick = { currentMode.value = mode },
border = BorderStroke(1.dp, color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight.copy(alpha = 0.5f)),
shape = RoundedCornerShape(15.dp),
) {
Column(Modifier.padding(bottom = 6.dp).padding(horizontal = 8.dp)) {
Text(
stringResource(title),
style = MaterialTheme.typography.h2,
fontWeight = FontWeight.Medium,
color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(annotatedStringResource(description), color = MaterialTheme.colors.onBackground, lineHeight = 24.sp)
}
}
Spacer(Modifier.height(DEFAULT_PADDING))
}

View File

@@ -25,7 +25,6 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
@@ -105,14 +104,14 @@ private fun InfoRow(icon: Painter, @StringRes titleId: Int, @StringRes textId: I
@Composable
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
if (user == null) {
ActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
OnboardingActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
} else {
ActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
OnboardingActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
}
}
@Composable
private fun ActionButton(
fun OnboardingActionButton(
@StringRes labelId: Int,
onboarding: OnboardingStage?,
onboardingStage: MutableState<OnboardingStage?>,

View File

@@ -0,0 +1,259 @@
package chat.simplex.app.views.onboarding
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) }
@Composable
fun featureDescription(icon: ImageVector, titleId: Int, descrId: Int, link: String?) {
@Composable
fun linkButton(link: String) {
val uriHandler = LocalUriHandler.current
Icon(
Icons.Outlined.OpenInNew, stringResource(titleId), tint = MaterialTheme.colors.primary,
modifier = Modifier
.clickable { uriHandler.openUri(link) }
)
}
Column(
horizontalAlignment = Alignment.Start
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(icon, stringResource(titleId), tint = HighOrLowlight)
Text(
generalGetString(titleId),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Medium
)
if (link != null) {
linkButton(link)
}
}
Text(generalGetString(descrId))
}
}
@Composable
fun pagination() {
Row(
Modifier
.padding(bottom = 16.dp)
) {
if (currentVersion.value > 0) {
val prev = currentVersion.value - 1
Surface(shape = RoundedCornerShape(20.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.clickable { currentVersion.value = prev }
.padding(8.dp)
) {
Icon(Icons.Outlined.ArrowBackIosNew, "previous", tint = MaterialTheme.colors.primary)
Text(versionDescriptions[prev].version, color = MaterialTheme.colors.primary)
}
}
}
Spacer(Modifier.fillMaxWidth().weight(1f))
if (currentVersion.value < versionDescriptions.lastIndex) {
val next = currentVersion.value + 1
Surface(shape = RoundedCornerShape(20.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.clickable { currentVersion.value = next }
.padding(8.dp)
) {
Text(versionDescriptions[next].version, color = MaterialTheme.colors.primary)
Icon(Icons.Outlined.ArrowForwardIos, "next", tint = MaterialTheme.colors.primary)
}
}
}
}
}
val v = versionDescriptions[currentVersion.value]
ModalView(close = close) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
String.format(generalGetString(R.string.new_in_version), v.version),
Modifier
.fillMaxWidth()
.padding(DEFAULT_PADDING),
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Normal,
color = HighOrLowlight
)
v.features.forEach { feature ->
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link)
}
if (!viaSettings) {
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(
Modifier.fillMaxWidth(), contentAlignment = Alignment.Center
) {
Text(
generalGetString(R.string.ok),
modifier = Modifier.clickable(onClick = close),
style = MaterialTheme.typography.h3,
color = MaterialTheme.colors.primary
)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}
Spacer(Modifier.fillMaxHeight().weight(1f))
pagination()
}
}
}
private data class FeatureDescription(
val icon: ImageVector,
val titleId: Int,
val descrId: Int,
val link: String? = null
)
private data class VersionDescription(
val version: String,
val features: List<FeatureDescription>
)
private val versionDescriptions: List<VersionDescription> = listOf(
VersionDescription(
version = "v4.2",
features = listOf(
FeatureDescription(
icon = Icons.Outlined.VerifiedUser,
titleId = R.string.v4_2_security_assessment,
descrId = R.string.v4_2_security_assessment_desc,
link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"
),
FeatureDescription(
icon = Icons.Outlined.Group,
titleId = R.string.v4_2_group_links,
descrId = R.string.v4_2_group_links_desc
),
FeatureDescription(
icon = Icons.Outlined.Check,
titleId = R.string.v4_2_auto_accept_contact_requests,
descrId = R.string.v4_2_auto_accept_contact_requests_desc
),
)
),
VersionDescription(
version = "v4.3",
features = listOf(
FeatureDescription(
icon = Icons.Outlined.Mic,
titleId = R.string.v4_3_voice_messages,
descrId = R.string.v4_3_voice_messages_desc
),
FeatureDescription(
icon = Icons.Outlined.DeleteForever,
titleId = R.string.v4_3_irreversible_message_deletion,
descrId = R.string.v4_3_irreversible_message_deletion_desc
),
FeatureDescription(
icon = Icons.Outlined.WifiTethering,
titleId = R.string.v4_3_improved_server_configuration,
descrId = R.string.v4_3_improved_server_configuration_desc
),
FeatureDescription(
icon = Icons.Outlined.VisibilityOff,
titleId = R.string.v4_3_improved_privacy_and_security,
descrId = R.string.v4_3_improved_privacy_and_security_desc
),
)
),
VersionDescription(
version = "v4.4",
features = listOf(
FeatureDescription(
icon = Icons.Outlined.Timer,
titleId = R.string.v4_4_disappearing_messages,
descrId = R.string.v4_4_disappearing_messages_desc
),
FeatureDescription(
icon = Icons.Outlined.Pending,
titleId = R.string.v4_4_live_messages,
descrId = R.string.v4_4_live_messages_desc
),
FeatureDescription(
icon = Icons.Outlined.VerifiedUser,
titleId = R.string.v4_4_verify_connection_security,
descrId = R.string.v4_4_verify_connection_security_desc
)
)
)
)
private val lastVersion = versionDescriptions.last().version
fun setLastVersionDefault(m: ChatModel) {
m.controller.appPrefs.whatsNewVersion.set(lastVersion)
}
fun shouldShowWhatsNew(m: ChatModel): Boolean {
val v = m.controller.appPrefs.whatsNewVersion.get()
setLastVersionDefault(m)
return v != lastVersion
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewWhatsNewView() {
SimpleXTheme {
WhatsNewView(
viaSettings = true,
close = {}
)
}
}

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,9 +79,10 @@ private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: Sta
@Composable
fun SharedPreferenceToggle(
text: String,
preference: Preference<Boolean>,
preferenceState: MutableState<Boolean>? = null
) {
preference: SharedPreference<Boolean>,
preferenceState: MutableState<Boolean>? = null,
onChange: ((Boolean) -> Unit)? = null,
) {
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(text, Modifier.padding(end = 24.dp))
@@ -91,6 +92,7 @@ fun SharedPreferenceToggle(
onCheckedChange = {
preference.set(it)
prefState.value = it
onChange?.invoke(it)
},
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
@@ -106,7 +108,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 +137,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,11 @@ package chat.simplex.app.views.usersettings
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import chat.simplex.app.R
import chat.simplex.app.TAG
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,12 +15,25 @@ 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)
return String(cipher.doFinal(data))
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull()
}
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
@@ -29,7 +47,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 +59,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

@@ -17,18 +17,13 @@ import chat.simplex.app.model.Format
import chat.simplex.app.model.FormatColor
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.AppBarTitle
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun MarkdownHelpView() {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.how_to_use_markdown), false)
Text(stringResource(R.string.you_can_use_markdown_to_format_messages__prompt))
Spacer(Modifier.height(DEFAULT_PADDING))
val bold = stringResource(R.string.bold)

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

@@ -46,25 +46,6 @@ enum class NotificationPreviewMode {
fun NotificationsSettingsView(
chatModel: ChatModel,
) {
val onNotificationsModeSelected = { mode: NotificationsMode ->
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
}
chatModel.notificationsMode.value = mode
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
CoroutineScope(Dispatchers.Default).launch {
if (mode == NotificationsMode.SERVICE)
SimplexService.start(SimplexApp.context)
else
SimplexService.stop(SimplexApp.context)
}
if (mode != NotificationsMode.PERIODIC) {
MessagesFetcherWorker.cancelAll()
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
}
val onNotificationPreviewModeSelected = { mode: NotificationPreviewMode ->
chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name)
chatModel.notificationPreviewMode.value = mode
@@ -76,7 +57,7 @@ fun NotificationsSettingsView(
showPage = { page ->
ModalManager.shared.showModalCloseable(true) {
when (page) {
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode, onNotificationsModeSelected)
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode) { changeNotificationsMode(it, chatModel) }
CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected)
}
}
@@ -159,7 +140,7 @@ fun NotificationPreviewView(
}
// mode, name, description
fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
private fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
val res = ArrayList<ValueTitleDesc<NotificationsMode>>()
res.add(
ValueTitleDesc(
@@ -211,3 +192,23 @@ fun notificationPreviewModes(): List<ValueTitleDesc<NotificationPreviewMode>> {
)
return res
}
fun changeNotificationsMode(mode: NotificationsMode, chatModel: ChatModel) {
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
}
chatModel.notificationsMode.value = mode
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
CoroutineScope(Dispatchers.Default).launch {
if (mode == NotificationsMode.SERVICE)
SimplexService.start(SimplexApp.context)
else
SimplexService.safeStopService(SimplexApp.context)
}
if (mode != NotificationsMode.PERIODIC) {
MessagesFetcherWorker.cancelAll()
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
}

View File

@@ -0,0 +1,148 @@
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.runtime.saveable.rememberSaveable
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, close: () -> Unit,) {
var preferences by rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(user.fullPreferences) }
var currentPreferences by rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(preferences) }
fun savePrefs(afterSave: () -> Unit = {}) {
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
}
afterSave()
}
}
ModalView(
close = {
if (preferences == currentPreferences) close()
else showUnsavedChangesAlert({ savePrefs(close) }, close)
},
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
PreferencesLayout(
preferences,
currentPreferences,
applyPrefs = { preferences = it },
reset = { preferences = currentPreferences },
savePrefs = ::savePrefs,
)
}
}
@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 timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.allow) }
TimedMessagesFeatureSection(timedMessages) {
applyPrefs(preferences.copy(timedMessages = TimedMessagesPreference(allow = if (it) FeatureAllowed.YES else FeatureAllowed.NO)))
}
SectionSpacer()
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) }
FeatureSection(ChatFeature.FullDelete, allowFullDeletion) {
applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it)))
}
SectionSpacer()
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
FeatureSection(ChatFeature.Voice, allowVoice) {
applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it)))
}
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = preferences == currentPreferences
)
}
}
@Composable
private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllowed>, onSelected: (FeatureAllowed) -> Unit) {
SectionView {
SectionItemView {
ExposedDropDownSettingRow(
feature.text,
FeatureAllowed.values().map { it to it.text },
allowFeature,
icon = feature.icon,
onSelected = onSelected
)
}
}
SectionTextFooter(feature.allowDescription(allowFeature.value))
}
@Composable
private fun TimedMessagesFeatureSection(allowFeature: State<FeatureAllowed>, onSelected: (Boolean) -> Unit) {
SectionView {
SectionItemView {
PreferenceToggleWithIcon(
ChatFeature.TimedMessages.text,
ChatFeature.TimedMessages.icon,
HighOrLowlight,
allowFeature.value == FeatureAllowed.ALWAYS || allowFeature.value == FeatureAllowed.YES,
onSelected
)
}
}
SectionTextFooter(ChatFeature.TimedMessages.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)
}
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(R.string.save_preferences_question),
confirmText = generalGetString(R.string.save_and_notify_contacts),
dismissText = generalGetString(R.string.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}

View File

@@ -1,39 +1,88 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import android.view.WindowManager
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.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.FragmentActivity
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()
val context = LocalContext.current
SettingsPreferenceItem(Icons.Outlined.VisibilityOff, stringResource(R.string.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen) { on ->
if (on) {
(context as? FragmentActivity)?.window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
} else {
(context as? FragmentActivity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
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: String): String =
parseServerAddress(srv)?.hostnames?.firstOrNull() ?: srv

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,319 @@
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 allServersDisabled = remember { derivedStateOf { servers.all { !it.enabled } } }
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)
} ||
allServersDisabled.value
}
}
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 = testing.value,
servers = servers,
serversUnchanged = serversUnchanged.value,
saveDisabled = saveDisabled.value,
allServersDisabled = allServersDisabled.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,
allServersDisabled: 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()
val testServersDisabled = testing || allServersDisabled
SectionItemView(testServers, disabled = testServersDisabled) {
Text(stringResource(R.string.smp_servers_test_servers), color = if (!testServersDisabled) 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.server)] = 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

@@ -34,6 +34,7 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.CreateLinkTab
import chat.simplex.app.views.newchat.CreateLinkView
import chat.simplex.app.views.onboarding.SimpleXInfo
import chat.simplex.app.views.onboarding.WhatsNewView
@Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
@@ -80,8 +81,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,29 +117,31 @@ 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(showCustomModal)
}
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()
SectionView(stringResource(R.string.settings_section_title_help)) {
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
SettingsActionItem(Icons.Outlined.Add, stringResource(R.string.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
SectionDivider()
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
SectionDivider()
@@ -175,7 +178,7 @@ fun SettingsLayout(
@Composable
fun SettingsIncognitoActionItem(
incognitoPref: Preference<Boolean>,
incognitoPref: SharedPreference<Boolean>,
incognito: MutableState<Boolean>,
stopped: Boolean,
onClickInfo: () -> Unit,
@@ -237,6 +240,20 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit))) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.chat_preferences),
click = {
withApi {
showCustomModal { m, close ->
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
}()
}
}
)
}
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -365,12 +382,18 @@ 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,
onChange: ((Boolean) -> Unit)? = null,
) {
SectionItemView {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
SharedPreferenceToggle(text, pref, prefState)
SharedPreferenceToggle(text, pref, prefState, onChange)
}
}
}
@@ -382,7 +405,7 @@ fun SettingsPreferenceItemWithInfo(
text: String,
stopped: Boolean,
onClickInfo: () -> Unit,
pref: Preference<Boolean>,
pref: SharedPreference<Boolean>,
prefState: MutableState<Boolean>? = null
) {
SectionItemView(onClickInfo) {
@@ -395,20 +418,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(
@@ -438,13 +483,13 @@ 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 = { {} },
showSettingsModal = { {} },
showCustomModal = { {}},
showCustomModal = { {} },
showTerminal = {},
// showVideoChatPrototype = {}
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,895 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="thousand_abbreviation">k</string>
<string name="connect_via_contact_link">Se connecter via le lien du contact \?</string>
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
<string name="profile_will_be_sent_to_contact_sending_link">Votre profil va être envoyé au contact qui vous a envoyé ce lien.</string>
<string name="you_will_join_group">Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe.</string>
<string name="connect_via_link_verb">Se connecter</string>
<string name="connect_via_group_link">Se connecter via le lien du groupe \?</string>
<string name="connect_via_invitation_link">Se connecter via un lien d\'invitation \?</string>
<string name="server_error">erreur</string>
<string name="server_connecting">connexion</string>
<string name="server_connected">connecté</string>
<string name="display_name_connection_established">connexion établie</string>
<string name="display_name_invited_to_connect">invité à se connecter</string>
<string name="simplex_link_invitation">Invitation unique SimpleX</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">Liens SimpleX</string>
<string name="simplex_link_mode_description">Description</string>
<string name="error_deleting_contact">Erreur lors de la suppression du contact</string>
<string name="error_joining_group">Erreur lors de la liaison avec le groupe</string>
<string name="sender_cancelled_file_transfer">L\'expéditeur a annulé le transfert de fichiers.</string>
<string name="deleted_description">supprimé</string>
<string name="marked_deleted_description">marquer comme supprimé</string>
<string name="unknown_message_format">format de message inconnu</string>
<string name="display_name_connecting">connexion…</string>
<string name="description_you_shared_one_time_link_incognito">vous avez partagé un lien unique en incognito</string>
<string name="description_via_group_link">via le lien de groupe</string>
<string name="description_via_contact_address_link">via le lien d\'adresse du contact</string>
<string name="description_via_contact_address_link_incognito">mode incognito via le lien d\'adresse du contact</string>
<string name="simplex_link_group">Lien de groupe SimpleX</string>
<string name="simplex_link_mode_browser">Via navigateur</string>
<string name="simplex_link_mode_browser_warning">Ouvrir le lien dans le navigateur peut réduire la confidentialité et la sécurité de la connexion. Les liens SimpleX non fiables seront en rouge.</string>
<string name="network_error_desc">Vérifiez votre connexion réseau avec <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> et réessayez.</string>
<string name="error_receiving_file">Erreur lors de la réception du fichier</string>
<string name="sender_may_have_deleted_the_connection_request">L\'expéditeur a peut-être supprimé la demande de connexion.</string>
<string name="connected_to_server_to_receive_messages_from_contact">Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact.</string>
<string name="sending_files_not_yet_supported">l\'envoi de fichiers n\'est pas encore supporté</string>
<string name="sender_you_pronoun">vous</string>
<string name="description_via_group_link_incognito">mode incognito via le lien de groupe</string>
<string name="simplex_link_contact">Adresse de contact SimpleX</string>
<string name="trying_to_connect_to_server_to_receive_messages">Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact.</string>
<string name="receiving_files_not_yet_supported">la réception de fichiers n\'est pas encore supportée</string>
<string name="connection_local_display_name">connexion <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
<string name="description_you_shared_one_time_link">vous avez partagé un lien unique</string>
<string name="description_via_one_time_link">via un lien unique</string>
<string name="description_via_one_time_link_incognito">mode incognito via un lien unique</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Assurez-vous que les adresses des serveurs SMP sont au bon format, séparées par des lignes et ne sont pas dupliquées.</string>
<string name="error_setting_network_config">Erreur lors de la mise à jour de la configuration réseau</string>
<string name="error_creating_address">Erreur lors de la création de l\'adresse</string>
<string name="contact_already_exists">Contact déjà existant</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Veuillez vérifier que vous avez utilisé le bon lien ou demandez à votre contact de vous en envoyer un autre.</string>
<string name="connection_error">Erreur de connexion</string>
<string name="error_adding_members">Erreur lors de l\'ajout de membre·s</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="invalid_message_format">format de message invalide</string>
<string name="simplex_link_mode_full">Lien entier</string>
<string name="error_saving_smp_servers">Erreur lors de la sauvegarde des serveurs SMP</string>
<string name="cannot_receive_file">Impossible de recevoir le fichier</string>
<string name="invalid_connection_link">Lien de connection invalide</string>
<string name="connection_timeout">Délai de connexion</string>
<string name="error_sending_message">Erreur lors de l\'envoi du message</string>
<string name="you_are_already_connected_to_vName_via_this_link">Vous êtes déjà connecté à <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
<string name="connection_error_auth">Erreur de connexion (AUTH)</string>
<string name="connection_error_auth_desc">A moins que votre contact ait supprimé la connexion ou que ce lien ait déjà été utilisé, il peut s\'agir d\'un bug - veuillez le signaler.
\nPour vous connecter, veuillez demander à votre contact de créer un autre lien de connexion et vérifiez que vous disposez d\'une connexion réseau stable.</string>
<string name="error_accepting_contact_request">Erreur de validation de la demande de contact</string>
<string name="error_deleting_group">Erreur lors de la suppression du groupe</string>
<string name="error_deleting_contact_request">Erreur lors de la suppression du contact</string>
<string name="error_deleting_pending_contact_connection">Erreur lors de la suppression de la connexion en attente</string>
<string name="error_changing_address">Erreur de changement d\'adresse</string>
<string name="error_smp_test_failed_at_step">Échec du test à l\'étape %s.</string>
<string name="error_smp_test_certificate">Il est possible que l\'empreinte du certificat dans l\'adresse du serveur soit incorrecte</string>
<string name="smp_server_test_connect">Se connecter</string>
<string name="smp_server_test_create_queue">Créer une file d\'attente</string>
<string name="smp_server_test_secure_queue">File d\'attente sécurisée</string>
<string name="smp_server_test_delete_queue">Supprimer la file d\'attente</string>
<string name="smp_server_test_disconnect">Se déconnecter</string>
<string name="icon_descr_instant_notifications">Notifications instantanées</string>
<string name="service_notifications">Notifications instantanées !</string>
<string name="service_notifications_disabled">Les notifications instantanées sont désactivées !</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Il peut être désactivé via les paramètres</b> - les notifications seront toujours affichées lorsque l\'application est en cours d\'exécution.</string>
<string name="turning_off_service_and_periodic">L\'optimisation de la batterie est active et désactive le service de fond et les demandes périodiques de nouveaux messages. Vous pouvez les réactiver via les paramètres.</string>
<string name="periodic_notifications">Notifications périodiques</string>
<string name="periodic_notifications_disabled">Les notifications périodiques sont désactivées !</string>
<string name="enter_passphrase_notification_title">Une phrase secrète est nécessaire</string>
<string name="turn_off_battery_optimization">Pour l\'utiliser, veuillez <b>désactiver l\'optimisation de la batterie</b> pour <xliff:g id="appName">SimpleX</xliff:g> dans la prochaine fenêtre de dialogue. Sinon, les notifications seront désactivées.</string>
<string name="error_smp_test_server_auth">Le serveur requiert une autorisation pour créer des files d\'attente, vérifiez le mot de passe</string>
<string name="periodic_notifications_desc">L\'application récupère périodiquement les nouveaux messages - elle utilise un peu votre batterie chaque jour. L\'application n\'utilise pas les notifications push - les données de votre appareil ne sont pas envoyées aux serveurs.</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Pour protéger votre vie privée, au lieu des notifications push, l\'application possède un <b><xliff:g id="appName">SimpleX</xliff:g> service de fond</b> - il utilise quelques pour cent de la batterie par jour.</string>
<string name="hide_notification">Cacher</string>
<string name="settings_notification_preview_mode_title">Montrer l\'aperçu</string>
<string name="notification_preview_mode_contact">Nom du contact</string>
<string name="notification_preview_somebody">Contact masqué:</string>
<string name="notification_preview_new_message">nouveau message</string>
<string name="notification_new_contact_request">Nouvelle demande de contact</string>
<string name="notification_contact_connected">Connecté</string>
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
<string name="la_notice_turn_on">Activer</string>
<string name="auth_simplex_lock_turned_on">SimpleX Lock activé</string>
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Il vous sera demandé de vous authentifier lorsque vous démarrez ou reprenez l\'application après 30 secondes en arrière-plan.</string>
<string name="auth_unlock">Déverrouiller</string>
<string name="auth_enable_simplex_lock">Activer SimpleX Lock</string>
<string name="auth_disable_simplex_lock">Désactiver SimpleX Lock</string>
<string name="auth_unavailable">Authentification indisponible</string>
<string name="auth_device_authentication_is_disabled_turning_off">L\'authentification de l\'appareil est désactivée. Désactivation de SimpleX Lock.</string>
<string name="auth_open_chat_console">Ouvrir la console du chat</string>
<string name="message_delivery_error_title">Erreur de distribution du message</string>
<string name="message_delivery_error_desc">Il est fort probable que ce contact ait supprimé la connexion avec vous.</string>
<string name="reply_verb">Répondre</string>
<string name="share_verb">Partager</string>
<string name="copy_verb">Copier</string>
<string name="delete_verb">Supprimer</string>
<string name="save_verb">Sauvegarder</string>
<string name="edit_verb">Modifier</string>
<string name="reveal_verb">Révéler</string>
<string name="hide_verb">Cacher</string>
<string name="allow_verb">Autoriser</string>
<string name="delete_message__question">Supprimer le message\?</string>
<string name="for_me_only">Supprimer pour moi</string>
<string name="your_chats">Vos chats</string>
<string name="notification_preview_mode_message">Texte du message</string>
<string name="notification_preview_mode_hidden">Caché</string>
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Pour protéger vos informations, activez la fonction SimpleX Lock.
\nVous serez invité à confirmer l\'authentification avant que cette fonction ne soit activée.</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">L\'authentification de l\'appareil n\'est pas activée. Vous pouvez activer SimpleX Lock via Paramètres, une fois que vous avez activé l\'authentification de l\'appareil.</string>
<string name="database_initialization_error_desc">La base de données ne fonctionne pas correctement. Appuyez ici pour en savoir plus.</string>
<string name="ntf_channel_calls">Appels SimpleX Chat</string>
<string name="ntf_channel_messages">Messages SimpleX Chat</string>
<string name="settings_notifications_mode_title">Service de notification</string>
<string name="notifications_mode_periodic">Lancer périodiquement</string>
<string name="notifications_mode_off">Exécuter lorsque lapp est ouverte</string>
<string name="notifications_mode_service">Toujours activé</string>
<string name="failed_to_parse_chat_title">Échec du chargement du chat</string>
<string name="failed_to_parse_chats_title">Échec du chargement des chats</string>
<string name="contact_developers">Veuillez mettre à jour lapp et contacter les développeurs.</string>
<string name="simplex_service_notification_text">Récupération des messages…</string>
<string name="settings_notification_preview_title">Aperçu de notification</string>
<string name="database_initialization_error_title">Échec dinitialisation de la base de données</string>
<string name="enter_passphrase_notification_desc">Pour recevoir des notifications, veuillez entrer la phrase secrète de la base de données</string>
<string name="simplex_service_notification_title">service <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="this_text_is_available_in_settings">Ce texte est disponible dans les paramètres</string>
<string name="group_preview_join_as">rejoindre en tant que %s</string>
<string name="group_preview_you_are_invited">vous êtes invité·e au groupe</string>
<string name="chat_with_developers">Discuter avec les développeurs</string>
<string name="tap_to_start_new_chat">Appuyez pour commencer un nouveau chat</string>
<string name="you_have_no_chats">Vous n\'avez aucune discussion</string>
<string name="images_limit_title">Trop dimages !</string>
<string name="share_file">Partager le fichier…</string>
<string name="attach">Attacher</string>
<string name="icon_descr_cancel_image_preview">Annuler laperçu dimage</string>
<string name="icon_descr_cancel_file_preview">Annuler laperçu du fichier</string>
<string name="icon_descr_sent_msg_status_send_failed">échec denvoi</string>
<string name="icon_descr_received_msg_status_unread">non lu</string>
<string name="welcome">Bienvenue !</string>
<string name="contact_connection_pending">connexion…</string>
<string name="group_connection_pending">connexion…</string>
<string name="share_message">Partager le message…</string>
<string name="share_image">Partager limage…</string>
<string name="images_limit_desc">Envoi de 10 images en même temps maximum</string>
<string name="personal_welcome">Bienvenue <xliff:g>%1$s</xliff:g> !</string>
<string name="notifications_mode_periodic_desc">Vérifie les nouveaux messages toutes les 10 minutes pendant 1 minute au maximum.</string>
<string name="notification_preview_mode_contact_desc">Afficher uniquement le contact</string>
<string name="for_everybody">Pour tous</string>
<string name="icon_descr_sent_msg_status_sent">envoyé</string>
<string name="icon_descr_sent_msg_status_unauthorized_send">envoi non autorisé</string>
<string name="icon_descr_context">Icône contextuelle</string>
<string name="image_descr">Image</string>
<string name="image_decoding_exception_desc">L\'image ne peut pas être décodée. Veuillez essayer une autre image ou contacter les développeurs.</string>
<string name="icon_descr_waiting_for_image">En attente de l\'image</string>
<string name="icon_descr_asked_to_receive">Demandé à recevoir l\'image</string>
<string name="icon_descr_image_snd_complete">Image envoyée</string>
<string name="waiting_for_image">En attente de l\'image</string>
<string name="image_saved">Image enregistrée dans la phototèque</string>
<string name="icon_descr_file">Fichier</string>
<string name="large_file">Fichier trop lourd !</string>
<string name="file_saved">Fichier sauvegardé</string>
<string name="file_not_found">Fichier introuvable</string>
<string name="error_saving_file">Erreur lors de la sauvegarde du fichier</string>
<string name="delete_contact_question">Supprimer le contact \?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Le contact et tous les messages seront supprimés - impossible de revenir en arrière !</string>
<string name="button_delete_contact">Supprimer le contact</string>
<string name="icon_descr_server_status_connected">Connecté</string>
<string name="icon_descr_send_message">Envoyer un message</string>
<string name="switch_receiving_address_question">Changement d\'adresse de réception \?</string>
<string name="icon_descr_record_voice_message">Enregistrer un message vocal</string>
<string name="allow_voice_messages_question">Autoriser les messages vocaux \?</string>
<string name="you_need_to_allow_to_send_voice">Vous devez autoriser votre contact à envoyer des messages vocaux pour pouvoir en envoyer.</string>
<string name="voice_messages_prohibited">Messages vocaux interdits !</string>
<string name="ask_your_contact_to_enable_voice">Veuillez demander à votre contact de permettre l\'envoi de messages vocaux.</string>
<string name="cancel_verb">Annuler</string>
<string name="only_group_owners_can_enable_voice">Seuls les propriétaires de groupes peuvent activer les messages vocaux.</string>
<string name="back">Retour</string>
<string name="no_details">aucun détail</string>
<string name="add_contact">Lien d\'invitation unique</string>
<string name="copied">Copié dans le presse-papiers</string>
<string name="share_one_time_link">Créer un lien d\'invitation unique</string>
<string name="add_contact_or_create_group">Commencer une nouvelle discussion</string>
<string name="connect_via_link_or_qr">Se connecter via un lien / code QR</string>
<string name="scan_QR_code">Scanner un code QR</string>
<string name="to_share_with_your_contact">(à partager avec votre contact)</string>
<string name="create_group">Créer un groupe secret</string>
<string name="from_gallery_button">Depuis la Phototèque</string>
<string name="choose_file">Choisir le fichier</string>
<string name="to_start_a_new_chat_help_header">Pour démarrer une nouvelle discussion</string>
<string name="chat_help_tap_button">Appuyez sur le bouton</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Scanner un code QR</b> : pour vous connecter à votre contact qui vous montre un code QR.</string>
<string name="to_connect_via_link_title">Pour se connecter via un lien</string>
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Si vous avez reçu un lien d\'invitation <xliff:g id="appName">SimpleX Chat</xliff:g>, vous pouvez l\'ouvrir dans votre navigateur :</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 bureau : scanner le code QR affiché depuis l\'app, via <b>Scanner le code QR</b>.</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 mobile : appuyez sur <b>Ouvrir dans l\'application</b>, puis appuyez sur <b>se connecter</b> dans l\'app.</string>
<string name="accept_contact_incognito_button">Accepter en incognito</string>
<string name="reject_contact_button">Rejeter</string>
<string name="clear_chat_question">Effacer la conversation \?</string>
<string name="clear_chat_warning">Tous les messages seront supprimés - impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous.</string>
<string name="clear_chat_menu_action">Effacer</string>
<string name="delete_contact_menu_action">Supprimer</string>
<string name="delete_group_menu_action">Supprimer</string>
<string name="mark_read">Marquer comme lu</string>
<string name="mark_unread">Marquer non lu</string>
<string name="set_contact_name">Définir le nom du contact</string>
<string name="you_invited_your_contact">Vous avez invité votre contact</string>
<string name="you_accepted_connection">Vous avez accepté la connexion</string>
<string name="delete_pending_connection__question">Supprimer la connexion en attente \?</string>
<string name="connection_you_accepted_will_be_cancelled">La connexion que vous avez acceptée sera annulée !</string>
<string name="alert_title_contact_connection_pending">Le contact n\'est pas encore connecté !</string>
<string name="icon_descr_close_button">Bouton fermer</string>
<string name="image_descr_profile_image">image de profil</string>
<string name="image_descr_link_preview">image d\'aperçu du lien</string>
<string name="icon_descr_cancel_link_preview">annuler l\'aperçu du lien</string>
<string name="icon_descr_settings">Paramètres</string>
<string name="icon_descr_address">Adresse <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_help">aide</string>
<string name="icon_descr_simplex_team">Équipe <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_more_button">Plus</string>
<string name="show_QR_code">Afficher le code QR</string>
<string name="invalid_QR_code">Code QR invalide</string>
<string name="this_QR_code_is_not_a_link">Ce code QR n\'est pas un lien !</string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Vous serez connecté·e lorsque votre demande de connexion sera acceptée, veuillez attendre ou vérifier plus tard !</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">Vous serez connecté·e lorsque l\'appareil de votre contact sera en ligne, veuillez attendre ou vérifier plus tard !</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Votre contact peut scanner le code QR depuis l\'app.</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Votre profil de chat sera envoyé
\nà votre contact</string>
<string name="share_invitation_link">Partager le lien d\'invitation</string>
<string name="your_profile_will_be_sent">Votre profil de chat sera envoyé à votre contact</string>
<string name="paste_button">Coller</string>
<string name="this_string_is_not_a_connection_link">Cette chaîne n\'est pas un lien de connexion !</string>
<string name="you_can_also_connect_by_clicking_the_link">Vous pouvez aussi vous connecter en cliquant sur le lien. Si il s\'ouvre dans le navigateur, cliquez sur <b>Ouvrir dans l\'app mobile</b>.</string>
<string name="create_one_time_link">Créer un lien d\'invitation unique</string>
<string name="text_field_set_contact_placeholder">Définir le nom du contact…</string>
<string name="icon_descr_server_status_disconnected">Déconnecté</string>
<string name="icon_descr_server_status_error">Erreur</string>
<string name="icon_descr_server_status_pending">En attente</string>
<string name="accept_connection_request__question">Accepter la demande de connexion \?</string>
<string name="clear_verb">Effacer</string>
<string name="clear_chat_button">Effacer la conversation</string>
<string name="paste_connection_link_below_to_connect">Collez le lien que vous avez reçu dans le cadre ci-dessous pour vous connecter avec votre contact.</string>
<string name="connect_via_link">Se connecter via un lien</string>
<string name="clear_verification">Retirer la vérification</string>
<string name="one_time_link">Lien d\'invitation unique</string>
<string name="your_contact_address">Votre adresse de contact</string>
<string name="scan_code">Scanner le code</string>
<string name="incorrect_code">Code de sécurité incorrect !</string>
<string name="security_code">Code de sécurité</string>
<string name="mark_code_verified">Marquer comme vérifié</string>
<string name="view_security_code">Afficher le code de sécurité</string>
<string name="verify_security_code">Vérifier le code de sécurité</string>
<string name="confirm_verb">Confirmer</string>
<string name="reset_verb">Réinitialisation</string>
<string name="ok">OK</string>
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(scanner ou coller depuis le presse-papiers)</string>
<string name="only_stored_on_members_devices">(uniquement stocké par les membres du groupe)</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Votre contact a besoin d\'être en ligne pour completer la connexion.
\nVous pouvez annuler la connexion et supprimer le contact (et réessayer plus tard avec un autre lien).</string>
<string name="contact_wants_to_connect_with_you">veut établir une connexion !</string>
<string name="icon_descr_profile_image_placeholder">image de profil (placeholder)</string>
<string name="image_descr_qr_code">Code QR</string>
<string name="image_descr_simplex_logo">Logo <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_email">E-mail</string>
<string name="connect_button">Se connecter</string>
<string name="notifications_mode_off_desc">L\'application peut recevoir des notifications uniquement lorsqu\'elle est en cours d\'exécution, aucun service d\'arrière-plan ne sera lancé.</string>
<string name="notifications_mode_service_desc">Le service d\'arrière-plan fonctionne en permanence. Les notifications s\'affichent dès que les messages sont disponibles.</string>
<string name="notification_preview_mode_message_desc">Afficher le contact et le message</string>
<string name="notification_display_mode_hidden_desc">Masquer le contact et le message</string>
<string name="auth_log_in_using_credential">Connectez-vous en utilisant votre identifiant</string>
<string name="auth_confirm_credential">Confirmez vos identifiants</string>
<string name="auth_stop_chat">Arrêter le chat</string>
<string name="delete_message_cannot_be_undone_warning">Le message sera supprimé - impossible de revenir en arrière !</string>
<string name="delete_message_mark_deleted_warning">Le message sera marqué comme supprimé. Le·s destinataire·s pourrai·ent révéler ce message.</string>
<string name="icon_descr_edited">modifié</string>
<string name="image_will_be_received_when_contact_is_online">L\'image sera reçue quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard!</string>
<string name="image_decoding_exception_title">Erreur de décodage</string>
<string name="contact_sent_large_file">Votre contact a envoyé un fichier dont la taille est supérieure à la taille maximale actuellement prise en charge (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
<string name="waiting_for_file">En attente du fichier</string>
<string name="voice_message">Message vocal</string>
<string name="toast_permission_denied">Autorisation refusée !</string>
<string name="use_camera_button">Utiliser l\'Appareil photo</string>
<string name="thank_you_for_installing_simplex">Merci d\'avoir installé <xliff:g id="appNameFull">SimpleX Chat</xliff:g> !</string>
<string name="you_can_connect_to_simplex_chat_founder">Vous pouvez <font color="#0088ff">vous connecter aux développeurs de <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour leur poser toutes vos questions et pour recevoir des informations sur les mises à jour</font>.</string>
<string name="above_then_preposition_continuation">ci-dessus, puis :</string>
<string name="add_new_contact_to_create_one_time_QR_code"><b>Ajouter un nouveau contact</b> : afin de créer un code QR à usage unique pour votre contact.</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Si vous choisissez de la rejeter, l\'expéditeur·rice NE sera PAS notifié·e.</string>
<string name="accept_contact_button">Accepter</string>
<string name="mute_chat">Muet</string>
<string name="unmute_chat">Démute</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Le contact avec lequel vous avez partagé ce lien NE pourra PAS se connecter !</string>
<string name="invalid_contact_link">Lien invalide !</string>
<string name="this_link_is_not_a_valid_connection_link">Ce lien n\'est pas un lien de connexion valide !</string>
<string name="connection_request_sent">Demande de connexion envoyée !</string>
<string name="file_will_be_received_when_contact_is_online">Le fichier sera reçu quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard!</string>
<string name="voice_message_send_text">Message vocal…</string>
<string name="maximum_supported_file_size">La taille maximale supportés des fichiers actuellement est de <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
<string name="voice_message_with_duration">Message vocal (<xliff:g id="duration">%1$s</xliff:g>)</string>
<string name="notifications">Notifications</string>
<string name="switch_receiving_address_desc">Cette fonctionnalité est expérimentale ! Elle ne fonctionnera que si l\'autre client a la version 4.2 installée. Vous devriez voir le message dans la conversation une fois le changement d\'adresse effectué. Vérifiez que vous pouvez toujours recevoir des messages de ce contact (ou membre du groupe).</string>
<string name="you_will_be_connected_when_group_host_device_is_online">Vous serez connecté·e au groupe lorsque l\'appareil de l\'hôte sera en ligne, veuillez attendre ou vérifier plus tard !</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Si vous ne pouvez pas vous rencontrer en personne, <b>montrez le code QR lors d\'un appel vidéo</b>, ou partagez le lien.</string>
<string name="scan_code_from_contacts_app">Scannez le code de sécurité depuis l\'application de votre contact.</string>
<string name="to_verify_compare">Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils.</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Si vous ne pouvez pas vous rencontrer en personne, vous pouvez <b>scanner un code QR lors d\'un appel vidéo</b>, ou votre contact peut partager un lien d\'invitation.</string>
<string name="smp_servers_add">Ajouter un serveur…</string>
<string name="markdown_in_messages">Markdown dans les messages</string>
<string name="smp_servers_preset_add">Ajouter des serveurs prédéfinis</string>
<string name="use_simplex_chat_servers__question">Utiliser les serveurs <xliff:g id="appNameFull">SimpleX Chat</xliff:g> \?</string>
<string name="smp_servers_delete_server">Supprimer le serveur</string>
<string name="ensure_ICE_server_address_are_correct_format_and_unique">Assurez-vous que les adresses des serveurs WebRTC ICE sont au bon format et ne sont pas dupliquées, un par ligne.</string>
<string name="network_enable_socks_info">Accéder aux serveurs via un proxy SOCKS sur le port 9050 \? Le proxy doit être démarré avant d\'activer cette option.</string>
<string name="network_use_onion_hosts">Utiliser les hôtes .onions</string>
<string name="network_use_onion_hosts_prefer_desc_in_alert">Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles.</string>
<string name="network_use_onion_hosts_required_desc_in_alert">Les hôtes .onion seront nécessaires pour la connexion.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Vous contrôlez par quel·s serveur·s vous pouvez <b>transmettre</b> ainsi que par quel·s serveur·s vous pouvez <b>recevoir</b> des messages de vos contacts.</string>
<string name="your_settings">Vos paramètres</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Console du chat</string>
<string name="smp_servers">Serveurs SMP</string>
<string name="smp_servers_test_servers">Tester les serveurs</string>
<string name="smp_servers_save">Sauvegarder les serveurs</string>
<string name="smp_servers_scan_qr">Scanner le code QR du serveur</string>
<string name="smp_servers_use_server">Utiliser ce serveur</string>
<string name="smp_servers_use_server_for_new_conn">Utiliser pour les nouvelles connexions</string>
<string name="smp_servers_add_to_another_device">Ajouter à un autre appareil</string>
<string name="install_simplex_chat_for_terminal">Installer <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour terminal</string>
<string name="star_on_github">Star sur GitHub</string>
<string name="contribute">Contribuer</string>
<string name="rate_the_app">Évaluer l\'app</string>
<string name="your_SMP_servers">Vos serveurs SMP</string>
<string name="how_to_use_your_servers">Comment utiliser vos serveurs</string>
<string name="saved_ICE_servers_will_be_removed">Les serveurs WebRTC ICE sauvegardés seront supprimés.</string>
<string name="your_ICE_servers">Vos serveurs ICE</string>
<string name="configure_ICE_servers">Configurer les serveurs ICE</string>
<string name="network_settings">Paramètres réseau avancés</string>
<string name="network_settings_title">Paramètres réseau</string>
<string name="network_socks_toggle">Utiliser un proxy SOCKS (port 9050)</string>
<string name="network_enable_socks">Utiliser un proxy SOCKS \?</string>
<string name="network_disable_socks">Utiliser une connexion Internet directe \?</string>
<string name="network_disable_socks_info">Si vous confirmez, les serveurs de messagerie seront en mesure de voir votre adresse IP, votre fournisseur ainsi que les serveurs auxquels vous vous connectez.</string>
<string name="network_use_onion_hosts_no">Non</string>
<string name="network_use_onion_hosts_required">Requis</string>
<string name="network_use_onion_hosts_prefer_desc">Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles.</string>
<string name="appearance_settings">Apparence</string>
<string name="create_address">Créer une adresse</string>
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Vous pouvez partager votre adresse sous forme de lien ou de code QR - n\'importe qui pourra se connecter à vous. Vous ne perdrez pas vos contacts si vous les supprimez par la suite.</string>
<string name="your_chat_profile">Votre profil de chat</string>
<string name="edit_image">Modifier l\'image</string>
<string name="save_and_notify_contacts">Sauvegarder et notifier les contacts</string>
<string name="save_and_notify_group_members">Sauvegarder et en informer les membres du groupe</string>
<string name="your_profile_is_stored_on_your_device">Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil.</string>
<string name="profile_is_only_shared_with_your_contacts">Le profil n\'est partagé qu\'avec vos contacts.</string>
<string name="display_name_cannot_contain_whitespace">Le nom d\'affichage ne peut pas contenir d\'espace.</string>
<string name="full_name_optional__prompt">Nom complet (optionnel)</string>
<string name="create_profile_button">Créer</string>
<string name="about_simplex">À propos de SimpleX</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Vous pouvez utiliser le format markdown pour mettre en forme les messages :</string>
<string name="bold">gras</string>
<string name="italic">italique</string>
<string name="strikethrough">barré</string>
<string name="callstatus_accepted">appel accepté</string>
<string name="callstatus_connecting">connexion à l\'appel…</string>
<string name="callstatus_error">erreur d\'appel</string>
<string name="callstate_received_answer">réponse reçu…</string>
<string name="callstate_received_confirmation">confimation reçu…</string>
<string name="callstate_connecting">connexion…</string>
<string name="opensource_protocol_and_code_anybody_can_run_servers">Protocole et code open-source tout le monde peut faire fonctionner les serveurs.</string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Pour protéger la vie privée, au lieu d\'ID d\'utilisateur utilisés par toutes les autres plateformes, <xliff:g id="appName">SimpleX</xliff:g> possède des identifiants pour les files d\'attente de messages, distincts pour chacun de vos contacts.</string>
<string name="read_more_in_github">Plus d\'informations sur notre GitHub.</string>
<string name="paste_the_link_you_received">Coller le lien reçu</string>
<string name="use_chat">Utiliser le chat</string>
<string name="onboarding_notifications_mode_title">Notifications privées</string>
<string name="onboarding_notifications_mode_subtitle">Peut être modifié ultérieurement via les paramètres.</string>
<string name="onboarding_notifications_mode_off">Quand l\'application fonctionne</string>
<string name="onboarding_notifications_mode_periodic">Périodique</string>
<string name="onboarding_notifications_mode_service">Instantanée</string>
<string name="onboarding_notifications_mode_off_desc"><b>Économie de batterie</b>. Vous recevrez des notifications uniquement lorsque l\'application est en cours d\'exécution, le service de fond ne sera PAS utilisé.</string>
<string name="about_simplex_chat">À propos de <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="how_to_use_simplex_chat">Comment l\'utiliser</string>
<string name="markdown_help">Aide Markdown</string>
<string name="save_servers_button">Sauvegarder</string>
<string name="network_and_servers">Réseau et serveurs</string>
<string name="save_and_notify_contact">Sauvegarder et en informer les contacts</string>
<string name="exit_without_saving">Quitter sans sauvegarder</string>
<string name="callstatus_rejected">appel rejeté</string>
<string name="callstatus_in_progress">appel en cours</string>
<string name="callstatus_ended">appel terminé <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstate_starting">lancement…</string>
<string name="is_verified">%s est vérifié·e</string>
<string name="is_not_verified">%s n\'est pas vérifié·e</string>
<string name="your_simplex_contact_address">Votre adresse de contact <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="database_passphrase_and_export">Phrase secrète et exportation de la base de données</string>
<string name="chat_with_the_founder">Envoyez vos questions et idées</string>
<string name="send_us_an_email">Envoyez nous un e-mail</string>
<string name="smp_servers_preset_address">Adresse du serveur prédéfinie</string>
<string name="smp_servers_test_server">Tester le serveur</string>
<string name="smp_servers_test_failed">Échec du test du serveur !</string>
<string name="smp_servers_test_some_failed">Certains serveurs n\'ont pas réussi le test :</string>
<string name="smp_servers_enter_manually">Entrer un serveur manuellement</string>
<string name="smp_servers_preset_server">Serveur prédéfini</string>
<string name="smp_servers_your_server">Votre serveur</string>
<string name="smp_servers_your_server_address">Votre adresse de serveur</string>
<string name="smp_servers_invalid_address">Adresse de serveur invalide !</string>
<string name="smp_servers_check_address">Vérifiez l\'adresse du serveur et réessayez.</string>
<string name="using_simplex_chat_servers">Utilise les serveurs <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<string name="how_to">Comment faire</string>
<string name="enter_one_ICE_server_per_line">Serveurs ICE (un par ligne)</string>
<string name="error_saving_ICE_servers">Erreur lors de la sauvegarde des serveurs ICE</string>
<string name="update_onion_hosts_settings_question">Mettre à jour le paramètre des hôtes .onion \?</string>
<string name="network_use_onion_hosts_prefer">Quand disponible</string>
<string name="network_use_onion_hosts_no_desc">Les hôtes .onion ne seront pas utilisés.</string>
<string name="network_use_onion_hosts_required_desc">Les hôtes .onion seront nécessaires pour la connexion.</string>
<string name="network_use_onion_hosts_no_desc_in_alert">Les hôtes .onion ne seront pas utilisés.</string>
<string name="delete_address__question">Supprimer l\'adresse \?</string>
<string name="all_your_contacts_will_remain_connected">Tous vos contacts resteront connectés.</string>
<string name="share_link">Partager le lien</string>
<string name="delete_address">Supprimer l\'adresse</string>
<string name="contact_requests">Demandes de contact</string>
<string name="accept_requests">Accepter les demandes</string>
<string name="accept_automatically">Automatiquement</string>
<string name="section_title_welcome_message">MESSAGE DE BIENVENUE</string>
<string name="display_name__field">Nom affiché :</string>
<string name="full_name__field">Nom complet :</string>
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Votre profil est stocké sur votre appareil et partagé uniquement avec vos contacts.
\n
\nLes serveurs <xliff:g id="appName">SimpleX</xliff:g> ne peuvent pas voir votre profil.</string>
<string name="delete_image">Supprimer l\'image</string>
<string name="save_preferences_question">Sauvegarder les préférences \?</string>
<string name="you_control_your_chat">Vous maîtrisez vos discussions !</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">La plateforme de messagerie et d\'applications qui protège votre vie privée et votre sécurité.</string>
<string name="we_do_not_store_contacts_or_messages_on_servers">Nous ne stockons aucun de vos contacts ou messages (une fois délivrés) sur les serveurs.</string>
<string name="create_profile">Créer le profil</string>
<string name="display_name">Nom affiché</string>
<string name="how_to_use_markdown">Comment utiliser markdown</string>
<string name="a_plus_b">a + b</string>
<string name="colored">coloré</string>
<string name="secret">secret</string>
<string name="callstatus_calling">appel…</string>
<string name="callstatus_missed">appel manqué</string>
<string name="callstate_waiting_for_answer">en attente de réponse…</string>
<string name="callstate_waiting_for_confirmation">en attente de confirmation…</string>
<string name="callstate_connected">connecté</string>
<string name="callstate_ended">terminé</string>
<string name="next_generation_of_private_messaging">La nouvelle génération de messagerie privée</string>
<string name="privacy_redefined">La vie privée redéfinie</string>
<string name="first_platform_without_user_ids">La 1ère plateforme sans aucun identifiant d\'utilisateur privée par design.</string>
<string name="immune_to_spam_and_abuse">Protégé du spam et des abus</string>
<string name="people_can_connect_only_via_links_you_share">Les gens peuvent se connecter à vous uniquement via les liens que vous partagez.</string>
<string name="decentralized">Décentralisé</string>
<string name="create_your_profile">Créez votre profil</string>
<string name="make_private_connection">Établir une connexion privée</string>
<string name="how_it_works">Comment ça fonctionne</string>
<string name="how_simplex_works">Comment <xliff:g id="appName">SimpleX</xliff:g> fonctionne</string>
<string name="many_people_asked_how_can_it_deliver">Beaucoup se demande : <i>si <xliff:g id="appName">SimpleX</xliff:g> n\'a pas d\'identifiant d\'utilisateur, comment peut-il transmettre des messages \?</i></string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages envoyés avec un <b>chiffrement de bout en bout à deux couches</b>.</string>
<string name="read_more_in_github_with_link">Pour en savoir plus, consultez notre <font color="#0088ff">GitHub repository</font>.</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Batterie peu utilisée</b>. Le service de fond vérifie les nouveaux messages toutes les 10 minutes. Vous risquez de manquer des appels et des messages urgents.</string>
<string name="onboarding_notifications_mode_service_desc"><b>Batterie plus utilisée </b> ! Le service de fond est toujours en cours d\'exécution - les notifications s\'afficheront dès que les messages seront disponibles.</string>
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> message⸱s manqué⸱s</string>
<string name="integrity_msg_bad_id">ID de message incorrecte</string>
<string name="settings_section_title_settings">PARAMÈTRES</string>
<string name="alert_text_skipped_messages_it_can_happen_when">C\'est possible quand :
\n1. Les messages expirent du serveur (après 30 jours si ils ne sont pas reçu).
\n2. Le serveur que vous utilisez pour recevoir les messages de ce contact a été mise à jour ou redémarré.
\n3. La connection est compromise.
\nVeuillez vous connecter aux développeurs via les Paramètres pour recevoir les mises à jour concernant les serveurs.
\nNous allons ajouter une redondance des serveurs pour éviter la perte de messages.</string>
<string name="icon_descr_call_rejected">Appel rejeté</string>
<string name="rcv_group_event_member_deleted">a retiré <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_member_deleted">vous avez retiré <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_invited_via_your_group_link">invité par votre lien de groupe</string>
<string name="snd_conn_event_switch_queue_phase_completed">vous avez changé d\'adresse</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Arrêtez le chat pour exporter, importer ou supprimer la base de données du chat. Vous ne pourrez pas recevoir et envoyer de messages pendant que le chat est arrêté.</string>
<string name="enable_automatic_deletion_message">Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes.</string>
<string name="encrypted_with_random_passphrase">La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire, que vous pouvez modifier.</string>
<string name="restore_database">Restaurer la sauvegarde de la base de données</string>
<string name="restore_passphrase_not_found_desc">La phrase secrète n\'a pas été trouvée dans le Keystore, veuillez la saisir manuellement. Cela a pu se produire si vous avez restauré les données de l\'app à l\'aide d\'un outil de sauvegarde. Si ce n\'est pas le cas, veuillez contacter les développeurs.</string>
<string name="restore_database_alert_desc">Veuillez entrer le mot de passe précédent après avoir restauré la sauvegarde de la base de données. Cette action ne peut pas être annulée.</string>
<string name="database_restore_error">Erreur de restauration de la base de données</string>
<string name="archive_created_on_ts">Créé le <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="encrypted_video_call">appel vidéo (chiffrement de bout en bout)</string>
<string name="audio_call_no_encryption">appel audio (sans chiffrement)</string>
<string name="encrypted_audio_call">appel audio (chiffrement de bout en bout)</string>
<string name="accept">Accepter</string>
<string name="reject">Rejeter</string>
<string name="icon_descr_video_call">appel vidéo</string>
<string name="icon_descr_audio_call">appel audio</string>
<string name="accept_call_on_lock_screen">Accepter</string>
<string name="allow_accepting_calls_from_lock_screen">Activer les appels depuis l\'écran verrouillé via les Paramètres.</string>
<string name="open_verb">Ouvrir</string>
<string name="call_connection_via_relay">via relais</string>
<string name="icon_descr_hang_up">Raccrocher</string>
<string name="icon_descr_video_on">Vidéo ON</string>
<string name="icon_descr_video_off">Vidéo OFF</string>
<string name="icon_descr_call_progress">Appel en cours</string>
<string name="icon_descr_call_ended">Appel terminé</string>
<string name="your_privacy">Votre vie privée</string>
<string name="settings_section_title_device">APPAREIL</string>
<string name="settings_section_title_chats">CHATS</string>
<string name="settings_developer_tools">Outils du développeur</string>
<string name="settings_section_title_icon">ICONE DE L\'APP</string>
<string name="your_chat_database">Votre base de données de chat</string>
<string name="run_chat_section">LANCER LE CHAT</string>
<string name="stop_chat_question">Arrêter le chat \?</string>
<string name="restart_the_app_to_use_imported_chat_database">Redémarrez l\'application pour utiliser la base de données de chat importée.</string>
<string name="data_section">DONNÉES</string>
<string name="chat_item_ttl_day">1 jour</string>
<string name="delete_messages">Supprimer les messages</string>
<string name="save_passphrase_in_keychain">Sauvegarder la phrase secrète dans le keystore</string>
<string name="database_encrypted">Base de données chiffrée !</string>
<string name="error_encrypting_database">Erreur lors du chiffrement de la base de données</string>
<string name="update_database">Mise à jour</string>
<string name="encrypt_database">Chiffrer</string>
<string name="enter_correct_current_passphrase">Veuillez entrer la phrase secrète actuelle correcte.</string>
<string name="database_is_not_encrypted">Votre base de données de chat n\'est pas chiffrée - définissez une phrase secrète pour la protéger.</string>
<string name="impossible_to_recover_passphrase"><b>Veuillez noter</b> : vous NE pourrez PAS récupérer ou modifier la phrase secrète si vous la perdez.</string>
<string name="keychain_allows_to_receive_ntfs">Le Keystore d\'Android sera utilisé pour stocker en toute sécurité la phrase secrète après sa modification ou redémarrage de l\'app - cela permettra de recevoir les notifications.</string>
<string name="store_passphrase_securely_without_recover">Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS accéder au chat si vous la perdez.</string>
<string name="passphrase_is_different">La phrase secrète de la base de données est différente de celle enregistrée dans le Keystore.</string>
<string name="unknown_error">Erreur inconnue</string>
<string name="enter_correct_passphrase">Entrez la phrase secrète correcte.</string>
<string name="alert_message_no_group">Ce groupe n\'existe plus.</string>
<string name="you_joined_this_group">Vous avez rejoint ce groupe</string>
<string name="you_rejected_group_invitation">Vous avez rejeté l\'invitation du groupe</string>
<string name="snd_conn_event_switch_queue_phase_completed_for_member">vous avez changé d\'adresse pour %s</string>
<string name="rcv_conn_event_switch_queue_phase_changing">changement d\'adresse…</string>
<string name="incoming_video_call">Appel vidéo entrant</string>
<string name="video_call_no_encryption">appel vidéo (sans chiffrement)</string>
<string name="ignore">Ignorer</string>
<string name="call_already_ended">Appel déjà terminé !</string>
<string name="settings_audio_video_calls">Appels audio et vidéo</string>
<string name="status_e2e_encrypted">chiffré de bout en bout</string>
<string name="settings_section_title_develop">DEVELOPPER</string>
<string name="settings_experimental_features">Fonctionnalités expérimentales</string>
<string name="settings_section_title_socks">SOCKS PROXY</string>
<string name="settings_section_title_themes">THEMES</string>
<string name="settings_section_title_messages">MESSAGES</string>
<string name="settings_section_title_calls">APPELS</string>
<string name="import_database">Importer la base de données</string>
<string name="new_database_archive">Nouvelle archive de base de données</string>
<string name="old_database_archive">Archives de l\'ancienne base de données</string>
<string name="delete_database">Supprimer la base de données</string>
<string name="error_starting_chat">Erreur lors du démarrage du chat</string>
<string name="import_database_confirmation">Importer</string>
<string name="delete_chat_profile_action_cannot_be_undone_warning">Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus.</string>
<string name="chat_database_deleted">Base de données du chat supprimée</string>
<string name="restart_the_app_to_create_a_new_chat_profile">Redémarrez l\'application pour créer un nouveau profil de chat.</string>
<string name="you_must_use_the_most_recent_version_of_database">Vous devez utiliser la version la plus récente de votre base de données de chat sur un seul appareil UNIQUEMENT, sinon vous risquez de ne plus recevoir les messages de certains contacts.</string>
<string name="total_files_count_and_size">%d fichier·s avec une taille totale de %s</string>
<string name="chat_item_ttl_none">jamais</string>
<string name="chat_item_ttl_week">1 semaine</string>
<string name="database_will_be_encrypted_and_passphrase_stored">La base de données sera chiffrée et la phrase secrète sera stockée dans le Keystore.</string>
<string name="database_encryption_will_be_updated">La phrase secrète de la base de données sera mise à jour et stockée dans le Keystore.</string>
<string name="database_passphrase_will_be_updated">La phrase secrète de la base de données sera mise à jour.</string>
<string name="store_passphrase_securely">Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS la changer si vous la perdez.</string>
<string name="wrong_passphrase">Mauvaise phrase secrète pour la base de données</string>
<string name="encrypted_database">Base de données chiffrée</string>
<string name="database_error">Erreur de base de données</string>
<string name="error_with_info">Erreur : %s</string>
<string name="cannot_access_keychain">Impossible d\'accéder au Keystore pour enregistrer le mot de passe de la base de données</string>
<string name="unknown_database_error_with_info">Erreur de base de données inconnue : %s</string>
<string name="wrong_passphrase_title">Mauvaise phrase secrète !</string>
<string name="leave_group_question">Quitter le groupe \?</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Vous ne recevrez plus de messages de ce groupe. L\'historique du chat sera conservé.</string>
<string name="icon_descr_add_members">Inviter des membres</string>
<string name="icon_descr_group_inactive">Groupe inactif</string>
<string name="alert_title_group_invitation_expired">Invitation expirée !</string>
<string name="alert_message_group_invitation_expired">L\'invitation du groupe n\'est plus valide, elle a été supprimé par l\'expéditeur.</string>
<string name="alert_title_no_group">Groupe introuvable !</string>
<string name="alert_title_cant_invite_contacts">Impossible d\'inviter les contacts !</string>
<string name="alert_title_cant_invite_contacts_descr">Vous utilisez un profil incognito pour ce groupe - pour éviter de partager votre profil principal ; inviter des contacts n\'est pas possible</string>
<string name="you_sent_group_invitation">Vous avez envoyé une invitation de groupe</string>
<string name="rcv_group_event_member_left">a quitté</string>
<string name="icon_descr_speaker_on">Haut-parleur ON</string>
<string name="send_link_previews">Envoi d\'aperçus de liens</string>
<string name="error_deleting_database">Erreur lors de la suppression de la base de données du chat</string>
<string name="error_stopping_chat">Erreur lors de l\'arrêt du chat</string>
<string name="error_exporting_chat_database">Erreur lors de l\'exportation de la base de données du chat</string>
<string name="import_database_question">Importer la base de données du chat \?</string>
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Votre base de données de chat actuelle sera SUPPRIMÉE et REMPLACÉE par celle qui a été importée.
\nCette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irrémédiablement perdus.</string>
<string name="enter_passphrase">Entrez la phrase secrète…</string>
<string name="incoming_audio_call">Appel audio entrant</string>
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> veut se connecter à vous via</string>
<string name="your_calls">Vos appels</string>
<string name="connect_calls_via_relay">Se connecter via relais</string>
<string name="call_on_lock_screen">Appels en écran verrouillé :</string>
<string name="show_call_on_lock_screen">Montrer</string>
<string name="no_call_on_lock_screen">Désactiver</string>
<string name="your_ice_servers">Vos serveurs ICE</string>
<string name="webrtc_ice_servers">Serveurs WebRTC ICE</string>
<string name="open_simplex_chat_to_accept_call">Ouvrez <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour décrocher</string>
<string name="status_no_e2e_encryption">sans chiffrement de bout en bout</string>
<string name="status_contact_has_e2e_encryption">Ce contact a le chiffrement de bout en bout</string>
<string name="status_contact_has_no_e2e_encryption">Ce contact n\'a pas le chiffrement de bout en bout</string>
<string name="call_connection_peer_to_peer">pair-à-pair</string>
<string name="icon_descr_audio_off">Audio OFF</string>
<string name="icon_descr_audio_on">Audio ON</string>
<string name="icon_descr_speaker_off">Haut-parleur OFF</string>
<string name="icon_descr_flip_camera">Retourner la caméra</string>
<string name="icon_descr_call_pending_sent">Appel en suspend</string>
<string name="icon_descr_call_missed">Appel manqué</string>
<string name="icon_descr_call_connecting">Appel en connexion</string>
<string name="answer_call">Répondre à l\'appel</string>
<string name="integrity_msg_bad_hash">hash de message incorrect</string>
<string name="integrity_msg_duplicate">message dupliqué</string>
<string name="alert_title_skipped_messages">Messages manqués</string>
<string name="privacy_and_security">Vie privée et sécurité</string>
<string name="protect_app_screen">Protéger l\'écran de l\'app</string>
<string name="auto_accept_images">Images auto-acceptées</string>
<string name="transfer_images_faster">Transfert d\'images plus rapide</string>
<string name="full_backup">Sauvegarde des données de l\'app</string>
<string name="settings_section_title_you">VOUS</string>
<string name="settings_section_title_help">AIDE</string>
<string name="settings_section_title_support">SOUTENEZ SIMPLEX CHAT</string>
<string name="settings_section_title_incognito">Mode Incognito</string>
<string name="chat_is_running">Le chat est en cours d\'exécution</string>
<string name="chat_is_stopped">Le chat est arrêté</string>
<string name="chat_database_section">BASE DE DONNÉES DU CHAT</string>
<string name="database_passphrase">Phrase secrète de la base de données</string>
<string name="export_database">Exporter la base de données</string>
<string name="stop_chat_confirmation">Arrêter</string>
<string name="set_password_to_export">Définir la phrase secrète pour l\'export</string>
<string name="set_password_to_export_desc">La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire. Veuillez la changer avant d\'exporter.</string>
<string name="error_importing_database">Erreur lors de l\'importation de la base de données du chat</string>
<string name="chat_database_imported">Base de données du chat importée</string>
<string name="delete_chat_profile_question">Supprimer le profil du chat \?</string>
<string name="stop_chat_to_enable_database_actions">Arrêter le chat pour agir sur la base de données.</string>
<string name="delete_files_and_media_question">Supprimer les fichiers et médias \?</string>
<string name="delete_files_and_media">"Supprimer les fichiers médias"</string>
<string name="delete_files_and_media_desc">Cette action ne peut être annulée - tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées.</string>
<string name="no_received_app_files">Aucun fichier reçu ou envoyé</string>
<string name="chat_item_ttl_month">1 mois</string>
<string name="chat_item_ttl_seconds">%s seconde·s</string>
<string name="delete_messages_after">Supprimer les messages après</string>
<string name="enable_automatic_deletion_question">Activer la suppression automatique des messages \?</string>
<string name="error_changing_message_deletion">Erreur de changement de paramètre</string>
<string name="remove_passphrase_from_keychain">Retirer la phrase secrète du Keystore \?</string>
<string name="notifications_will_be_hidden">Les notifications seront délivrées jusqu\'à ce que l\'application s\'arrête !</string>
<string name="remove_passphrase">Supprimer</string>
<string name="current_passphrase">Phrase secrète actuelle…</string>
<string name="new_passphrase">Nouvelle phrase secrète…</string>
<string name="confirm_new_passphrase">Confirmer la nouvelle phrase secrète…</string>
<string name="update_database_passphrase">Mise à jour de la phrase secrète de la base de données</string>
<string name="keychain_is_storing_securely">Le Keystore d\'Android est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner.</string>
<string name="you_have_to_enter_passphrase_every_time">Vous devez saisir la phrase secrète à chaque fois que l\'application démarre - elle n\'est pas stockée sur l\'appareil.</string>
<string name="encrypt_database_question">Chiffrer la base de données \?</string>
<string name="change_database_passphrase_question">Changer la phrase secrète de la base de données \?</string>
<string name="database_will_be_encrypted">La base de données sera chiffrée.</string>
<string name="keychain_error">Erreur de la keychain</string>
<string name="file_with_path">Fichier : %s</string>
<string name="database_passphrase_is_required">La phrase secrète de la base de données est nécessaire pour ouvrir le chat.</string>
<string name="save_passphrase_and_open_chat">Sauvegarder la phrase secrète et ouvrir le chat</string>
<string name="open_chat">Ouvrir le chat</string>
<string name="database_backup_can_be_restored">La tentative de modification de la phrase secrète de la base de données n\'a pas abouti.</string>
<string name="restore_database_alert_title">Restaurer la sauvegarde de la base de données \?</string>
<string name="restore_database_alert_confirm">Restaurer</string>
<string name="chat_is_stopped_indication">Le chat est arrêté</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Vous pouvez lancer le chat via les Paramètres / la Base de données de l\'app ou en la redémarrant.</string>
<string name="chat_archive_header">Archives du chat</string>
<string name="chat_archive_section">ARCHIVE DU CHAT</string>
<string name="save_archive">Sauvegarder l\'archive</string>
<string name="delete_archive">Supprimer l\'archive</string>
<string name="delete_chat_archive_question">Supprimer l\'archive du chat \?</string>
<string name="group_invitation_item_description">Invitation au groupe <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Rejoindre le groupe \?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Vous êtes invité·e dans un groupe. Rejoignez le pour vous connecter avec ses membres.</string>
<string name="join_group_button">Rejoindre</string>
<string name="join_group_incognito_button">Rejoindre en incognito</string>
<string name="joining_group">Entrain de rejoindre le groupe</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Vous avez rejoint ce groupe. Connexion à l\'invitation d\'un membre du groupe.</string>
<string name="leave_group_button">Quitter</string>
<string name="you_are_invited_to_group">Vous êtes invité·e au groupe</string>
<string name="group_invitation_tap_to_join">Appuyez pour rejoindre</string>
<string name="group_invitation_tap_to_join_incognito">Appuyez pour rejoindre incognito</string>
<string name="group_invitation_expired">Invitation au groupe expirée</string>
<string name="rcv_group_event_member_added">a invité <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_connected">est connecté·e</string>
<string name="rcv_group_event_changed_member_role">a modifié le rôle de %s pour %s</string>
<string name="rcv_group_event_changed_your_role">a modifié votre rôle pour %s</string>
<string name="rcv_group_event_user_deleted">vous a retiré</string>
<string name="rcv_group_event_group_deleted">a supprimé le groupe</string>
<string name="rcv_group_event_updated_group_profile">mise à jour du profil de groupe</string>
<string name="snd_group_event_changed_member_role">vous avez modifié le rôle de %s pour %s</string>
<string name="snd_group_event_changed_role_for_yourself">vous avez modifié votre rôle pour %s</string>
<string name="snd_group_event_user_left">vous avez quitté</string>
<string name="snd_group_event_group_profile_updated">mise à jour du profil de groupe</string>
<string name="rcv_conn_event_switch_queue_phase_completed">adresse modifiée pour vous</string>
<string name="snd_conn_event_switch_queue_phase_changing_for_member">changement d\'adresse pour %s…</string>
<string name="snd_conn_event_switch_queue_phase_changing">changement d\'adresse…</string>
<string name="group_member_role_member">membre</string>
<string name="group_member_role_admin">admin</string>
<string name="group_member_role_owner">propriétaire</string>
<string name="group_member_status_removed">supprimé</string>
<string name="group_member_status_left">a quitté</string>
<string name="group_member_status_group_deleted">groupe supprimé</string>
<string name="group_member_status_invited">invité·e</string>
<string name="group_member_status_introduced">connexion (introduite)</string>
<string name="group_member_status_intro_invitation">connexion (introduite par invitation)</string>
<string name="group_member_status_accepted">connexion (acceptée)</string>
<string name="group_member_status_announced">connexion (annoncée)</string>
<string name="group_member_status_connected">connecté</string>
<string name="group_member_status_complete">complet</string>
<string name="group_member_status_creator">créateur</string>
<string name="group_member_status_connecting">connexion</string>
<string name="no_contacts_to_add">Aucun contact à ajouter</string>
<string name="new_member_role">Nouveau rôle</string>
<string name="delete_group_question">Supprimer le groupe\?</string>
<string name="group_link">Lien du groupe</string>
<string name="button_create_group_link">Créer un lien</string>
<string name="button_edit_group_profile">Modifier le profil du groupe</string>
<string name="remove_member_confirmation">Supprimer</string>
<string name="member_info_section_title_member">MEMBRE</string>
<string name="live_message">Message dynamique !</string>
<string name="send_live_message">Envoyer un message dynamique</string>
<string name="send_live_message_desc">Envoyez un message dynamique - il sera mis à jour pour le⸱s destinataire⸱s au fur et à mesure que vous le tapez</string>
<string name="send_verb">Envoyer</string>
<string name="member_role_will_be_changed_with_invitation">Le rôle sera changé pour «%s». Le membre va recevoir une nouvelle invitation.</string>
<string name="live">LIVE</string>
<string name="button_add_members">Inviter des membres</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">Vous pouvez partager un lien ou un code QR - n\'importe qui pourra rejoindre le groupe. Vous ne perdrez pas les membres du groupe si vous le supprimez par la suite.</string>
<string name="info_row_local_name">Nom local</string>
<string name="create_group_link">Créer un lien de groupe</string>
<string name="error_deleting_link_for_group">Erreur lors de la suppression du lien du groupe</string>
<string name="error_creating_link_for_group">Erreur lors de la création du lien du groupe</string>
<string name="only_group_owners_can_change_prefs">Seuls les propriétaires du groupe peuvent modifier les préférences du groupe.</string>
<string name="section_title_for_console">POUR TERMINAL</string>
<string name="change_member_role_question">Changer le rôle du groupe \?</string>
<string name="member_role_will_be_changed_with_notification">Le rôle sera changé pour «%s». Les membres du groupe seront notifiés.</string>
<string name="icon_descr_contact_checked">Contact vérifié⸱e</string>
<string name="clear_contacts_selection_button">Effacer</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact·s sélectionné·e·s</string>
<string name="skip_inviting_button">Passer linvitation de membres</string>
<string name="select_contacts">Sélectionnez des contacts</string>
<string name="no_contacts_selected">Aucun contact sélectionné</string>
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBRES</string>
<string name="group_info_member_you">vous : <xliff:g id="group_info_you">%1$s</xliff:g></string>
<string name="button_delete_group">Supprimer le groupe</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Le groupe va être supprimé pour tout les membres - impossible de revenir en arrière!</string>
<string name="delete_group_for_self_cannot_undo_warning">Le groupe va être supprimé pour vous - impossible de revenir en arrière!</string>
<string name="button_leave_group">Quitter le groupe</string>
<string name="delete_link_question">Supprimer le lien\?</string>
<string name="delete_link">Supprimer le lien</string>
<string name="all_group_members_will_remain_connected">Tous les membres du groupe resteront connectés.</string>
<string name="icon_descr_expand_role">Étendre la sélection de rôle</string>
<string name="invite_to_group_button">Inviter au groupe</string>
<string name="invite_prohibited">Impossible d\'inviter le contact!</string>
<string name="invite_prohibited_description">Vous essayez d\'inviter un contact avec lequel vous avez partagé un profil incognito à rejoindre le groupe dans lequel vous utilisez votre profil principal</string>
<string name="info_row_database_id">ID de base de données</string>
<string name="button_remove_member">Retirer le membre</string>
<string name="button_send_direct_message">Envoi de message direct</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Ce membre sera retiré du groupe - impossible de revenir en arrière!</string>
<string name="role_in_group">Rôle</string>
<string name="change_role">Changer le rôle</string>
<string name="change_verb">Changer</string>
<string name="switch_verb">Échanger</string>
<string name="error_removing_member">Erreur lors de la suppression d\'un membre</string>
<string name="error_changing_role">Erreur lors du changement de rôle</string>
<string name="group_full_name_field">Nom complet du groupe :</string>
<string name="update_network_settings_confirmation">Mise à jour</string>
<string name="chat_preferences_on">on</string>
<string name="chat_preferences_off">off</string>
<string name="direct_messages">Messages dynamiques</string>
<string name="full_deletion">Supprimer pour tous</string>
<string name="only_you_can_delete_messages">Vous êtes le seul à pouvoir supprimer des messages de manière irréversible (votre contact peut les marquer pour suppression).</string>
<string name="conn_stats_section_title_servers">SERVEURS</string>
<string name="receiving_via">Réception via</string>
<string name="theme_system">Système</string>
<string name="allow_direct_messages">Autoriser l\'envoi de messages directs aux membres.</string>
<string name="prohibit_direct_messages">Interdire l\'envoi de messages directs aux membres.</string>
<string name="group_members_can_delete">Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés.</string>
<string name="message_deletion_prohibited_in_chat">La suppression irréversible de messages est interdite dans ce groupe.</string>
<string name="sending_via">Envoyé via</string>
<string name="network_status">État du réseau</string>
<string name="switch_receiving_address">Changer d\'adresse de réception</string>
<string name="create_secret_group_title">Créer un groupe secret</string>
<string name="group_main_profile_sent">Votre profil de chat sera envoyé aux membres du groupe</string>
<string name="network_option_enable_tcp_keep_alive">Activer le TCP keep-alive</string>
<string name="network_options_save">Sauvegarder</string>
<string name="update_network_settings_question">Mettre à jour les paramètres réseau \?</string>
<string name="incognito">Incognito</string>
<string name="incognito_random_profile">Votre profil aléatoire</string>
<string name="incognito_random_profile_description">Un profil aléatoire sera envoyé à votre contact</string>
<string name="incognito_random_profile_from_contact_description">Un profil aléatoire sera envoyé au contact qui vous a envoyé ce lien</string>
<string name="incognito_info_allows">Cela permet d\'avoir plusieurs connections anonymes sans aucune données partagées entre elles sur un même profil.</string>
<string name="incognito_info_find">Pour trouver le profil utilisé lors d\'une connexion incognito, appuyez sur le nom du contact ou du groupe en haut du chat.</string>
<string name="theme_light">Clair</string>
<string name="theme_dark">Sombre</string>
<string name="theme">Thème</string>
<string name="save_color">Sauvegarder la couleur</string>
<string name="reset_color">Réinitialisation des couleurs</string>
<string name="color_primary">Principale</string>
<string name="chat_preferences_you_allow">Vous autorisez</string>
<string name="chat_preferences_contact_allows">Votre contact autorise</string>
<string name="chat_preferences_default">par défaut (%s)</string>
<string name="chat_preferences_no">non</string>
<string name="chat_preferences_always">toujours</string>
<string name="chat_preferences">Préférences de chat</string>
<string name="contact_preferences">Préférences de contact</string>
<string name="group_preferences">Préférences du groupe</string>
<string name="set_group_preferences">Définir les préférences du groupe</string>
<string name="your_preferences">Vos préférences</string>
<string name="allow_your_contacts_irreversibly_delete">Autorise votre contact à supprimer de façon définitive des messages envoyés.</string>
<string name="contacts_can_mark_messages_for_deletion">Vos contacts peuvent marquer les messages pour les supprimer ; vous pourrez les consulter.</string>
<string name="allow_your_contacts_to_send_voice_messages">Autorise vos contacts à envoyer des messages vocaux.</string>
<string name="allow_voice_messages_only_if">Autoriser les messages vocaux uniquement si votre contact les autorise.</string>
<string name="prohibit_sending_voice_messages">Interdire l\'envoi de messages vocaux.</string>
<string name="only_you_can_send_disappearing">Seulement vous pouvez envoyer des messages éphémères.</string>
<string name="only_you_can_send_voice">Vous seul pouvez envoyer des messages vocaux.</string>
<string name="allow_to_delete_messages">Autoriser la suppression irréversible de messages envoyés.</string>
<string name="disappearing_messages_are_prohibited">Les messages éphémères sont interdits dans ce groupe.</string>
<string name="group_members_can_send_voice">Les membres du groupe peuvent envoyer des messages vocaux.</string>
<string name="delete_after">Supprimer après</string>
<string name="ttl_sec">%d sec</string>
<string name="ttl_s">%ds</string>
<string name="ttl_min">%d min</string>
<string name="ttl_month">%d mois</string>
<string name="ttl_months">%d mois</string>
<string name="ttl_m">%dm</string>
<string name="ttl_mth">%dm</string>
<string name="ttl_hour">%d heure</string>
<string name="ttl_hours">%d heures</string>
<string name="ttl_h">%dh</string>
<string name="ttl_day">%d jour</string>
<string name="ttl_days">%d jours</string>
<string name="ttl_d">%dj</string>
<string name="ttl_week">%d semaine</string>
<string name="ttl_weeks">%d semaines</string>
<string name="ttl_w">%dsmn</string>
<string name="timed_messages">Messages éphémères</string>
<string name="voice_messages">Messages vocaux</string>
<string name="feature_enabled">activé</string>
<string name="feature_enabled_for_you">activé pour vous</string>
<string name="feature_enabled_for_contact">activé pour le contact</string>
<string name="feature_off">off</string>
<string name="feature_received_prohibited">reçu, non autorisé</string>
<string name="allow_your_contacts_to_send_disappearing_messages">Autorise votre contact à envoyer des messages éphémères.</string>
<string name="conn_level_desc_direct">directe</string>
<string name="group_is_decentralized">Le groupe est entièrement décentralisé il n\'est visible que par ses membres.</string>
<string name="group_members_can_send_disappearing">Les membres du groupes peuvent envoyer des messages éphémères.</string>
<string name="network_options_revert">Revenir en arrière</string>
<string name="prohibit_sending_disappearing_messages">Interdit lenvoi de messages éphémères.</string>
<string name="incognito_info_protects">Le mode Incognito protège la confidentialité de votre profil principal — pour chaque nouveau contact un nouveau profil aléatoire est créé.</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">La mise à jour des ces paramètres reconnectera le client à tous les serveurs.</string>
<string name="incognito_info_share">Lorsque vous partagez un profil incognito avec quelqu\'un, ce profil sera utilisé pour les groupes auxquels il vous invite.</string>
<string name="chat_preferences_yes">oui</string>
<string name="allow_disappearing_messages_only_if">Autorise les messages éphémères seulement si votre contact les autorises.</string>
<string name="allow_irreversible_message_deletion_only_if">Autoriser la suppression irréversible des messages uniquement si votre contact vous l\'autorise.</string>
<string name="only_your_contact_can_delete">Seul votre contact peut supprimer de manière irréversible des messages (vous pouvez les marquer pour suppression).</string>
<string name="only_your_contact_can_send_disappearing">Seulement votre contact peut envoyer des messages éphémères.</string>
<string name="both_you_and_your_contact_can_send_disappearing">Vous et votre contact peuvent envoyer des messages éphémères.</string>
<string name="voice_messages_are_prohibited">Les messages vocaux sont interdits dans ce groupe.</string>
<string name="group_display_name_field">Nom affiché du groupe :</string>
<string name="group_unsupported_incognito_main_profile_sent">Le mode Incognito n\'est pas supporté ici - votre profil principal sera envoyé aux membres du groupe</string>
<string name="conn_level_desc_indirect">indirecte (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<string name="info_row_group">Groupe</string>
<string name="info_row_connection">Connexion</string>
<string name="network_option_seconds_label">sec</string>
<string name="network_option_tcp_connection_timeout">Délai de connexion TCP</string>
<string name="group_profile_is_stored_on_members_devices">Le profil du groupe est stocké sur les appareils des membres, pas sur les serveurs.</string>
<string name="save_group_profile">Sauvegarder le profil du groupe</string>
<string name="error_saving_group_profile">Erreur lors de la sauvegarde du profil de groupe</string>
<string name="network_options_reset_to_defaults">Réinitialisation des valeurs par défaut</string>
<string name="network_option_protocol_timeout">Délai du protocole</string>
<string name="network_option_ping_interval">Intervalle de PING</string>
<string name="both_you_and_your_contacts_can_delete">Vous et votre contact pouvez tous deux supprimer de manière irréversible les messages envoyés.</string>
<string name="message_deletion_prohibited">La suppression irréversible de message est interdite dans ce chat.</string>
<string name="both_you_and_your_contact_can_send_voice">Vous et votre contact pouvez tous deux supprimer de manière irréversible les messages envoyés.</string>
<string name="only_your_contact_can_send_voice">Seul votre contact peut envoyer des messages vocaux.</string>
<string name="voice_prohibited_in_this_chat">Les messages vocaux sont interdits dans ce chat.</string>
<string name="disappearing_prohibited_in_this_chat">Les messages éphémères sont interdits dans cette discussion.</string>
<string name="allow_to_send_voice">Autoriser l\'envoi de messages vocaux.</string>
<string name="prohibit_sending_voice">Interdire l\'envoi de messages vocaux.</string>
<string name="allow_to_send_disappearing">Autorise lenvoi de messages éphémères.</string>
<string name="prohibit_sending_disappearing">Interdit lenvoi de messages éphémères.</string>
<string name="prohibit_message_deletion">Interdire la suppression irréversible des messages.</string>
<string name="group_members_can_send_dms">Les membres du groupe peuvent envoyer des messages directs.</string>
<string name="direct_messages_are_prohibited_in_chat">Les messages directs entre membres sont interdits dans ce groupe.</string>
</resources>

View File

@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
<string name="thousand_abbreviation">т</string>
<!-- Connect via Link - MainActivity.kt -->
<string name="connect_via_contact_link">Соединиться через ссылку-контакт?</string>
<string name="connect_via_invitation_link">Соединиться через ссылку-приглашение?</string>
@@ -10,7 +9,6 @@
<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 -->
<string name="server_connected">соединено</string>
<string name="server_error">ошибка</string>
@@ -18,15 +16,14 @@
<string name="connected_to_server_to_receive_messages_from_contact">Установлено соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="trying_to_connect_to_server_to_receive_messages">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">удалено</string>
<string name="marked_deleted_description">помечено к удалению</string>
<string name="sending_files_not_yet_supported">отправка файлов не поддерживается</string>
<string name="receiving_files_not_yet_supported">получение файлов не поддерживается</string>
<string name="sender_you_pronoun">вы</string>
<string name="unknown_message_format">неизвестный формат сообщения</string>
<string name="invalid_message_format">неверный формат сообщения</string>
<!-- PendingContactConnection - ChatModel.kt -->
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
<string name="display_name_connection_established">соединение установлено</string>
@@ -40,16 +37,24 @@
<string name="description_via_contact_address_link_incognito">инкогнито через ссылку-контакт</string>
<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>
<string name="error_setting_network_config">Ошибка при сохранении настроек сети</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Превышено время соединения</string>
<string name="connection_error">Ошибка соединения</string>
<string name="network_error_desc">Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз.</string>
<string name="network_error_desc">Пожалуйста, проверьте ваше соединение с сервером <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> и попробуйте еще раз.</string>
<string name="error_sending_message">Ошибка при отправке сообщения</string>
<string name="error_adding_members">Ошибка при добавлении членов группы</string>
<string name="error_joining_group">Ошибка при вступлении в группу</string>
@@ -70,7 +75,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>
<string name="service_notifications">Мгновенные уведомления!</string>
@@ -86,17 +98,13 @@
<string name="enter_passphrase_notification_desc">Для получения уведомлений, пожалуйста, введите пароль от базы данных</string>
<string name="database_initialization_error_title">Ошибка базы данных</string>
<string name="database_initialization_error_desc">Ошибка при инициализации базы данных. Нажмите чтобы узнать больше</string>
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
<string name="simplex_service_notification_text">Приём сообщений…</string>
<string name="hide_notification">Скрыть</string>
<!-- Notification channels -->
<string name="ntf_channel_messages">SimpleX Chat сообщения</string>
<string name="ntf_channel_calls">SimpleX Chat звонки</string>
<string name="ntf_channel_calls_lockscreen">SimpleX Chat звонки (экран блокировки)</string>
<!-- Notifications -->
<string name="settings_notifications_mode_title">Сервис уведомлений</string>
<string name="settings_notification_preview_mode_title">Показывать уведомления</string>
@@ -117,12 +125,10 @@
<string name="notification_preview_new_message">новое сообщение</string>
<string name="notification_new_contact_request">Новый запрос на соединение</string>
<string name="notification_contact_connected">Соединен(а)</string>
<!-- local authentication notice - SimpleXAPI.kt -->
<string name="la_notice_title_simplex_lock">Блокировка SimpleX</string>
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Чтобы защитить вашу информацию, включите блокировку <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.\nВам будет нужно пройти аутентификацию для включения блокировки.</string>
<string name="la_notice_turn_on">Включить</string>
<!-- LocalAuthentication.kt -->
<string name="auth_simplex_lock_turned_on">Блокировка SimpleX включена</string>
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме.</string>
@@ -136,11 +142,9 @@
<string name="auth_device_authentication_is_disabled_turning_off">Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</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>
@@ -148,18 +152,20 @@
<string name="save_verb">Сохранить</string>
<string name="edit_verb">Редактировать</string>
<string name="delete_verb">Удалить</string>
<string name="reveal_verb">Показать</string>
<string name="hide_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>
<string name="delete_message_mark_deleted_warning">Сообщение будет помечено на удаление. Получатель(и) сможет(смогут) посмотреть это сообщение.</string>
<string name="for_me_only">Удалить для меня</string>
<string name="for_everybody">Для всех</string>
<!-- CIMetaView.kt -->
<string name="icon_descr_edited">отредактировано</string>
<string name="icon_descr_sent_msg_status_sent">отправлено</string>
<string name="icon_descr_sent_msg_status_unauthorized_send">ошибка авторизации при отправке</string>
<string name="icon_descr_sent_msg_status_send_failed">ошибка при отправке</string>
<string name="icon_descr_received_msg_status_unread">не прочитано</string>
<!-- ChatListView.kt -->
<string name="personal_welcome">Здравствуйте <xliff:g>%1$s</xliff:g>!</string>
<string name="welcome">Здравствуйте!</string>
@@ -172,12 +178,10 @@
<string name="tap_to_start_new_chat">Нажмите, чтобы начать чат</string>
<string name="chat_with_developers">Соединиться с разработчиками</string>
<string name="you_have_no_chats">У вас нет чатов</string>
<!-- ShareListView.kt -->
<string name="share_message">Отправить сообщение…</string>
<string name="share_image">Отправить изображение…</string>
<string name="share_file">Отправить файл…</string>
<!-- ComposeView.kt, helpers -->
<string name="attach">Прикрепить</string>
<string name="icon_descr_context">Значок контекста</string>
@@ -187,7 +191,6 @@
<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>
<string name="icon_descr_waiting_for_image">Ожидается прием изображения</string>
@@ -196,7 +199,6 @@
<string name="waiting_for_image">Ожидается прием изображения</string>
<string name="image_will_be_received_when_contact_is_online">Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже!</string>
<string name="image_saved">Изображение сохранено в Галерею</string>
<!-- Files - CIFileView.kt -->
<string name="icon_descr_file">Файл</string>
<string name="large_file">Большой файл!</string>
@@ -207,10 +209,12 @@
<string name="file_saved">Файл сохранен</string>
<string name="file_not_found">Файл не найден</string>
<string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Voice messages -->
<string name="voice_message">Голосовое сообщение</string>
<string name="voice_message_with_duration">Голосовое сообщение (<xliff:g id="duration">%1$s</xliff:g>)</string>
<string name="voice_message_send_text">Голосовое сообщение…</string>
<!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Уведомления</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact_question">Удалить контакт?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Контакт и все сообщения будут удалены - это действие нельзя отменить!</string>
@@ -222,19 +226,23 @@
<string name="icon_descr_server_status_pending">Ожидается соединение с сервером</string>
<string name="switch_receiving_address_question">Переключить адрес получения?</string>
<string name="switch_receiving_address_desc">Это экспериментальная функция! Она будет работать, только если на другом клиенте установлена версия 4.2. После завершения смены адреса вы увидите сообщение — убедитесь, что вы все еще можете получать сообщения от этого контакта (или члена группы).</string>
<!-- 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>
<string name="copied">Скопировано в буфер обмена</string>
<!-- NewChatSheet -->
<string name="add_contact_or_create_group">Начать новый разговор</string>
<string name="share_one_time_link">Создать ссылку-приглашение</string>
@@ -244,13 +252,11 @@
<string name="to_share_with_your_contact">(чтобы отправить вашему контакту)</string>
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(сканировать или вставить из буфера)</string>
<string name="only_stored_on_members_devices">(хранится только у членов группы)</string>
<!-- GetImageView -->
<string name="toast_permission_denied">Разрешение не получено!</string>
<string name="use_camera_button">Камера</string>
<string name="from_gallery_button">Галерея</string>
<string name="choose_file">Файлы</string>
<!-- help - ChatHelpView.kt -->
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
<string name="you_can_connect_to_simplex_chat_founder">Вы можете <font color="#0088ff">соединиться с разработчиками</font>, чтобы задать любые вопросы или получать уведомления о новых версиях.</string>
@@ -263,14 +269,12 @@
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Если вы получили ссылку с приглашением из <xliff:g id="appName">SimpleX Chat</xliff:g>, вы можете открыть ее в браузере:</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 на компьютере: сосканируйте показанный QR код из приложения через <b>Сканировать QR код</b>.</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 на мобильном: намжите кнопку <b>Open in mobile app</b> на веб странице, затем нажмите <b>Соединиться</b> в приложении.</string>
<!-- Contact Request Alert Dialogue - CharListNavLinkView.kt -->
<string name="accept_connection_request__question">Принять запрос на соединение?</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Отправителю НЕ будет послано уведомление, если вы отклоните запрос на соединение.</string>
<string name="accept_contact_button">Принять</string>
<string name="accept_contact_incognito_button">Принять инкогнито</string>
<string name="reject_contact_button">Отклонить</string>
<!-- Clear Chat - ChatListNavLinkView.kt -->
<string name="clear_chat_question">Очистить чат?</string>
<string name="clear_chat_warning">Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для вас.</string>
@@ -282,29 +286,23 @@
<string name="mark_read">Прочитано</string>
<string name="mark_unread">Не прочитано</string>
<string name="set_contact_name">Имя контакта</string>
<!-- Actions - ChatListNavLinkView.kt -->
<string name="mute_chat">Без звука</string>
<string name="unmute_chat">Уведомлять</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">Вы пригласили ваш контакт</string>
<string name="you_accepted_connection">Вы приняли приглашение соединиться</string>
<string name="delete_pending_connection__question">Удалить ожидаемое соединение?</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Контакт, которому вы отправили эту ссылку, не сможет соединиться!</string>
<string name="connection_you_accepted_will_be_cancelled">Подтвержденное соединение будет отменено!</string>
<!-- Connection Pending Alert Dialogue - ChatListNavLinkView.kt -->
<string name="alert_title_contact_connection_pending">Соединение еще не установлено!</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой).</string>
<!-- Contact Request Information - ContactRequestView.kt -->
<string name="contact_wants_to_connect_with_you">хочет соединиться с вами!</string>
<!-- Image Placeholder - ChatInfoImage.kt -->
<string name="icon_descr_profile_image_placeholder">аватар не установлен</string>
<string name="image_descr_profile_image">аватар</string>
<!-- Content Descriptions -->
<string name="icon_descr_close_button">закрыть</string>
<string name="image_descr_link_preview">изображение превью ссылки</string>
@@ -317,10 +315,8 @@
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> логотип</string>
<string name="icon_descr_email">Email</string>
<string name="icon_descr_more_button">Больше</string>
<!-- Connection info - ContactConnectionInfoView.kt -->
<string name="show_QR_code">Показать QR код</string>
<!-- Add Contact - AddContactView.kt -->
<string name="invalid_QR_code">Неверный QR код</string>
<string name="this_QR_code_is_not_a_link">Этот QR код не является ссылкой!</string>
@@ -337,16 +333,13 @@
<string name="share_invitation_link">Поделиться ссылкой</string>
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
<string name="your_profile_will_be_sent">Ваш профиль будет отправлен вашему контакту</string>
<!-- PasteToConnect.kt -->
<string name="connect_button">Соединиться</string>
<string name="paste_button">Вставить</string>
<!-- CreateLinkView.kt -->
<string name="create_one_time_link">Создать одноразовую ссылку</string>
<string name="one_time_link">Одноразовая ссылка</string>
<string name="your_contact_address">Ваш SimpleX адрес</string>
<!-- settings - SettingsView.kt -->
<string name="your_settings">Настройки</string>
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
@@ -360,17 +353,34 @@
<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>
@@ -398,7 +408,6 @@
<string name="network_use_onion_hosts_no_desc_in_alert">Onion хосты не используются.</string>
<string name="network_use_onion_hosts_required_desc_in_alert">Подключаться только к onion хостам.</string>
<string name="appearance_settings">Интерфейс</string>
<!-- Address Items - UserAddressView.kt -->
<string name="create_address">Создать адрес</string>
<string name="delete_address__question">Удалить адрес?</string>
@@ -406,13 +415,11 @@
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились.</string>
<string name="share_link">Поделиться\nссылкой</string>
<string name="delete_address">Удалить\nадрес</string>
<!-- AcceptRequestsView.kt -->
<string name="contact_requests">Запросы контактов</string>
<string name="accept_requests">Принимать запросы</string>
<string name="accept_automatically">Автоматически</string>
<string name="section_title_welcome_message">ПРИВЕТСТВЕННОЕ СООБЩЕНИЕ</string>
<!-- User profile details - UserProfileView.kt -->
<string name="display_name__field">Имя профиля:</string>
<string name="full_name__field">"Полное имя:</string>
@@ -420,8 +427,11 @@
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\n<xliff:g id="appName">SimpleX</xliff:g> серверы не могут получить доступ к вашему профилю.</string>
<string name="edit_image">Поменять аватар</string>
<string name="delete_image">Удалить аватар</string>
<string name="save_and_notify_contacts">Сохранить (и послать обновление контактам)</string>
<string name="save_preferences_question">Сохранить предпочтения?</string>
<string name="save_and_notify_contact">Сохранить и уведомить контакт</string>
<string name="save_and_notify_contacts">Сохранить и уведомить контакты</string>
<string name="save_and_notify_group_members">Сохранить и уведомить членов группы</string>
<string name="exit_without_saving">Выйти без сохранения</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность.</string>
@@ -434,7 +444,6 @@
<string name="full_name_optional__prompt">Полное имя (не обязательно)</string>
<string name="create_profile_button">Создать</string>
<string name="about_simplex">О SimpleX</string>
<!-- markdown demo - MarkdownHelpView.kt -->
<string name="how_to_use_markdown">Как форматировать</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Вы можете форматировать сообщения:</string>
@@ -447,7 +456,6 @@
<string name="connect_via_link">Соединиться через ссылку</string>
<string name="this_string_is_not_a_connection_link">Эта строка не является ссылкой-приглашением!</string>
<string name="you_can_also_connect_by_clicking_the_link">Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку <b>Открыть в приложении</b>.</string>
<!-- CICallStatus -->
<string name="callstatus_calling">входящий звонок…</string>
<string name="callstatus_missed">пропущенный звонок</string>
@@ -457,7 +465,6 @@
<string name="callstatus_in_progress">активный звонок</string>
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstatus_error">ошибка звонка</string>
<!-- CallState -->
<string name="callstate_starting">инициализация…</string>
<string name="callstate_waiting_for_answer">ожидается ответ…</string>
@@ -467,7 +474,6 @@
<string name="callstate_connecting">соединяется…</string>
<string name="callstate_connected">соединено</string>
<string name="callstate_ended">завершен</string>
<!-- SimpleXInfo -->
<string name="next_generation_of_private_messaging">Новое поколение приватных сообщений</string>
<string name="privacy_redefined">Более конфиденциальный</string>
@@ -479,7 +485,6 @@
<string name="create_your_profile">Создать профиль</string>
<string name="make_private_connection">Добавьте контакт</string>
<string name="how_it_works">Как это работает</string>
<!-- How SimpleX Works -->
<string name="how_simplex_works">Как <xliff:g id="appName">SimpleX</xliff:g> работает</string>
<string name="many_people_asked_how_can_it_deliver">Много пользователей спросили: <i>как <xliff:g id="appName">SimpleX</xliff:g> доставляет сообщения без идентификаторов пользователей?</i></string>
@@ -488,10 +493,10 @@
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются <b>с двухуровневым end-to-end шифрованием</b>.</string>
<string name="read_more_in_github">Узнайте больше из нашего GitHub репозитория.</string>
<string name="read_more_in_github_with_link">Узнайте больше из нашего <font color="#0088ff">GitHub репозитория</font>.</string>
<!-- SetNotificationsMode.kt -->
<string name="use_chat">Использовать чат</string>
<!-- MakeConnection -->
<string name="paste_the_link_you_received">Вставить полученную ссылку</string>
<!-- Call -->
<string name="incoming_video_call">Входящий видеозвонок</string>
<string name="incoming_audio_call">Входящий аудиозвонок</string>
@@ -506,7 +511,6 @@
<string name="call_already_ended">Звонок уже завершен!</string>
<string name="icon_descr_video_call">видеозвонок</string>
<string name="icon_descr_audio_call">аудиозвонок</string>
<!-- Call settings -->
<string name="settings_audio_video_calls">Аудио- и видеозвонки</string>
<string name="your_calls">Ваши звонки</string>
@@ -517,12 +521,10 @@
<string name="no_call_on_lock_screen">Выключить</string>
<string name="your_ice_servers">Ваши ICE серверы</string>
<string name="webrtc_ice_servers">WebRTC ICE серверы</string>
<!-- Call Lock Screen -->
<string name="open_simplex_chat_to_accept_call">Откройте <xliff:g id="appNameFull">SimpleX Chat</xliff:g>\nчтобы принять звонок</string>
<string name="allow_accepting_calls_from_lock_screen">Вы можете разрешить принимать звонки на экране блокировки через Настройки.</string>
<string name="open_verb">Открыть</string>
<!-- Call overlay -->
<string name="status_e2e_encrypted">e2e зашифровано</string>
<string name="status_no_e2e_encryption">нет e2e шифрования</string>
@@ -538,7 +540,6 @@
<string name="icon_descr_speaker_off">Выключить спикер</string>
<string name="icon_descr_speaker_on">Включить спикер</string>
<string name="icon_descr_flip_camera">Перевернуть камеру</string>
<!-- Call items -->
<string name="icon_descr_call_pending_sent">Входящий звонок</string>
<string name="icon_descr_call_missed">Пропущенный звонок</string>
@@ -547,7 +548,6 @@
<string name="icon_descr_call_progress">Текущий звонок</string>
<string name="icon_descr_call_ended">Звонок завершен</string>
<string name="answer_call">Принять звонок</string>
<!-- Message integrity -->
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> пропущенных сообщений</string>
<string name="integrity_msg_bad_hash">ошибка хэш сообщения</string>
@@ -555,14 +555,14 @@
<string name="integrity_msg_duplicate">повторное сообщение</string>
<string name="alert_title_skipped_messages">Пропущенные сообщения</string>
<string name="alert_text_skipped_messages_it_can_happen_when">Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения.</string>
<!-- Privacy settings -->
<string name="privacy_and_security">Конфиденциальность</string>
<string name="your_privacy">Конфиденциальность</string>
<string name="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>
<string name="full_backup">Резервная копия данных</string>
<!-- Settings sections -->
<string name="settings_section_title_you">ВЫ</string>
<string name="settings_section_title_settings">НАСТРОЙКИ</string>
@@ -579,7 +579,6 @@
<string name="settings_section_title_messages">СООБЩЕНИЯ</string>
<string name="settings_section_title_calls">ЗВОНКИ</string>
<string name="settings_section_title_incognito">Режим Инкогнито</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">База данных</string>
<string name="run_chat_section">ЗАПУСТИТЬ ЧАТ</string>
@@ -629,7 +628,6 @@
<string name="enable_automatic_deletion_message">Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут.</string>
<string name="delete_messages">Удалить сообщения</string>
<string name="error_changing_message_deletion">Ошибка при изменении настройки</string>
<!-- DatabaseEncryptionView.kt -->
<string name="save_passphrase_in_keychain">Сохранить пароль в Keystore</string>
<string name="database_encrypted">База данных зашифрована!</string>
@@ -658,7 +656,6 @@
<string name="database_passphrase_will_be_updated">Пароль базы данных будет изменен.</string>
<string name="store_passphrase_securely">Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</string>
<string name="store_passphrase_securely_without_recover">Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его.</string>
<!-- DatabaseErrorView.kt -->
<string name="wrong_passphrase">Неправильный пароль базы данных</string>
<string name="encrypted_database">База данных зашифрована</string>
@@ -682,11 +679,10 @@
<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>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Вы можете запустить чат через Настройки приложения или перезапустив приложение.</string>
<!-- ChatArchiveView.kt -->
<string name="chat_archive_header">Архив чата</string>
<string name="chat_archive_section">АРХИВ ЧАТА</string>
@@ -694,7 +690,6 @@
<string name="delete_archive">Удалить архив</string>
<string name="archive_created_on_ts">Дата создания <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="delete_chat_archive_question">Удалить архив чата?</string>
<!-- Groups -->
<string name="group_invitation_item_description">приглашение в группу <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Вступить в группу?</string>
@@ -714,7 +709,6 @@
<string name="alert_message_no_group">Эта группа больше не существует.</string>
<string name="alert_title_cant_invite_contacts">Нельзя пригласить контакты!</string>
<string name="alert_title_cant_invite_contacts_descr">Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>
<string name="you_are_invited_to_group">Вы приглашены в группу</string>
@@ -723,7 +717,6 @@
<string name="you_joined_this_group">Вы вступили в эту группу</string>
<string name="you_rejected_group_invitation">Вы отклонили приглашение в группу</string>
<string name="group_invitation_expired">Приглашение в группу истекло</string>
<!-- Group event chat items -->
<string name="rcv_group_event_member_added">пригласил(а) <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_connected">соединен(а)</string>
@@ -740,7 +733,6 @@
<string name="snd_group_event_member_deleted">вы удалили <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">вы покинули группу</string>
<string name="snd_group_event_group_profile_updated">профиль группы обновлен</string>
<!-- Conn event chat items -->
<string name="rcv_conn_event_switch_queue_phase_completed">поменял(а) адрес для вас</string>
<string name="rcv_conn_event_switch_queue_phase_changing">смена адреса…</string>
@@ -748,12 +740,10 @@
<string name="snd_conn_event_switch_queue_phase_changing_for_member">смена адреса для %s…</string>
<string name="snd_conn_event_switch_queue_phase_completed">вы поменяли адрес</string>
<string name="snd_conn_event_switch_queue_phase_changing">смена адреса…</string>
<!-- GroupMemberRole -->
<string name="group_member_role_member">член группы</string>
<string name="group_member_role_admin">админ</string>
<string name="group_member_role_owner">владелец</string>
<!-- GroupMemberStatus -->
<string name="group_member_status_removed">удален(а)</string>
<string name="group_member_status_left">покинул(а)</string>
@@ -766,21 +756,20 @@
<string name="group_member_status_connected">соединен(а)</string>
<string name="group_member_status_complete">соединение завершено</string>
<string name="group_member_status_creator">создатель</string>
<string name="group_member_status_connecting">соединяется</string>
<!-- AddGroupMembersView.kt -->
<string name="no_contacts_to_add">Нет контактов для добавления</string>
<string name="new_member_role">Роль члена группы</string>
<string name="icon_descr_expand_role">Развернуть выбор роли</string>
<string name="invite_to_group_button">Пригласить в группу</string>
<string name="skip_inviting_button">Не приглашать членов</string>
<string name="select_contacts">Выберите контакты</string>
<string name="icon_descr_contact_checked">Контакт выбран</string>
<string name="clear_contacts_selection_button">Очистить</string>
<string name="num_contacts_selected">Выбрано контактов: <xliff:g id="num_contacts">%1$s</xliff:g></string>
<string name="no_contacts_selected">Контакты не выбраны</string>
<string name="invite_prohibited">Нельзя пригласить контакт!</string>
<string name="invite_prohibited_description">Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль</string>
<!-- GroupChatInfoView.kt -->
<string name="button_add_members">Пригласить членов группы</string>
<string name="group_info_section_title_num_members">ЧЛЕНОВ ГРУППЫ: <xliff:g id="num_members">%1$s</xliff:g></string>
@@ -799,12 +788,11 @@
<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>
<string name="info_row_local_name">Локальное имя</string>
<string name="info_row_database_id">ID базы данных</string>
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member">Удалить члена группы</string>
<string name="button_send_direct_message">Отправить сообщение</string>
@@ -824,14 +812,12 @@
<string name="info_row_connection">Соединение</string>
<string name="conn_level_desc_direct">прямое</string>
<string name="conn_level_desc_indirect">непрямое (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<!-- ConnectionStats -->
<string name="conn_stats_section_title_servers">СЕРВЕРЫ</string>
<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>
<string name="group_is_decentralized">Группа полностью децентрализована — она видна только членам.</string>
@@ -839,12 +825,10 @@
<string name="group_full_name_field">Полное имя:</string>
<string name="group_unsupported_incognito_main_profile_sent">Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы</string>
<string name="group_main_profile_sent">Ваш профиль чата будет отправлен членам группы</string>
<!-- GroupProfileView.kt -->
<string name="group_profile_is_stored_on_members_devices">Профиль группы хранится на устройствах членов, а не на серверах.</string>
<string name="save_group_profile">Сохранить профиль группы</string>
<string name="error_saving_group_profile">Ошибка при сохранении профиля группы</string>
<!-- AdvancedNetworkSettings.kt -->
<string name="network_options_reset_to_defaults">Сбросить настройки</string>
<string name="network_option_seconds_label">сек</string>
@@ -857,26 +841,127 @@
<string name="update_network_settings_question">Обновить настройки сети?</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">Обновление настроек приведет к переподключению клиента ко всем серверам.</string>
<string name="update_network_settings_confirmation">Обновить</string>
<!-- Incognito mode -->
<string name="incognito">Инкогнито</string>
<string name="incognito_random_profile">Случайный профиль</string>
<string name="incognito_random_profile_description">Вашему контакту будет отправлен случайный профиль</string>
<string name="incognito_random_profile_from_contact_description">Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль</string>
<string name="incognito_info_protects">Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль.</string>
<string name="incognito_info_allows">Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</string>
<string name="incognito_info_share">Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.</string>
<string name="incognito_info_find">Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата.</string>
<!-- Default themes -->
<string name="theme_system">Системная</string>
<string name="theme_light">Светлая</string>
<string name="theme_dark">Темная</string>
<!-- Appearance.kt -->
<string name="theme">Тема</string>
<string name="save_color">Сохранить цвет</string>
<string name="reset_color">Сбросить цвета</string>
<string name="color_primary">Акцент</string>
</resources>
<!-- 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="set_group_preferences">Предпочтения группы</string>
<string name="your_preferences">Ваши предпочтения</string>
<string name="direct_messages">Прямые сообщения</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_direct_messages">Разрешить посылать прямые сообщения членам группы.</string>
<string name="prohibit_direct_messages">Запретить посылать прямые сообщения членам группы.</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_send_dms">Члены группы могут посылать прямые сообщения.</string>
<string name="direct_messages_are_prohibited_in_chat">Прямые сообщения между членами группы запрещены.</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>
<string name="onboarding_notifications_mode_off_desc"><b>Минимальный расход батареи</b>. Вы получите уведомления только когда приложение запущено, без фонового сервиса.</string>
<string name="onboarding_notifications_mode_title">Уведомления</string>
<string name="onboarding_notifications_mode_off">Когда приложение запущено</string>
<string name="onboarding_notifications_mode_periodic">Периодически</string>
<string name="onboarding_notifications_mode_service">Мгновенно</string>
<string name="onboarding_notifications_mode_service_desc"><b>Больше расход батареи</b>! Фоновый сервис постоянно запущен - уведомления будут показаны как только есть новые сообщения.</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Меньше расход батареи</b>. Фоновый сервис проверяет новые сообщения каждые 10 минут. Вы можете пропустить звонки и срочные сообщения.</string>
<string name="onboarding_notifications_mode_subtitle">Можно изменить позже в настройках.</string>
<string name="live">LIVE</string>
<string name="send_live_message">Отправить живое сообщение</string>
<string name="live_message">Живое сообщение!</string>
<string name="send_verb">Отправить</string>
<string name="scan_code_from_contacts_app">Сканируйте код безопасности из приложения контакта.</string>
<string name="delete_after">Удалять через</string>
<string name="ttl_sec">%d сек</string>
<string name="ttl_s">%dс</string>
<string name="ttl_min">%d мин</string>
<string name="ttl_month">%d мес.</string>
<string name="ttl_months">%d мес.</string>
<string name="ttl_m">%dм</string>
<string name="ttl_mth">%dмес</string>
<string name="ttl_hour">%d час</string>
<string name="ttl_hours">%d ч.</string>
<string name="ttl_h">%dч</string>
<string name="ttl_day">%d день</string>
<string name="ttl_week">%d нед.</string>
<string name="timed_messages">Исчезающие сообщения</string>
<string name="view_security_code">Показать код безопасности</string>
<string name="verify_security_code">Подтвердить код безопасности</string>
<string name="both_you_and_your_contact_can_send_disappearing">Вы и ваш контакт можете отправлять исчезающие сообщения.</string>
<string name="only_you_can_send_disappearing">Только вы можете отправлять исчезающие сообщения.</string>
<string name="only_your_contact_can_send_disappearing">Только ваш контакт может отправлять исчезающие сообщения.</string>
<string name="disappearing_prohibited_in_this_chat">Исчезающие сообщения запрещены в этом чате.</string>
<string name="allow_to_send_disappearing">Разрешить посылать исчезающие сообщения.</string>
<string name="contact_developers">Пожалуйста, обновите приложение и свяжитесь с разработчиками.</string>
<string name="allow_your_contacts_to_send_disappearing_messages">Разрешить вашим контактам отправлять исчезающие сообщения.</string>
<string name="failed_to_parse_chat_title">Не удалось открыть чат</string>
<string name="failed_to_parse_chats_title">Не удалось открыть чаты</string>
<string name="incorrect_code">Неправильный код безопасности!</string>
<string name="scan_code">Сканировать код</string>
<string name="send_live_message_desc">Отправить живое сообщение — оно будет обновляться для получателей по мере того, как вы его вводите</string>
<string name="create_group_link">Создать ссылку группы</string>
<string name="prohibit_sending_disappearing_messages">Запретить отправлять исчезающие сообщения.</string>
<string name="disappearing_messages_are_prohibited">Исчезающие сообщения запрещены в этой группе.</string>
<string name="ttl_w">%dнед</string>
<string name="ttl_d">%dд</string>
<string name="ttl_weeks">%d нед.</string>
<string name="ttl_days">%d дней</string>
<string name="to_verify_compare">Чтобы подтвердить безопасность end-to-end шифрования с вашим контактом сравните (или сканируйте) код на ваших устройствах.</string>
<string name="is_verified">%s подтверждён</string>
<string name="is_not_verified">%s не подтверждён</string>
<string name="security_code">Код безопасности</string>
<string name="mark_code_verified">Подтвердить</string>
<string name="clear_verification">Сбросить подтверждение</string>
<string name="allow_disappearing_messages_only_if">Разрешить исчезающие сообщения, только если ваш контакт разрешает их вам.</string>
<string name="prohibit_sending_disappearing">Запретить посылать исчезающие сообщения.</string>
<string name="group_members_can_send_disappearing">Члены группы могут посылать исчезающие сообщения.</string>
</resources>

View File

@@ -21,11 +21,13 @@
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">deleted</string>
<string name="marked_deleted_description">marked deleted</string>
<string name="sending_files_not_yet_supported">sending files is not supported yet</string>
<string name="receiving_files_not_yet_supported">receiving files is not supported yet</string>
<string name="sender_you_pronoun">you</string>
<string name="unknown_message_format">unknown message format</string>
<string name="invalid_message_format">invalid message format</string>
<string name="live">LIVE</string>
<!-- PendingContactConnection - ChatModel.kt -->
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
@@ -41,15 +43,29 @@
<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>
<string name="error_setting_network_config">Error updating network configuration</string>
<string name="failed_to_parse_chat_title">Failed to load chat</string>
<string name="failed_to_parse_chats_title">Failed to load chats</string>
<string name="contact_developers">Please update the app and contact developers.</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Connection timeout</string>
<string name="connection_error">Connection error</string>
<string name="network_error_desc">Please check your network connection and try again.</string>
<string name="network_error_desc">Please check your network connection with <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> and try again.</string>
<string name="error_sending_message">Error sending message</string>
<string name="error_adding_members">Error adding member(s)</string>
<string name="error_joining_group">Error joining group</string>
@@ -70,6 +86,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>
@@ -95,7 +119,6 @@
<!-- Notification channels -->
<string name="ntf_channel_messages">SimpleX Chat messages</string>
<string name="ntf_channel_calls">SimpleX Chat calls</string>
<string name="ntf_channel_calls_lockscreen">SimpleX Chat calls (lock screen)</string>
<!-- Notifications -->
<string name="settings_notifications_mode_title">Notification service</string>
@@ -148,9 +171,13 @@
<string name="save_verb">Save</string>
<string name="edit_verb">Edit</string>
<string name="delete_verb">Delete</string>
<string name="reveal_verb">Reveal</string>
<string name="hide_verb">Hide</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>
<string name="delete_message_mark_deleted_warning">Message will be marked for deletion. The recipient(s) will be able to reveal this message.</string>
<string name="for_me_only">Delete for me</string>
<string name="for_everybody">For everyone</string>
<!-- CIMetaView.kt -->
@@ -208,6 +235,11 @@
<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_with_duration">Voice message (<xliff:g id="duration">%1$s</xliff:g>)</string>
<string name="voice_message_send_text">Voice message…</string>
<!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Notifications</string>
@@ -222,14 +254,27 @@
<string name="icon_descr_server_status_pending">Pending</string>
<string name="switch_receiving_address_question">Switch receiving address?</string>
<string name="switch_receiving_address_desc">This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed please check that you can still receive messages from this contact (or group member).</string>
<string name="view_security_code">View security code</string>
<string name="verify_security_code">Verify security code</string>
<!-- 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>
<string name="send_live_message">Send live message</string>
<string name="live_message">Live message!</string>
<string name="send_live_message_desc">Send a live message - it will update for the recipient(s) as you type it</string>
<string name="send_verb">Send</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>
@@ -350,6 +395,19 @@
<string name="one_time_link">One-time invitation link</string>
<string name="your_contact_address">Your contact address</string>
<!-- ScanCodeView.kt -->
<string name="scan_code">Scan code</string>
<string name="incorrect_code">Incorrect security code!</string>
<string name="scan_code_from_contacts_app">Scan security code from your contact\'s app.</string>
<!-- VerifyCodeView.kt -->
<string name="security_code">Security code</string>
<string name="mark_code_verified">Mark verified</string>
<string name="clear_verification">Clear verification</string>
<string name="to_verify_compare">To verify end-to-end encryption with your contact compare (or scan) the code on your devices.</string>
<string name="is_verified">%s is verified</string>
<string name="is_not_verified">%s is not verified</string>
<!-- settings - SettingsView.kt -->
<string name="your_settings">Your settings</string>
<string name="your_simplex_contact_address">Your <xliff:g id="appName">SimpleX</xliff:g> contact address</string>
@@ -363,17 +421,34 @@
<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>
@@ -423,7 +498,11 @@
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts.\n\n<xliff:g id="appName">SimpleX</xliff:g> servers cannot see your profile.</string>
<string name="edit_image">Edit image</string>
<string name="delete_image">Delete image</string>
<string name="save_and_notify_contacts">Save (and notify contacts)</string>
<string name="save_preferences_question">Save preferences?</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>
<string name="exit_without_saving">Exit without saving</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">You control your chat!</string>
@@ -489,6 +568,17 @@
<string name="read_more_in_github">Read more in our GitHub repository.</string>
<string name="read_more_in_github_with_link">Read more in our <font color="#0088ff">GitHub repository</font>.</string>
<!-- SetNotificationsMode.kt -->
<string name="use_chat">Use chat</string>
<string name="onboarding_notifications_mode_title">Private notifications</string>
<string name="onboarding_notifications_mode_subtitle">It can be changed later via settings.</string>
<string name="onboarding_notifications_mode_off">When app is running</string>
<string name="onboarding_notifications_mode_periodic">Periodic</string>
<string name="onboarding_notifications_mode_service">Instant</string>
<string name="onboarding_notifications_mode_off_desc"><b>Best for battery</b>. You will receive notifications only when the app is running, background service will NOT be used.</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Good for battery</b>. Background service checks for new messages every 10 minutes. You may miss calls and urgent messages.</string>
<string name="onboarding_notifications_mode_service_desc"><b>Uses more battery</b>! Background service is always running notifications will be shown as soon as the messages are available.</string>
<!-- MakeConnection -->
<string name="paste_the_link_you_received">Paste received link</string>
@@ -559,9 +649,11 @@
<!-- 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>
<string name="full_backup">App data backup</string>
<!-- Settings sections -->
<string name="settings_section_title_you">YOU</string>
@@ -682,6 +774,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>
@@ -774,6 +867,8 @@
<string name="new_member_role">New member role</string>
<string name="icon_descr_expand_role">Expand role selection</string>
<string name="invite_to_group_button">Invite to group</string>
<string name="skip_inviting_button">Skip inviting members</string>
<string name="select_contacts">Select contacts</string>
<string name="icon_descr_contact_checked">Contact checked</string>
<string name="clear_contacts_selection_button">Clear</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(s) selected</string>
@@ -792,6 +887,7 @@
<string name="button_leave_group">Leave group</string>
<string name="button_edit_group_profile">Edit group profile</string>
<string name="group_link">Group link</string>
<string name="create_group_link">Create group link</string>
<string name="button_create_group_link">Create link</string>
<string name="delete_link_question">Delete link?</string>
<string name="delete_link">Delete link</string>
@@ -799,6 +895,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>
@@ -830,7 +927,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>
@@ -880,4 +977,108 @@
<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="set_group_preferences">Set group preferences</string>
<string name="your_preferences">Your preferences</string>
<string name="timed_messages">Disappearing messages</string>
<string name="direct_messages">Direct messages</string>
<string name="full_deletion">Delete for everyone</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="accept_feature">Accept</string>
<string name="accept_feature_set_1_day">Set 1 day</string>
<string name="allow_your_contacts_to_send_disappearing_messages">Allow your contacts to send disappearing messages.</string>
<string name="allow_disappearing_messages_only_if">Allow disappearing messages only if your contact allows them.</string>
<string name="prohibit_sending_disappearing_messages">Prohibit sending disappearing messages.</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_contact_can_send_disappearing">Both you and your contact can send disappearing messages.</string>
<string name="only_you_can_send_disappearing">Only you can send disappearing messages.</string>
<string name="only_your_contact_can_send_disappearing">Only your contact can send disappearing messages.</string>
<string name="disappearing_prohibited_in_this_chat">Disappearing messages are prohibited in this chat.</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_send_disappearing">Allow to send disappearing messages.</string>
<string name="prohibit_sending_disappearing">Prohibit sending disappearing messages.</string>
<string name="allow_direct_messages">Allow sending direct messages to members.</string>
<string name="prohibit_direct_messages">Prohibit sending direct messages to members.</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_send_disappearing">Group members can send disappearing messages.</string>
<string name="disappearing_messages_are_prohibited">Disappearing messages are prohibited in this group.</string>
<string name="group_members_can_send_dms">Group members can send direct messages.</string>
<string name="direct_messages_are_prohibited_in_chat">Direct messages between members are prohibited in this group.</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 group.</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 group.</string>
<string name="delete_after">Delete after</string>
<string name="ttl_sec">%d sec</string>
<string name="ttl_s">%ds</string>
<string name="ttl_min">%d min</string>
<string name="ttl_month">%d month</string>
<string name="ttl_months">%d months</string>
<string name="ttl_m">%dm</string>
<string name="ttl_mth">%dmth</string>
<string name="ttl_hour">%d hour</string>
<string name="ttl_hours">%d hours</string>
<string name="ttl_h">%dh</string>
<string name="ttl_day">%d day</string>
<string name="ttl_days">%d days</string>
<string name="ttl_d">%dd</string>
<string name="ttl_week">%d week</string>
<string name="ttl_weeks">%d weeks</string>
<string name="ttl_w">%dw</string>
<!-- WhatsNewView.kt -->
<string name="whats_new">What\'s new</string>
<string name="new_in_version">New in %s</string>
<string name="v4_2_security_assessment">Security assessment</string>
<string name="v4_2_security_assessment_desc">SimpleX Chat security was audited by Trail of Bits.</string>
<string name="v4_2_group_links">Group links</string>
<string name="v4_2_group_links_desc">Admins can create the links to join groups.</string>
<string name="v4_2_auto_accept_contact_requests">Auto-accept contact requests</string>
<string name="v4_2_auto_accept_contact_requests_desc">With optional welcome message.</string>
<string name="v4_3_voice_messages">Voice messages</string>
<string name="v4_3_voice_messages_desc">Max 40 seconds, received instantly.</string>
<string name="v4_3_irreversible_message_deletion">Irreversible message deletion</string>
<string name="v4_3_irreversible_message_deletion_desc">Your contacts can allow full message deletion.</string>
<string name="v4_3_improved_server_configuration">Improved server configuration</string>
<string name="v4_3_improved_server_configuration_desc">Add servers by scanning QR codes.</string>
<string name="v4_3_improved_privacy_and_security">Improved privacy and security</string>
<string name="v4_3_improved_privacy_and_security_desc">Hide app screen in the recent apps.</string>
<string name="v4_4_disappearing_messages">Disappearing messages</string>
<string name="v4_4_disappearing_messages_desc">Sent messages will be deleted after set time.</string>
<string name="v4_4_live_messages">Live messages</string>
<string name="v4_4_live_messages_desc">Recipients see updates as you type them.</string>
<string name="v4_4_verify_connection_security">Verify connection security</string>
<string name="v4_4_verify_connection_security_desc">Compare security codes with your contacts.</string>
</resources>

View File

@@ -17,6 +17,9 @@ 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
@State private var showWhatsNew = false
var body: some View {
ZStack {
@@ -29,7 +32,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,16 +49,29 @@ 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
alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
showWhatsNew = shouldShowWhatsNew()
}
}
prefShowLANotice = true
}
.sheet(isPresented: $showWhatsNew) {
WhatsNewView()
}
if chatModel.showCallView, let call = chatModel.activeCall {
ActiveCallView(call: call)
}
@@ -68,9 +84,9 @@ struct ContentView: View {
userAuthorized = true
} else {
dismissAllSheets(animated: false) {
chatModel.chatId = nil
justAuthenticate()
}
chatModel.chatId = nil
}
}
@@ -116,14 +132,14 @@ struct ContentView: View {
func notificationAlert() -> Alert {
Alert(
title: Text("Notifications are disabled!"),
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
)
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
)
}
}

View File

@@ -1,4 +1,4 @@
import UIKit
//import UIKit
let s = """
{
@@ -15,6 +15,6 @@ let s = """
}
"""
//let s = "\"2022-04-24T11:59:23.703162Z\""
let json = getJSONDecoder()
let d = s.data(using: .utf8)!
print (try! json.decode(ChatInfo.self, from: d))
//let json = getJSONDecoder()
//let d = s.data(using: .utf8)!
//print (try! json.decode(ChatInfo.self, from: d))

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> = [:]
@@ -98,7 +101,7 @@ final class ChatModel: ObservableObject {
}
func updateContact(_ contact: Contact) {
updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact && !contact.viaGroupLink)
updateChat(.direct(contact: contact), addMissing: contact.directOrUsed)
}
func updateGroup(_ groupInfo: GroupInfo) {
@@ -219,8 +222,10 @@ final class ChatModel: ObservableObject {
withAnimation(.default) {
self.reversedChatItems[i] = cItem
self.reversedChatItems[i].viewTimestamp = .now
// on some occasions the confirmation of message being accepted by the server (tick)
// arrives earlier than the response from API, and item remains without tick
if case .sndNew = cItem.meta.itemStatus {
self.reversedChatItems[i].meta = ci.meta
self.reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
}
}
return false
@@ -231,16 +236,19 @@ final class ChatModel: ObservableObject {
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew {
decreaseUnreadCounter(cInfo)
}
// update previews
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
chat.chatItems = [cItem]
chat.chatItems = [ChatItem.deletedItemDummy()]
}
}
// remove from current chat
if chatId == cInfo.id {
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
if reversedChatItems[i].isRcvNew() == true {
if reversedChatItems[i].isRcvNew {
NtfManager.shared.decNtfBadgeCount()
}
_ = withAnimation {
@@ -281,10 +289,7 @@ final class ChatModel: ObservableObject {
private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i
while j < reversedChatItems.count {
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
reversedChatItems[j].meta.itemStatus = .rcvRead
reversedChatItems[j].viewTimestamp = .now
}
markChatItemRead_(j)
j += 1
}
}
@@ -337,14 +342,28 @@ final class ChatModel: ObservableObject {
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update preview
decreaseUnreadCounter(cInfo)
// update current chat
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
markChatItemRead_(i)
}
}
private func markChatItemRead_(_ i: Int) {
let meta = reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
reversedChatItems[i].meta.itemStatus = .rcvRead
reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
}
}
}
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
}
// update current chat
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
reversedChatItems[j].meta.itemStatus = .rcvRead
reversedChatItems[j].viewTimestamp = .now
}
}
func totalUnreadCount() -> Int {
@@ -413,7 +432,7 @@ final class ChatModel: ObservableObject {
var unreadBelow = 0
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
totalBelow += 1
if reversedChatItems[i].isRcvNew() {
if reversedChatItems[i].isRcvNew {
unreadBelow += 1
}
i += 1
@@ -501,4 +520,6 @@ final class Chat: ObservableObject, Identifiable {
var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}

View File

@@ -0,0 +1,189 @@
//
// ImageUtils.swift
// SimpleX (iOS)
//
// Created by Evgeny on 24/12/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import SimpleXChat
import SwiftUI
func getLoadedFilePath(_ file: CIFile?) -> String? {
if let fileName = getLoadedFileName(file) {
return getAppFilePath(fileName).path
}
return nil
}
func getLoadedFileName(_ file: CIFile?) -> String? {
if let file = file,
file.loaded,
let fileName = file.filePath {
return fileName
}
return nil
}
func getLoadedImage(_ file: CIFile?) -> UIImage? {
let loadedFilePath = getLoadedFilePath(file)
if let loadedFilePath = loadedFilePath, let fileName = file?.filePath {
let filePath = getAppFilePath(fileName)
do {
let data = try Data(contentsOf: filePath)
let img = UIImage(data: data)
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
return img
} catch {
return UIImage(contentsOfFile: loadedFilePath)
}
}
return nil
}
func saveAnimImage(_ image: UIImage) -> String? {
let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil }
return saveFile(imageData, fileName)
}
func saveImage(_ uiImage: UIImage) -> String? {
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE) {
let ext = imageHasAlpha(uiImage) ? "png" : "jpg"
let fileName = generateNewFileName("IMG", ext)
return saveFile(imageDataResized, fileName)
}
return nil
}
func cropToSquare(_ image: UIImage) -> UIImage {
let size = image.size
let side = min(size.width, size.height)
let newSize = CGSize(width: side, height: side)
var origin = CGPoint.zero
if size.width > side {
origin.x -= (size.width - side) / 2
} else if size.height > side {
origin.y -= (size.height - side) / 2
}
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
}
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? {
var img = image
let usePng = imageHasAlpha(image)
var data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0
while dataSize != 0 && dataSize > maxDataSize {
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
let clippedRatio = min(ratio, 2.0)
img = reduceSize(img, ratio: clippedRatio)
data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85)
dataSize = data?.count ?? 0
}
logger.debug("resizeImageToDataSize final \(dataSize)")
return data
}
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
var img = image
var str = compressImageStr(img)
var dataSize = str?.count ?? 0
while dataSize != 0 && dataSize > maxDataSize {
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
let clippedRatio = min(ratio, 2.0)
img = reduceSize(img, ratio: clippedRatio)
str = compressImageStr(img)
dataSize = str?.count ?? 0
}
logger.debug("resizeImageToStrSize final \(dataSize)")
return str
}
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
let ext = imageHasAlpha(image) ? "png" : "jpg"
if let data = imageHasAlpha(image) ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
return "data:image/\(ext);base64,\(data.base64EncodedString())"
}
return nil
}
private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
let bounds = CGRect(origin: .zero, size: newSize)
return resizeImage(image, newBounds: bounds, drawIn: bounds)
}
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1.0
format.opaque = !imageHasAlpha(image)
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
image.draw(in: drawIn)
}
}
func imageHasAlpha(_ img: UIImage) -> Bool {
let alpha = img.cgImage?.alphaInfo
return alpha == .first || alpha == .last || alpha == .premultipliedFirst || alpha == .premultipliedLast || alpha == .alphaOnly
}
func saveFileFromURL(_ url: URL) -> String? {
let savedFile: String?
if url.startAccessingSecurityScopedResource() {
do {
let fileData = try Data(contentsOf: url)
let fileName = uniqueCombine(url.lastPathComponent)
savedFile = saveFile(fileData, fileName)
} catch {
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
savedFile = nil
}
} else {
logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false")
savedFile = nil
}
url.stopAccessingSecurityScopedResource()
return savedFile
}
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
let fileName = uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
return fileName
}
private func uniqueCombine(_ fileName: String) -> String {
func tryCombine(_ fileName: String, _ n: Int) -> String {
let ns = fileName as NSString
let name = ns.deletingPathExtension
let ext = ns.pathExtension
let suffix = (n == 0) ? "" : "_\(n)"
let f = "\(name)\(suffix).\(ext)"
return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
}
return tryCombine(fileName, 0)
}
private var tsFormatter: DateFormatter?
private func getTimestamp() -> String {
var df: DateFormatter
if let tsFormatter = tsFormatter {
df = tsFormatter
} else {
df = DateFormatter()
df.dateFormat = "yyyyMMdd_HHmmss"
df.locale = Locale(identifier: "US")
tsFormatter = df
}
return df.string(from: Date())
}
func dropImagePrefix(_ s: String) -> String {
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
}
private func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
}

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

@@ -219,9 +219,9 @@ func loadChat(chat: Chat, search: String = "") {
}
}
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async -> ChatItem? {
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? {
let chatModel = ChatModel.shared
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg)
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live)
let r: ChatResponse
if type == .direct {
var cItem: ChatItem!
@@ -255,15 +255,15 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
)
}
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem {
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay)
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem {
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay)
if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem }
throw r
}
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> ChatItem {
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
if case let .chatItemDeleted(_, toChatItem) = r { return toChatItem.chatItem }
if case let .chatItemDeleted(deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
throw r
}
@@ -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) }
@@ -345,14 +356,14 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
}
func apiContactInfo(contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
throw r
}
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (ConnectionStats?) {
let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) {
let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) }
throw r
}
@@ -365,6 +376,32 @@ func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws
try await sendCommandOkResp(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
}
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
let r = await chatSendCmd(.apiGetContactCode(contactId: contactId))
if case let .contactCode(contact, connectionCode) = r { return (contact, connectionCode) }
throw r
}
func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) {
let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberCode(_, member, connectionCode) = r { return (member, connectionCode) }
throw r
}
func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? {
let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode))
if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) }
logger.error("apiVerifyContact error: \(String(describing: r))")
return nil
}
func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? {
let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode))
if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) }
logger.error("apiVerifyGroupMember error: \(String(describing: r))")
return nil
}
func apiAddContact() async -> String? {
let r = await chatSendCmd(.addContact, bgTask: false)
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
@@ -476,6 +513,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 }
@@ -570,10 +613,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
}
@@ -581,16 +629,16 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? {
func networkErrorAlert(_ r: ChatResponse) -> Bool {
let am = AlertManager.shared
switch r {
case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))):
case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))):
am.showAlertMsg(
title: "Connection timeout",
message: "Please check your network connection and try again."
message: "Please check your network connection with \(serverHostname(addr)) and try again."
)
return true
case .chatCmdError(.errorAgent(.BROKER(.NETWORK))):
case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))):
am.showAlertMsg(
title: "Connection error",
message: "Please check your network connection and try again."
message: "Please check your network connection with \(serverHostname(addr)) and try again."
)
return true
default:
@@ -760,6 +808,12 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
return []
}
func apiListMembersSync(_ groupId: Int64) -> [GroupMember] {
let r = chatSendCmdSync(.apiListMembers(groupId: groupId))
if case let .groupMembers(group) = r { return group.members }
return []
}
func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil }
return ChatModel.shared.chats
@@ -826,7 +880,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) }
@@ -895,7 +949,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .contactConnectionDeleted(connection):
m.removeChat(connection.id)
case let .contactConnected(contact, _):
if !contact.viaGroupLink {
if contact.directOrUsed {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
@@ -903,7 +957,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
NtfManager.shared.notifyContactConnected(contact)
}
case let .contactConnecting(contact):
if !contact.viaGroupLink {
if contact.directOrUsed {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
@@ -950,22 +1004,25 @@ func processReceivedMsg(_ res: ChatResponse) async {
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
m.addChatItem(cInfo, cItem)
if case .image = cItem.content.msgContent,
let file = cItem.file,
file.fileSize <= maxImageSize,
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
Task {
await receiveFile(fileId: file.fileId)
if let file = cItem.file,
let mc = cItem.content.msgContent,
file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV {
let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
if (mc.isImage && acceptImages)
|| (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) {
Task {
await receiveFile(fileId: file.fileId) // TODO check inlineFileMode != IFMSent
}
}
}
if !cItem.chatDir.sent && !cItem.isCall() && !cItem.isMutedMemberEvent {
if cItem.showNotification {
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 {
@@ -980,14 +1037,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
case let .chatItemUpdated(aChatItem):
chatItemSimpleUpdate(aChatItem)
case let .chatItemDeleted(_, toChatItem):
let cInfo = toChatItem.chatInfo
let cItem = toChatItem.chatItem
if cItem.meta.itemDeleted {
m.removeChatItem(cInfo, cItem)
case let .chatItemDeleted(deletedChatItem, toChatItem, _):
if let toChatItem = toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
// currently only broadcast deletion of rcv message can be received, and only this case should happen
_ = m.upsertChatItem(cInfo, cItem)
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
}
case let .receivedGroupInvitation(groupInfo, _, _):
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
@@ -1025,9 +1079,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)
}
@@ -1119,7 +1173,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
m.updateContact(contact)
var err: String
switch chatError {
case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
case .errorAgent(agentError: .BROKER(_, .NETWORK)): err = "network"
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
default: err = String(describing: chatError)
}

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

@@ -32,15 +32,26 @@ struct ChatInfoToolbar: View {
.frame(width: imageSize, height: imageSize)
.padding(.trailing, 4)
VStack {
Text(cInfo.displayName).font(.headline)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.lineLimit(1)
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
Text(cInfo.fullName).font(.subheadline)
.lineLimit(1)
}
}
}
.foregroundColor(.primary)
.frame(width: 220)
}
private var contactVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
}
struct ChatInfoToolbar_Previews: PreviewProvider {

View File

@@ -18,6 +18,15 @@ func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
}
}
func infoRow(_ title: Text, _ value: String) -> some View {
HStack {
title
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey) -> some View {
HStack {
Text(title)
@@ -53,10 +62,11 @@ 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?
@Binding var customUserProfile: Profile?
@State var localAlias: String
@Binding var connectionCode: String?
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -89,7 +99,9 @@ struct ChatInfoView: View {
aliasTextFieldFocused = false
}
localAliasTextEdit()
Group {
localAliasTextEdit()
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
@@ -99,15 +111,18 @@ struct ChatInfoView: View {
}
}
Section {
if let code = connectionCode { verifyCodeButton(code) }
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)
@@ -141,17 +156,23 @@ struct ChatInfoView: View {
}
}
func contactInfoHeader() -> some View {
private func contactInfoHeader() -> some View {
VStack {
let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
.frame(width: 192, height: 192)
.padding(.top, 12)
.padding()
Text(contact.profile.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
HStack {
if contact.verified {
Image(systemName: "checkmark.shield")
.foregroundColor(.secondary)
}
Text(contact.profile.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
}
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
Text(cInfo.fullName)
.font(.title2)
@@ -161,7 +182,7 @@ struct ChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
func localAliasTextEdit() -> some View {
private func localAliasTextEdit() -> some View {
TextField("Set contact name…", text: $localAlias)
.disableAutocorrection(true)
.focused($aliasTextFieldFocused)
@@ -192,7 +213,50 @@ struct ChatInfoView: View {
}
}
func networkStatusRow() -> some View {
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
VerifyCodeView(
displayName: contact.displayName,
connectionCode: code,
connectionVerified: contact.verified,
verify: { code in
if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) {
let (verified, existingCode) = r
contact.activeConn.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
connectionCode = existingCode
DispatchQueue.main.async {
chat.chatInfo = .direct(contact: contact)
}
return r
}
return nil
}
)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code")
} label: {
Label(
contact.verified ? "View security code" : "Verify security code",
systemImage: contact.verified ? "checkmark.shield" : "shield"
)
}
}
private 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")
}
}
private func networkStatusRow() -> some View {
HStack {
Text("Network status")
Image(systemName: "info.circle")
@@ -205,14 +269,14 @@ struct ChatInfoView: View {
}
}
func serverImage() -> some View {
private func serverImage() -> some View {
let status = chat.serverInfo.networkStatus
return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : .secondary)
.font(.system(size: 12))
}
func deleteContactButton() -> some View {
private func deleteContactButton() -> some View {
Button(role: .destructive) {
alert = .deleteContactAlert
} label: {
@@ -221,7 +285,7 @@ struct ChatInfoView: View {
}
}
func clearChatButton() -> some View {
private func clearChatButton() -> some View {
Button() {
alert = .clearChatAlert
} label: {
@@ -307,7 +371,9 @@ struct ChatInfoView_Previews: PreviewProvider {
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData,
connectionStats: Binding.constant(nil),
localAlias: ""
customUserProfile: Binding.constant(nil),
localAlias: "",
connectionCode: Binding.constant(nil)
)
}
}

View File

@@ -0,0 +1,63 @@
//
// Created by Avently on 19.12.2022.
// Copyright (c) 2022 SimpleX Chat. All rights reserved.
//
import UIKit
import SwiftUI
class AnimatedImageView: UIView {
var image: UIImage? = nil
var imageView: UIImageView? = nil
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("Not implemented")
}
convenience init(image: UIImage) {
self.init()
self.image = image
imageView = UIImageView(gifImage: image)
imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!)
}
override func layoutSubviews() {
super.layoutSubviews()
imageView!.frame = bounds
}
func updateImage(_ image: UIImage) {
if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image)
imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!)
subview.removeFromSuperview()
}
}
imageView!.frame = bounds
self.layoutSubviews()
}
}
struct SwiftyGif: UIViewRepresentable {
private let image: UIImage
init(image: UIImage) {
self.image = image
}
func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image)
}
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
imageView.updateImage(image)
imageView.imageView!.startAnimatingGif()
}
}

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,36 @@
//
// 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 icon: String? = nil
var iconColor: Color
var body: some View {
HStack(alignment: .bottom, spacing: 4) {
Image(systemName: icon ?? feature.iconFilled)
.foregroundColor(iconColor)
.scaleEffect(feature.iconScale)
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: ChatFeature.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

@@ -0,0 +1,86 @@
//
// CIFeaturePreferenceView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 21/12/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct CIFeaturePreferenceView: View {
@EnvironmentObject var chat: Chat
var chatItem: ChatItem
var feature: ChatFeature
var allowed: FeatureAllowed
var param: Int?
var body: some View {
HStack(alignment: .center, spacing: 4) {
Image(systemName: feature.icon)
.foregroundColor(.secondary)
.scaleEffect(feature.iconScale)
if let ct = chat.chatInfo.contact,
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil
featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept")
.onTapGesture {
allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil)
}
} else {
featurePreferenceView()
}
}
.padding(.leading, 6)
.padding(.bottom, 6)
.textSelection(.disabled)
}
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
.fontWeight(.light)
.foregroundColor(.secondary)
if let acceptText {
r = r
+ Text(acceptText)
.fontWeight(.medium)
.foregroundColor(.accentColor)
+ Text(" ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
.foregroundColor(.secondary)
return r.font(.caption)
}
}
func allowFeatureToContact(_ contact: Contact, _ feature: ChatFeature, param: Int? = nil) {
Task {
do {
let prefs = contactUserPreferencesToPreferences(contact.mergedPreferences).setAllowed(feature, param: param)
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
await MainActor.run {
ChatModel.shared.updateContact(toContact)
}
}
} catch {
logger.error("allowFeatureToContact apiSetContactPrefs error: \(responseError(error))")
}
}
}
struct CIFeaturePreferenceView_Previews: PreviewProvider {
static var previews: some View {
let content = CIContent.rcvChatPreference(feature: .timedMessages, allowed: .yes, param: 30)
let chatItem = ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, content.text, .rcvRead, false, false, false),
content: content,
quotedItem: nil,
file: nil
)
CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
.environmentObject(Chat.sampleData)
}
}

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,18 +148,19 @@ struct CIFileView_Previews: PreviewProvider {
quotedItem: nil,
file: nil
)
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))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile)
Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
}
}

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