Compare commits

...

429 Commits

Author SHA1 Message Date
Evgeny Poberezkin bf25b116d7 Merge branch 'stable' into stable-ios 2024-01-18 20:59:49 +00:00
Evgeny Poberezkin 5236e0f201 core: 5.4.4.0 2024-01-18 20:56:45 +00:00
Evgeny Poberezkin 1eb1ed92f7 ci: disable *-armv7a tags 2024-01-17 19:27:07 +00:00
Evgeny Poberezkin c25e9e3b72 scripts: curl/wget security (#3693) 2024-01-17 15:23:43 +00:00
Evgeny Poberezkin 300223b32e core: update simplexmq 5.5.0.6 (fix race conditions) (#3691)
* core: update simplexmq (fix race conditions)

* simplexmq 5.5.0.6
2024-01-16 23:42:29 +00:00
Evgeny Poberezkin 8d7dcb550a core: update simplexmq, optimize batching, remove builder (#3685)
* core: update simplexmq (optimize batching, remove builder)

* do not use builder to batch

* refactor
2024-01-15 10:46:13 +00:00
Evgeny Poberezkin c070d69114 Merge branch 'stable' into stable-ios 2024-01-10 12:17:26 +00:00
Evgeny Poberezkin 045b195483 5.4.3: ios 188, android 169, desktop 22 2024-01-10 11:56:57 +00:00
Evgeny Poberezkin a3b91ad182 Merge branch 'stable' into stable-ios 2024-01-09 20:20:52 +00:00
Evgeny Poberezkin 53414608db core: 5.4.3.0 (simplexmq 5.5.0.5) 2024-01-09 20:20:14 +00:00
Stanislav Dmitrenko c7cf206585 android: fix call sound when the app in the background (#3660)
* android: fix call sound when the app in the background

* using previous notification channel

* Revert "using previous notification channel"

This reverts commit 19da9a9ce193c39b353f478e884a97bbbf002e77.

* prevent playing sound on call twice
2024-01-09 19:45:46 +00:00
Evgeny Poberezkin 6067ac3c93 ios: more aggressive GC in NSE 2024-01-09 19:34:54 +00:00
Evgeny Poberezkin 591df1e0f4 Merge branch 'stable' into stable-ios 2024-01-09 10:40:52 +00:00
Evgeny Poberezkin a2f190a6c6 core: update simplexmq (better batching) 2024-01-09 09:15:35 +00:00
Stanislav Dmitrenko 267178dddb android, desktop: show alerts on critical and internal errors (#3653)
* android, desktop: show alerts on critical and internal errors

* test

* don't stop chat if it's stopped already

* show notification

* restart chat or app

* Revert "test"

This reverts commit 5b78bbae5b.

* update strings

* strings2

* refactoring

* refactoring2

* refactoring3

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2024-01-08 18:20:52 +00:00
spaced4ndy fadce0c140 core: create new chat controller with chatActivated set to true 2024-01-08 17:34:10 +04:00
spaced4ndy 58ad97fe6d core: pause cleanup when chat is suspended (#3658) 2024-01-08 17:28:01 +04:00
Evgeny Poberezkin 3ccd9903a7 core: do not start clean up manager in background NSE (#3657)
* core: do not start clean up manager in background NSE

* update UIs

* fix test
2024-01-08 12:53:16 +00:00
Evgeny Poberezkin e294999044 ios: fix callkit calls via NSE (#3655)
* ios: fix callkit calls via NSE

* comments

* more reliable NSE start

* remove public logs, different RTS parameters for NSE

* only suspend NSE if we have chat controller, to avoid crashes if suspension attempted without controller created

* comments

* fix

* simplify
2024-01-08 10:56:01 +00:00
Evgeny Poberezkin b1156d401c Merge branch 'stable' into stable-ios 2024-01-06 11:49:37 +00:00
Evgeny Poberezkin 2bbc687f4a core: simplexmq 5.5.0.4 2024-01-06 11:48:28 +00:00
Evgeny Poberezkin bb61b9c658 core: update simplexmq (critical errors, worker restarts, subscription timeouts) 2024-01-05 20:07:19 +00:00
sh 575d899f5a build-android: fix new arrangement of nix command (#3634) 2024-01-02 14:39:23 +00:00
Evgeny Poberezkin 3411658a8d Merge branch 'stable' into stable-ios 2023-12-30 10:21:46 +00:00
sh 825257e898 build-android: update nix and add armv7a branch switch (#3612)
* build-android: build armv7a in seperate tag

And update nix install.

* build-android: change tag detection logic

* build-android: minor change of logic
2023-12-30 10:19:56 +00:00
Evgeny Poberezkin fdbfeb6652 Merge branch 'master-ios' into stable-ios 2023-12-28 09:18:42 +00:00
Evgeny Poberezkin 51bf2f413c 5.4.2: ios 186, android 166, desktop 20 2023-12-27 22:24:21 +00:00
Evgeny Poberezkin 2c6b0ef917 Merge branch 'master-ghc8107' into master-ios 2023-12-27 21:03:06 +00:00
Evgeny Poberezkin ff239d81e5 Merge branch 'master' into master-ghc8107 2023-12-27 21:02:11 +00:00
Evgeny Poberezkin e3a69b12ba core: 5.4.2.1 (simplexmq 5.5.0.2) 2023-12-27 21:00:19 +00:00
Evgeny Poberezkin 0a6e47dd61 Merge branch 'master' into master-ghc8107 2023-12-27 20:57:25 +00:00
Evgeny Poberezkin ee75219ed3 core: 5.4.2.1 (simplexmq 5.5.0.2) 2023-12-27 20:57:05 +00:00
Evgeny Poberezkin b220b5f6ec core: fix contact subscriptions (#3611) 2023-12-27 18:55:50 +00:00
Stanislav Dmitrenko 0b8346701c android, desktop: fix terminal items crash (#3607)
* android, desktop: fix terminal items crash

* test

* Revert "test"

This reverts commit 3f1e5c93ca.

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-12-27 14:59:38 +00:00
Evgeny Poberezkin efc873b09b core: min version for remote connection 5.4.2.0 (#3594) 2023-12-27 14:48:28 +00:00
Stanislav Dmitrenko 9f71502b51 desktop (windows): fix script that generates localization (#3606)
* desktop (windows): fix script that generates localization

* firstOrNull
2023-12-27 14:47:04 +00:00
Evgeny Poberezkin 5d8dd8ffd3 Merge branch 'master-ghc8107' into master-ios 2023-12-27 14:10:02 +00:00
Evgeny Poberezkin 9918d91def Merge branch 'master' into master-ghc8107 2023-12-27 14:09:23 +00:00
Evgeny Poberezkin bbde6d81ee core: update simplexmq 2023-12-27 13:57:02 +00:00
Stanislav Dmitrenko 58906e1a60 desktop (windows): fixed handling non-utf8 Windows profile names (#3605) 2023-12-27 13:56:48 +00:00
Stanislav Dmitrenko ed3d234826 android, desktop: limit text length in terminal view (#3604) 2023-12-27 11:27:34 +00:00
Stanislav Dmitrenko dded56d8b8 ios: converting video to mp4 and making quality lower (#3597)
* ios: converting video to mp4 and making quality lower

Lower quality allows to play that videos on Android as well

* update export settings

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-12-27 11:23:46 +00:00
spaced4ndy 4d5aefa82c ui: don't share address at onboarding by default (#3603)
* ios: don't share address at onboarding by default

* android
2023-12-27 10:54:44 +00:00
Evgeny Poberezkin 235b220378 Merge branch 'master-ghc8107' into master-ios 2023-12-26 21:27:34 +00:00
Evgeny Poberezkin de637fab50 Merge branch 'master' into master-ghc8107 2023-12-26 21:12:36 +00:00
Evgeny Poberezkin 9ac99ec2d9 core: update simplexmq (mark failed work items to continue processing) (#3600)
* core: update simplexmq (mark failed work items to continue processing) WIP

* simplexmq
2023-12-26 19:53:58 +00:00
spaced4ndy 37d033c7a5 core: test group members connect in group when they were previously connected as contacts (#3595) 2023-12-25 11:03:02 +04:00
spaced4ndy 5798efcf71 code: modify test 2023-12-24 20:55:03 +04:00
spaced4ndy e086719f27 core: add to tests 2023-12-24 20:44:30 +04:00
spaced4ndy bb4293eb5e fix tests 2023-12-24 20:23:09 +04:00
Evgeny Poberezkin c3c66182f2 Merge branch 'stable' 2023-12-24 14:20:58 +00:00
Evgeny Poberezkin 5b90d92ca2 core: add group tests 2023-12-24 14:20:07 +00:00
Evgeny Poberezkin af22348bf8 core: use version from config, add tests (#3588)
* core: use version from config, add tests

* comment

* refactor
2023-12-24 13:27:51 +00:00
Stanislav Dmitrenko 5a6670998c android, desktop: loading prev messages better (#3585)
* android, desktop: loading prev messages better

* better

* better

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-12-24 12:22:00 +00:00
spaced4ndy 700f6fa663 core, docs: drop message views if they exist, remove mentions in docs (#3589)
* core, docs: drop message views if they exist, remove mentions in docs

* fix migration
2023-12-24 11:13:34 +00:00
Stanislav Dmitrenko d474cae705 ios: saving and sharing items menu item (#3581)
* ios: saving and sharing items menu item

* refactor

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-12-23 23:15:31 +00:00
Evgeny Poberezkin 6a8dba25f0 Merge branch 'master-ghc8107' into master-ios 2023-12-23 19:57:49 +00:00
Evgeny Poberezkin 6d0a83aa58 Merge branch 'master' into master-ghc8107 2023-12-23 19:57:08 +00:00
Evgeny Poberezkin 2834b192ce core: do not create group history item (#3586)
* core: do not create group history item

* fix tests
2023-12-23 19:56:01 +00:00
Evgeny Poberezkin decfbdb07b Merge branch 'master-ghc8107' into master-ios 2023-12-23 17:25:40 +00:00
Evgeny Poberezkin 6a66525927 Merge branch 'master' into master-ghc8107 2023-12-23 17:24:47 +00:00
Evgeny Poberezkin b8cb954882 ios: update library 2023-12-23 17:24:30 +00:00
Evgeny Poberezkin 6aeef6f132 5.4.2.0: fix migration in simplexmq 2023-12-23 16:09:08 +00:00
Evgeny Poberezkin 9becf48db6 Merge branch 'master-ghc8107' into master-ios 2023-12-23 15:13:16 +00:00
Evgeny Poberezkin f5ed8debcc Merge branch 'master' into master-ghc8107 2023-12-23 15:11:38 +00:00
Evgeny Poberezkin 355d2449c5 fix for GHC 8.10.7 2023-12-23 15:05:36 +00:00
Evgeny Poberezkin fa1702a566 5.4.2.0: update .cabal 2023-12-23 14:13:38 +00:00
Evgeny Poberezkin f7382cdd6f Merge branch 'master' into master-ghc8107 2023-12-23 13:52:44 +00:00
Evgeny Poberezkin 95d6df926c 5.4.2.0 2023-12-23 13:46:11 +00:00
Evgeny Poberezkin cccd517277 core: fix simplexmq commit 2023-12-23 13:08:40 +00:00
spaced4ndy 12d1ada25e core: support batch sending in groups, batch introductions; send recent message history to new members (#3519)
* core: batch send stubs, comments

* multiple events in ChatMessage and supporting types

* Revert "multiple events in ChatMessage and supporting types"

This reverts commit 9b239b26ba.

* schema, refactor group processing for batched messages

* encoding, refactor processing

* refactor code to work with updated schema

* encoding, remove instances

* wip

* implement batching

* batch introductions

* wip

* collect and send message history

* missing new line

* rename

* test

* rework to build history via chat items

* refactor, tests

* correctly set member version range, dont include deleted items

* tests

* fix disappearing messages

* check number of errors

* comment

* check size in encodeChatMessage

* fix - don't check msg size for binary

* use builder

* rename

* rename

* rework batching

* lazy msg body

* use withStoreBatch

* refactor

* reverse batches

* comment

* possibly fix builder for single msg

* refactor batcher

* refactor

* dont repopulate msg_deliveries on down migration

* EncodedChatMessage type

* remove type

* batcher tests

* add tests

* group history preference

* test group link

* fix tests

* fix for random update

* add test testImageFitsSingleBatch

* refactor

* rename function

* refactor

* mconcat

* rename feature

* catch error on each batch

* refactor file inv retrieval

* refactor gathering item forward events

* refactor message batching

* unite migrations

* move files

* refactor

* Revert "unite migrations"

This reverts commit 0be7a3117a.

* refactor splitFileDescr

* improve tests

* Revert "dont repopulate msg_deliveries on down migration"

This reverts commit 2944c1cc28.

* fix down migration

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-12-23 13:07:23 +00:00
Evgeny Poberezkin f93f68e425 core: agent background mode for iOS NSE (#3574)
* core: agent background mode for iOS NSE

* change parameter for APIActivateChat

* fix

* update lib

* update lib

* simplexmq

* simplify
2023-12-23 13:06:59 +00:00
Andor Kesselman 23989aca57 Update README.md (#3553) 2023-12-22 08:48:26 +00:00
Andor Kesselman 57a6e85668 docs: fix typo (#3552) 2023-12-22 08:47:48 +00:00
Stanislav Dmitrenko 67590f3258 Revert "ios: making thumbnails faster" (#3571)
This reverts commit cd9cb8e064.
2023-12-22 08:46:55 +00:00
Stanislav Dmitrenko c83238c35a desktop: enable sending images and files with enter (#3582) 2023-12-21 22:48:32 +00:00
Stanislav Dmitrenko 8b0d2dede7 android, desktop: saving and sharing files menu item (#3580) 2023-12-21 15:19:36 +00:00
Stanislav Dmitrenko c4855313b6 android: splash screen with background color on Android 12+ (#3579) 2023-12-21 13:49:49 +00:00
Evgeny Poberezkin 2bff3b9c97 desktop, android: update api to pass controller when encrypting files (use ChaChaDRG as source of randomness) (#3578) 2023-12-21 12:49:18 +00:00
Evgeny Poberezkin aa037c0662 ios: update core library (uses GHC 9.6.3) 2023-12-21 10:05:43 +00:00
Evgeny Poberezkin 2590fc0c80 Merge branch 'master-ghc8107' into master-ios 2023-12-21 10:00:44 +00:00
Evgeny Poberezkin 521b901cc9 Merge branch 'master' into master-ghc8107 (without changes for iPhone7) 2023-12-21 09:59:25 +00:00
Evgeny Poberezkin d198d6a8db core: build iOS library with ghc 9.6.3 with iPhone7 etc. support (#3577)
* bump haskell.nix

* bump flake.lock

* Try openssl fix

* CFLAGS. not CCFLAGS

* Fix iOS build issues and improve static library handling

---------

Co-authored-by: Moritz Angermann <moritz.angermann@gmail.com>
2023-12-21 09:57:43 +00:00
Evgeny Poberezkin cdd735930d Merge branch 'master-ghc8107' into master-ios 2023-12-21 00:45:46 +00:00
Evgeny Poberezkin 754c76d6fd Merge branch 'master' into master-ghc8107 2023-12-21 00:45:10 +00:00
Evgeny Poberezkin 7bcda7e54b core: use ChaChaDRG as the source of randomness (#3551)
* core: use ChaChaDRG as the source of randomness

* do not use entropy directly

* dont use RNG from agent

* simplexmq

* update iOS
2023-12-21 00:42:40 +00:00
Stanislav Dmitrenko 4a4d470859 android, desktop: try-catch composables (#3575)
* android, desktop: try-catch composables

* test

* better catching on Android

* more try-catch'es

* Revert "test"

This reverts commit adaf92b116.

* more try-catch'es

* unneeded imports
2023-12-20 18:00:44 +00:00
Evgeny Poberezkin 6ba3100d34 core: batch sending messages (#3566)
* core: batch sending messages

* batch without iorefs (#3573)

* one-pass

* simplexmq

* simplexmq

* simplexmq

* simplexmq

* revert change to ios project file

* refactor

* simplify

---------

Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com>
2023-12-20 10:38:39 +04:00
Evgeny Poberezkin 7b073ba9f8 core: allow deleting last user (#3567)
* core: allow deleting last user (tests fail)

* tests, allow activating the hidden user when there is no active user

* hide logs

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* comment

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* comment

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2023-12-19 10:26:01 +00:00
Stanislav Dmitrenko 5e042d222e desktop: saving qr code as an image (#3572) 2023-12-19 10:24:13 +00:00
Stanislav Dmitrenko 26a189917b sctipt: check string formatting (#3570)
* sctipt: check string formatting

* all
2023-12-18 21:37:10 +00:00
spaced4ndy ce9218b186 ios: rework authentication (#3556) 2023-12-18 22:04:49 +04:00
Evgeny Poberezkin f0338a03d1 directory: better search, allow both simplex:/ and simplex.chat links in description (#3546)
* directory: new commands

* better search

* search test

* return group links in simplex.chat domain, allow both simplex:/ and simplex.chat links in group description
2023-12-18 10:41:08 +00:00
Evgeny Poberezkin 6fa0001ea7 ios: delay suspendChat in NSE, background schedule depends on notifications mode (#3561)
* ios: delay suspendChat in NSE

* different background refresh interval depending on the settings

* simplify

* comment

* reduce NSE suspend interval

* space
2023-12-18 10:36:25 +00:00
Stanislav Dmitrenko 974fa448b4 android, desktop: some alerts became privacy sensitive (#3554)
* android, desktop: some alerts became privacy sensitive

* changes
2023-12-14 13:11:19 +00:00
spaced4ndy 8cec5428ee core: save CIContent tag in chat_items table (#3555) 2023-12-14 17:08:40 +04:00
Evgeny Poberezkin 73130bf321 ios: update core library 2023-12-13 21:48:25 +00:00
Evgeny Poberezkin 9bfc2ed16e nix: fix script 2023-12-13 20:30:25 +00:00
Evgeny Poberezkin aea7ff1c89 nix: fix script 2023-12-13 20:27:58 +00:00
Evgeny Poberezkin 52c22c2f77 nix: fix direct-sqlcipher sha 2023-12-13 20:23:18 +00:00
Evgeny Poberezkin f16da09213 Merge branch 'master-ghc8107' into master-ios 2023-12-13 12:36:22 +00:00
Evgeny Poberezkin 256f85024f Merge branch 'master' into master-ghc8107 2023-12-13 12:35:37 +00:00
spaced4ndy 67241ff65c ios: fix code scanners only attempting to scan once (#3548) 2023-12-13 16:13:05 +04:00
spaced4ndy b6b041490f core: improve chat list pagination performance, simplify logic by always reading chat stats and last item id for previews (#3541)
* core: improve chat list pagination performance

* fix query

* core: improve chat list pagination performance, simplify logic by always reading chat stats (#3543)

* microseconds

* fix

* update simplexmq

* simplify queries

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-12-13 15:32:23 +04:00
Evgeny Poberezkin 7f9f9a674c ios: fix member view freezing on iOS 15, closes #3487 (#3547) 2023-12-13 11:27:28 +00:00
Evgeny Poberezkin ae94bb6f87 core: use crypton instead of cryptonite (#3542)
* update hackage

* use crypton instead of cryptonite

* remove http2 from cabal.project

* simplexmq
2023-12-13 11:20:03 +00:00
Evgeny Poberezkin 870f9e42dd Merge branch 'master' into master-ghc8107 2023-12-12 20:09:39 +00:00
Evgeny Poberezkin 7ec39d1ffa all: increase default TCP timeouts, update simplexmq (#3540) 2023-12-12 13:13:36 +00:00
Evgeny Poberezkin ca6dfb5ea1 docs: update latest version 2023-12-12 10:25:02 +00:00
Evgeny Poberezkin a5048db6fa ios: improve media picker for multiple images/videos (#3538)
* ios: improve media picker to work with multiple images reliably

* MainActor
2023-12-12 09:04:48 +00:00
Evgeny Poberezkin aca3a71b38 ios: update library 2023-12-11 18:57:42 +00:00
Evgeny Poberezkin a993a26920 Merge branch 'master-ghc8107' into master-ios 2023-12-11 17:37:17 +00:00
Evgeny Poberezkin 8a66390a78 fix for GHC 8.10.7 2023-12-11 16:14:56 +00:00
Evgeny Poberezkin aff589becd Merge branch 'master-ghc8107' into master-ios 2023-12-11 14:57:49 +00:00
Evgeny Poberezkin 6546426ec0 Merge branch 'master' into master-ghc8107 2023-12-11 14:56:25 +00:00
Evgeny Poberezkin d358390e3d Merge pull request #3532 from simplex-chat/ios-notifications
improve iOS notifications
2023-12-11 14:55:03 +00:00
Evgeny Poberezkin f9a125bc32 Merge branch 'master' into ios-notifications 2023-12-11 14:11:00 +00:00
spaced4ndy 5125b27938 Merge branch 'master-ghc8107' into master-ios 2023-12-11 17:53:26 +04:00
spaced4ndy 53560378bb Merge branch 'master' into master-ghc8107 2023-12-11 17:52:48 +04:00
Alexander Bondarenko 35c1975d66 core: chat list pagination (#3505)
* add pagination args to APIGetChats

* add search to chat list API

* rename arg to paginationTs_ to match type

* lift another condition to ids query

* collect all chat refs before sorting, then get details

* split remaining preview functions

* roll back to collecting ids first with query cleanup

* add connection join back to filter out groups

* extract and expand tests

* add fav/unread args

* WIP

* lay out the queries with favs

* tweak tests

* add fav tests

* fix order by in the before case

* build query footer wholly from pagination

* add migration for direct contacts

* fix setting contact_used

* fix setting contact_used for group link contacts

* align search x filters space with UI, support filter by either favorite or unread, optimize queries, indexes

* always set chat_ts, fix tests

* refactor tests

* fix pagination logic, more tests

* refactor, rename

* increase default pagination count

* comments

* refactor

* comment

* report errors

* refactor

* remove unused type

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2023-12-11 17:50:32 +04:00
Evgeny Poberezkin 0bfe37137c core: update simplexmq (message notification markers) 2023-12-11 13:11:35 +00:00
Evgeny Poberezkin 666903ae76 Merge branch 'master' into ios-notifications 2023-12-11 13:06:32 +00:00
Evgeny Poberezkin 8a41a4c214 ios: do not start chat if it was stopped, deliver "app stopped" notifications (#3535)
* add stopped notifications, remove full off mode

* core: allow initializing chat data without starting chat

* ios: ask before starting chat if it was stopped

* correct text

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* fix comment

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2023-12-11 12:59:49 +00:00
Evgeny Poberezkin 79a954336c ios: communication between NSE and app via files (#3533)
* ios: communication between NSE and app via files

* clean up

* better concurrency
2023-12-11 12:34:56 +00:00
Evgeny Poberezkin f65b8a9e78 core: mark all user messages read (#3530) 2023-12-11 12:26:45 +00:00
Evgeny Poberezkin e8016adfdc simplexmq 2023-12-10 17:47:44 +00:00
Evgeny Poberezkin d3059afc99 ios, core: better notifications processing to avoid contention for database (#3485)
* core: forward notifications about message processing (for iOS notifications)

* simplexmq

* the option to keep database key, to allow re-opening the database

* export new init with keepKey and reopen DB api

* stop remote ctrl when suspending chat

* ios: close/re-open db on suspend/activate

* allow activating chat without restoring (for NSE)

* update NSE to suspend/activate (does not work)

* simplexmq

* suspend chat and close database when last notification in the process is processed

* stop reading notifications on message markers

* replace async stream with cancellable concurrent queue

* better synchronization of app and NSE

* remove outside of task

* remove unused var

* whitespace

* more debug logging, handle cancelled read after dequeue

* comments

* more comments
2023-12-09 21:59:40 +00:00
Evgeny Poberezkin 691d318764 Merge branch 'master-ios' into stable-ios 2023-12-09 20:43:45 +00:00
Evgeny Poberezkin 2f7632a70f 5.4.1: ios 185, android 164, desktop 19 2023-12-07 21:01:14 +00:00
Evgeny Poberezkin 28b7bad69f Merge branch 'master-ghc8107' into master-ios 2023-12-07 15:13:12 +00:00
Evgeny Poberezkin 9f3d3e8ba4 Merge branch 'master' into master-ghc8107 2023-12-07 15:00:23 +00:00
Evgeny Poberezkin 27c14f32f1 core: 5.4.0.7 2023-12-07 14:30:42 +00:00
Stanislav Dmitrenko 13a32f7864 android: made minimum supported version of Android as 9 (#3525) 2023-12-07 10:49:16 +00:00
Stanislav Dmitrenko b1652b8930 desktop: fix toasts theme (#3524) 2023-12-06 21:19:30 +00:00
Stanislav Dmitrenko a9b36e8e39 desktop: fix onboarding when choosing random password (#3523) 2023-12-06 20:33:53 +00:00
Evgeny Poberezkin ee163a6540 core: add missing field 2023-12-06 12:34:10 +00:00
Evgeny Poberezkin 4fd6405113 core: update simplexmq (better suspend agent) 2023-12-06 00:19:24 +00:00
Stanislav Dmitrenko ccc62274ee android, desktop: crash handler addition (#3517)
* android, desktop: crash handler addition

* added
2023-12-05 22:50:25 +00:00
Stanislav Dmitrenko 4c6d52ba75 android, desktop: crash handler (#3516)
* android, desktop: crash handler

* test

* rename

* string

* Revert "test"

This reverts commit 530faf39c1.

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-12-05 21:31:49 +00:00
Evgeny Poberezkin 9df63160e5 ios: fix simplex address view (#3515)
* ios: fix simplex address view

* fix lib paths

* fix call
2023-12-05 09:48:04 +00:00
Stanislav Dmitrenko c8e9788c29 desktop: enhancements to remote desktop connect UI (#3513)
* desktop: enhancements to remote desktop connect UI

* changes

* more changes

This reverts commit e8323e8bfa.

* color

* random port
2023-12-04 21:04:58 +00:00
spaced4ndy 7099776357 docs: fix typo 2023-12-04 19:17:24 +04:00
Evgeny Poberezkin 4efd505fd3 Merge branch 'master-ghc8107' into master-ios 2023-12-04 12:32:23 +00:00
Evgeny Poberezkin 087acd9180 changes to support GHC 8.10.7 (#3512)
* Revert "raise lower bound on mtl to a real version (#3499)"

This reverts commit f94c0311c1.

* Revert "core: expand ranges to fit ghc 8.10 & 9.6 (#3496)"

This reverts commit 9a1c7f41f7.

* update simplexmq

* remove netword-transport fork

* compatibility with GHC 8.10.7

* simplexmq

* fix test

* simplexmq, deps

* update sqlcipher deps in sha256nix

* fix index-state in cabal.project

* index-state

* remove import

* add cabal.project.freeze

* simplexmq

* remove freeze

* simplexmq

* bytestring,simplexmq

* template-haskell, simplexmq

* simplexmq

* simplexmq

* simplexmq

* mtl

* simplexmq

* remove duplicate index-state
2023-12-04 12:29:49 +00:00
Evgeny Poberezkin 0b822e4a5c Merge branch 'master' into master-ghc8107 2023-12-04 10:07:16 +00:00
Evgeny Poberezkin 3481d379c6 core: compatibility with GHC 8.10.7, narrow dependency ranges (#3503)
* Revert "raise lower bound on mtl to a real version (#3499)"

This reverts commit f94c0311c1.

* Revert "core: expand ranges to fit ghc 8.10 & 9.6 (#3496)"

This reverts commit 9a1c7f41f7.

* update simplexmq

* remove netword-transport fork

* simplexmq

* fix test

* fix index-state in cabal.project

* simplexmq

* simplexmq

* bytestring,simplexmq

* template-haskell, simplexmq

* simplexmq

* simplexmq

* mtl

* simplexmq
2023-12-04 10:01:37 +00:00
Evgeny Poberezkin 85c1e871dc Merge branch 'stable' 2023-12-04 00:06:53 +00:00
Evgeny Poberezkin fec5ff3f15 docs: SimpleX address (#3508)
* docs: SimpleX address

* table

* header
2023-12-03 22:21:13 +00:00
Evgeny Poberezkin acaa597c90 desktop, android: fix image not appearing in view when received (#3504)
* desktop, android: fix image not appearing in view when received

* change to KeyChangeEffect
2023-12-03 15:42:43 +00:00
Evgeny Poberezkin 6a9a67db14 cli: option to mark shown messages as read (off by default) (#3506)
* cli: option to mark shown messages as read (off by default)

* fix tests

* fix tests
2023-12-03 15:42:26 +00:00
Evgeny Poberezkin f8a469488e Merge branch 'master' into master-ghc8107 2023-12-02 12:30:36 +00:00
Alexander Bondarenko f94c0311c1 raise lower bound on mtl to a real version (#3499)
* raise lower bound on mtl to a real version

* simplexmq

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-12-02 12:24:29 +00:00
Stanislav Dmitrenko e1ff7c88d7 desktop: allow changing listening ip and port of remote (#3498)
* desktop: allow changing listening ip and port of remote

* remove empty lines

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-12-01 20:41:08 +00:00
Evgeny Poberezkin 3b5e806418 Merge branch 'master' into master-ghc8107 2023-12-01 17:46:12 +00:00
Alexander Bondarenko 9a1c7f41f7 core: expand ranges to fit ghc 8.10 & 9.6 (#3496)
* expand ranges to fit ghc 8.10 & 9.6

* update nix

* use hashes from mq master

* fix more deps

* use network-transport from hackage
2023-12-01 16:52:47 +00:00
Stanislav Dmitrenko 40e69ae713 desktop: enable database operations (#3495)
* desktop: enable database operations

* disconnect hosts button

* not relaying on dev tools

* different logic

* different logic 2

* toggle placement
2023-12-01 15:04:00 +00:00
spaced4ndy b74e33b958 docs: inactive group members rfc (#3419) 2023-12-01 19:02:50 +04:00
Stanislav Dmitrenko 540c8883a0 android: do not show alert too early in obboarding (#3493) 2023-11-30 19:39:16 +00:00
Stanislav Dmitrenko 0e18b13bea desktop: adapting onboarding process to linking devices (#3490)
* desktop: adapting onboarding process to linking devices

* show progress on long operations

* changes

* clearing chat cache logic

* lines

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-30 19:38:21 +00:00
spaced4ndy bcb2e9fb9b Merge branch 'master-ghc8107' into master-ios 2023-11-30 21:11:07 +04:00
spaced4ndy ef5c13b1c1 Merge branch 'master' into master-ghc8107 2023-11-30 21:10:51 +04:00
spaced4ndy a4b44254bc core: update simplexmq (ghc 8.10.7 compatibility) (#3492) 2023-11-30 21:09:07 +04:00
spaced4ndy 48056d7452 Merge branch 'master-ghc8107' into master-ios 2023-11-30 20:57:16 +04:00
spaced4ndy 38533213d2 Merge branch 'master' into master-ghc8107 2023-11-30 20:56:51 +04:00
spaced4ndy 5819e42305 core: remove CRNewContactConnection response; mobile, desktop: create pending connections based on api responses (CRNewContactConnection was being used as "event" in UI) (#3489) 2023-11-30 20:31:32 +04:00
Jesse Horne 9580b4110d desktop: remember window position and size (#3465)
* initial work on storing desktop window position and size

* removed useless imports

* updated to use app preferences

* vars to vals

* defensive programming

* fixed default

* removed default json

* do nothing if encoding to json while storing fails

* names, clean up

* move comment

* changes

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2023-11-30 12:43:01 +00:00
Stanislav Dmitrenko 05a64c99a2 ios: moving webrtc commands processing to another mechanism (#3480)
* ios: moving webrtc commands processing to another mechanism

* async

* decide

* handle errors

* error alert

* await

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-28 17:36:05 +00:00
Alexander Bondarenko 6a21d5c7f1 add remote host bindings (#3471)
* add remote host bindings

* group iface/address together

* rename migration

* add implementation

* update view and api

* bump upstream

* add schema

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-28 16:32:33 +00:00
Stanislav Dmitrenko 950bbe19da ios: fix calls connecting state (#3475)
* ios: fix calls connecting state

* optimization

* changes

* removed relay protocol

* simplify

* use actor

* fix loop, better onChange, some questions

* remove extra iteration

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-27 22:20:51 +00:00
Evgeny Poberezkin 05278e5a06 core: allow remote host commands without user (#3478) 2023-11-27 18:34:15 +00:00
spaced4ndy b5a99b2b14 Merge branch 'master-ghc8107' into master-ios 2023-11-27 19:17:56 +04:00
spaced4ndy b0002fe07d Merge branch 'master' into master-ghc8107 2023-11-27 19:17:39 +04:00
spaced4ndy 7a54d74517 Revert "ios: update libraries (#3474)"
This reverts commit bfcb2ac230.
2023-11-27 19:16:53 +04:00
spaced4ndy bfcb2ac230 ios: update libraries (#3474) 2023-11-27 19:02:44 +04:00
spaced4ndy 3073c4a1d5 core: fix chat previews showing not the latest message, fix message ordering in direct chats; mobile: update group previews only on timestamp increase (#3473) 2023-11-27 17:14:12 +04:00
Evgeny Poberezkin d4ac1c0cf2 core, ui: add remote host/controller stop reasons to events (#3472) 2023-11-26 23:23:37 +00:00
Evgeny Poberezkin 5899b8f734 Merge branch 'master-ghc8107' into master-ios 2023-11-26 20:00:03 +00:00
Evgeny Poberezkin 4a311b9578 fix for ghc 8.10.7 2023-11-26 19:34:39 +00:00
Evgeny Poberezkin b8da5e225b Merge branch 'master' into master-ghc8107 2023-11-26 18:53:40 +00:00
Evgeny Poberezkin d29f1bb0cf core: use fourmolu styles (#3470) 2023-11-26 18:16:37 +00:00
Jesse Horne 75c2de8a12 desktop: closing console window no longer closes entire application (#3466)
* closing the console now doesn't close all windows

* simplify

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-26 13:40:51 +00:00
Alexander Bondarenko f20ac33e67 cli: remove clashing short option for device-name (#3468) 2023-11-26 13:16:32 +00:00
Evgeny Poberezkin d9428db2f5 Merge branch 'master-ios' into stable-ios 2023-11-26 13:15:13 +00:00
Evgeny Poberezkin 9e9a6ac64e Merge branch 'master' into master-ios 2023-11-26 11:51:34 +00:00
Evgeny Poberezkin 5cc537f14c Merge branch 'master' into master-ghc8107 2023-11-26 11:50:50 +00:00
Evgeny Poberezkin 1bbaffe0a6 Merge branch 'master-ghc8107' into master-ios 2023-11-24 20:02:30 +00:00
Evgeny Poberezkin 0c096e2c89 Merge branch 'master' into master-ghc8107 2023-11-24 19:00:30 +00:00
Evgeny Poberezkin 57eea0947f Merge branch 'master' into master-ios 2023-11-23 16:23:30 +00:00
Evgeny Poberezkin 1781495ee3 Merge branch 'master' into master-ghc8107 2023-11-23 16:22:46 +00:00
Evgeny Poberezkin 006ab7c46a Merge branch 'master-ghc8107' into master-ios 2023-11-21 21:15:39 +00:00
Evgeny Poberezkin 45102442f4 Merge branch 'master' into master-ghc8107 2023-11-21 21:15:10 +00:00
spaced4ndy 45026b6bf5 Merge branch 'master-ghc8107' into master-ios 2023-11-21 19:42:06 +04:00
spaced4ndy 3bdc6b5e28 Merge branch 'master' into master-ghc8107 2023-11-21 19:41:06 +04:00
Evgeny Poberezkin 12f38028f3 Merge branch 'master-ghc8107' into master-ios 2023-11-21 00:01:53 +00:00
Evgeny Poberezkin 3597d34716 Merge branch 'master' into master-ghc8107 2023-11-21 00:00:59 +00:00
Evgeny Poberezkin 55ead740cc update hpack 2023-11-20 14:43:05 +00:00
Evgeny Poberezkin 4fecfe825a update hpack 2023-11-20 14:34:25 +00:00
Evgeny Poberezkin fad4cbf7e5 Merge branch 'master-ghc8107' into master-ios 2023-11-20 14:30:44 +00:00
Evgeny Poberezkin 49a9b0e7d6 update hpack version 2023-11-20 14:30:10 +00:00
Evgeny Poberezkin b6b23d6ddd Merge branch 'master-ghc8107' into master-ios 2023-11-20 13:25:10 +00:00
Evgeny Poberezkin 307a1b3c5e fix for ghc 8.10.7 2023-11-20 13:23:45 +00:00
Evgeny Poberezkin ed6b3bbead Merge branch 'master' into master-ghc8107 2023-11-20 13:01:22 +00:00
spaced4ndy 9ecd5ff1b2 Merge branch 'master-ghc8107' into master-ios 2023-11-20 14:07:45 +04:00
spaced4ndy 7d4127c51d Merge branch 'master' into master-ghc8107 2023-11-20 14:07:08 +04:00
Evgeny Poberezkin 0297cc43d0 Merge branch 'master-ghc8107' into master-ios 2023-11-20 00:07:33 +00:00
Evgeny Poberezkin e1a8099474 fix for GHC 8.10.7 2023-11-20 00:06:45 +00:00
Evgeny Poberezkin daa8d9bb21 Merge branch 'master' into master-ghc8107 2023-11-19 23:42:13 +00:00
Evgeny Poberezkin 20dcfbbffe Merge branch 'master-ghc8107' into master-ios 2023-11-17 11:48:16 +00:00
Evgeny Poberezkin 3937ffa9a6 Merge branch 'master' into master-ghc8107 2023-11-17 11:47:52 +00:00
Evgeny Poberezkin 909ff6516b Merge branch 'master-ghc8107' into master-ios 2023-11-16 18:55:39 +00:00
Evgeny Poberezkin f6e66f1c53 Merge branch 'master' into master-ghc8107 2023-11-16 18:13:02 +00:00
Evgeny Poberezkin bfb6dfb2c5 Merge branch 'master-ghc8107' into master-ios 2023-11-11 13:57:13 +00:00
Evgeny Poberezkin 1570bc2b99 Merge branch 'master' into master-ghc8107 2023-11-11 13:56:53 +00:00
Evgeny Poberezkin 2f1c7400e9 Merge branch 'master-ghc8107' into master-ios 2023-11-11 09:52:27 +00:00
Evgeny Poberezkin f3014f258d Merge branch 'master' into master-ghc8107 2023-11-11 09:51:42 +00:00
Evgeny Poberezkin 168a31c9d7 Merge branch 'master-ghc8107' into master-ios 2023-11-10 21:22:56 +00:00
Evgeny Poberezkin 74b78a8d7b Merge branch 'master' into master-ghc8107 2023-11-10 21:11:08 +00:00
Evgeny Poberezkin 8e33b92f39 Merge branch 'master-ghc8107' into master-ios 2023-11-10 21:08:36 +00:00
Evgeny Poberezkin fe4eb7b5af Merge branch 'master' into master-ghc8107 without changes, to skip update for ghc 9.6.3 2023-11-10 21:04:20 +00:00
Evgeny Poberezkin 3c35dcb4f2 Merge branch 'master-ghc8107' into master-ios 2023-11-06 11:27:10 +00:00
Evgeny Poberezkin 2516d5a393 Merge branch 'master' into master-ghc8107 2023-11-06 11:26:22 +00:00
spaced4ndy 552dc9ab64 Merge branch 'master-ghc8107' into master-ios 2023-11-06 11:42:23 +04:00
spaced4ndy 4253cd7fb9 Merge branch 'master' into master-ghc8107 2023-11-06 11:41:55 +04:00
Evgeny Poberezkin 916e14e44c Merge branch 'master-ghc8107' into master-ios 2023-11-04 13:39:57 +00:00
Evgeny Poberezkin 1f5b80d560 fix for ghc8107 2023-11-04 13:37:25 +00:00
Evgeny Poberezkin 2de111e76c Merge branch 'master' into master-ghc8107 2023-11-04 13:02:08 +00:00
spaced4ndy c27e7fb68d Merge branch 'master-ghc8107' into master-ios 2023-10-30 21:00:55 +04:00
spaced4ndy 5dbe2b2745 Merge branch 'master' into master-ghc8107 2023-10-30 21:00:11 +04:00
Evgeny Poberezkin c84d96b534 Merge branch 'master-ghc8107' into master-ios 2023-10-29 18:27:26 +00:00
Evgeny Poberezkin 6881600e06 Merge branch 'master' into master-ghc8107 2023-10-29 18:24:13 +00:00
spaced4ndy e2a6ff28fb Merge branch 'master-ghc8107' into master-ios 2023-10-25 10:47:53 +04:00
spaced4ndy 9ded1c9821 Merge branch 'master' into master-ghc8107 2023-10-25 10:47:35 +04:00
spaced4ndy aaab15175d Merge branch 'master-ghc8107' into master-ios 2023-10-24 18:14:10 +04:00
spaced4ndy c3e82a6a4e Merge branch 'master' into master-ghc8107 2023-10-24 18:13:56 +04:00
spaced4ndy 64b4d6c6f6 Merge branch 'master-ghc8107' into master-ios 2023-10-24 17:41:36 +04:00
spaced4ndy e7e66ff873 Merge branch 'master' into master-ghc8107 2023-10-24 17:40:55 +04:00
Evgeny Poberezkin 567ba1974e Merge branch 'master-ghc8107' into master-ios 2023-10-22 15:35:27 +01:00
Evgeny Poberezkin d6b9a45a39 Merge branch 'master' into master-ghc8107 2023-10-22 15:10:33 +01:00
Evgeny Poberezkin 64f429f4d2 Merge branch 'master-ghc8107' into master-ios 2023-10-18 22:44:49 +01:00
Evgeny Poberezkin 4004aafbc5 Merge branch 'master' into master-ghc8107 2023-10-18 22:44:27 +01:00
spaced4ndy b30b1d67d5 Merge branch 'master-ghc8107' into master-ios 2023-10-16 20:05:35 +04:00
spaced4ndy c7a8992043 core: fix compilation for ghc 8.10.7 2023-10-16 20:05:13 +04:00
spaced4ndy 48e7418c3a Merge branch 'master-ghc8107' into master-ios 2023-10-16 19:28:31 +04:00
spaced4ndy ed9f277421 Merge branch 'master' into master-ghc8107 2023-10-16 19:28:06 +04:00
Evgeny Poberezkin e0d71dd784 Merge branch 'master-ghc8107' into master-ios 2023-10-15 18:53:52 +01:00
Evgeny Poberezkin d8fb31f167 Merge branch 'master' into master-ghc8107 2023-10-15 18:53:23 +01:00
Evgeny Poberezkin a4e07cbbb3 Merge branch 'master-ghc8107' into master-ios 2023-10-11 21:58:42 +01:00
Evgeny Poberezkin 7692195bfa core: fix for ghc 8.10.7 2023-10-11 21:57:53 +01:00
Evgeny Poberezkin b4a72fafaa Merge branch 'master-ghc8107' into master-ios 2023-10-11 21:27:37 +01:00
Evgeny Poberezkin effc281271 Merge branch 'master' into master-ghc8107 2023-10-11 21:27:21 +01:00
spaced4ndy a5c81d8c77 Merge branch 'master-ghc8107' into master-ios 2023-10-11 13:22:10 +04:00
spaced4ndy 67d74a0a27 Merge branch 'master' into master-ghc8107 2023-10-11 13:21:46 +04:00
Evgeny Poberezkin 3309a3fe4f Merge branch 'stable' into stable-ios 2023-10-09 19:57:27 +01:00
Evgeny Poberezkin bae6a53ec8 Merge branch 'master-ghc8107' into master-ios 2023-10-09 17:32:16 +01:00
Evgeny Poberezkin 74d186af16 Merge branch 'master' into master-ghc8107 2023-10-09 17:31:27 +01:00
Evgeny Poberezkin 4d4dc44302 Merge branch 'stable' into stable-ios 2023-10-09 14:36:32 +01:00
Evgeny Poberezkin 373da2b528 Merge branch 'master-ghc8107' into master-ios 2023-10-09 14:05:41 +01:00
Evgeny Poberezkin 4782cab507 Merge branch 'master' into master-ghc8107 2023-10-09 14:05:04 +01:00
Evgeny Poberezkin 5246445400 Merge branch 'stable-android' into stable-ios 2023-10-08 17:32:06 +01:00
Evgeny Poberezkin cb52d75ff0 Merge branch 'stable' into stable-android 2023-10-08 17:31:23 +01:00
Evgeny Poberezkin d19d2d58a3 Merge branch 'master-ghc8107' into master-ios 2023-10-08 08:41:36 +01:00
Evgeny Poberezkin 2501cbe55d Merge branch 'master' into master-ghc8107 2023-10-08 08:38:02 +01:00
Evgeny Poberezkin 48158a9db1 Merge branch 'master-ghc8107' into master-ios 2023-10-07 21:10:44 +01:00
Evgeny Poberezkin 6b8b9ab4fd Merge branch 'master' into master-ghc8107 2023-10-07 19:06:38 +01:00
Evgeny Poberezkin 25972f8f6c Merge branch 'master-ghc8107' into master-ios 2023-10-02 23:05:10 +01:00
Evgeny Poberezkin 316d605899 Merge branch 'master' into master-ghc8107 2023-10-02 23:04:13 +01:00
Evgeny Poberezkin 8ac12a58c6 Merge branch 'master-ghc8107' into master-ios 2023-10-01 13:22:14 +01:00
Evgeny Poberezkin a3f2d5c919 Merge branch 'master' into master-ghc8107 2023-10-01 13:20:06 +01:00
Evgeny Poberezkin 4de3d6a8f4 Merge branch 'master-ghc8107' into master-ios 2023-10-01 11:20:19 +01:00
Evgeny Poberezkin 0312fde818 Merge branch 'master' into master-ghc8107 2023-10-01 11:19:27 +01:00
Evgeny Poberezkin 39bbea1e18 Merge branch 'stable-android' into stable-ios 2023-10-01 11:04:46 +01:00
Evgeny Poberezkin f7478f5699 Merge branch 'master-ghc8107' into master-ios 2023-09-29 13:15:50 +01:00
Evgeny Poberezkin 915b53054c Merge branch 'master' into master-ghc8107 2023-09-29 13:14:57 +01:00
Evgeny Poberezkin dce29e91d4 Merge branch 'master-ghc8107' into master-ios 2023-09-27 22:10:30 +01:00
Evgeny Poberezkin e273bd1239 Merge branch 'master' into master-ghc8107 2023-09-27 22:04:00 +01:00
spaced4ndy 760ab4a45c Merge branch 'master-ghc8107' into master-ios 2023-09-27 20:38:19 +04:00
spaced4ndy e7f0234134 Merge branch 'master' into master-ghc8107 2023-09-27 20:11:39 +04:00
Evgeny Poberezkin 9c99e4fae2 Merge branch 'master-ghc8107' into master-ios 2023-09-27 16:07:08 +01:00
Evgeny Poberezkin 98a3fc214d Merge branch 'master' into master-ghc8107 2023-09-27 16:04:25 +01:00
Evgeny Poberezkin 6a578cfe3c Merge branch 'master-ghc8107' into master-android 2023-09-25 16:53:04 +01:00
Evgeny Poberezkin dacc075fe8 Merge branch 'master' into master-ghc8107 2023-09-25 16:52:33 +01:00
spaced4ndy c931f52631 Merge branch 'master-ghc8107' into master-ios 2023-09-25 17:44:12 +04:00
spaced4ndy 55418e2bc0 Merge branch 'master-ghc8107' into master-android 2023-09-25 17:43:59 +04:00
spaced4ndy f2b5c0f3a8 Merge branch 'master' into master-ghc8107 2023-09-25 17:43:37 +04:00
spaced4ndy 2d8739dec2 Merge branch 'master-ghc8107' into master-ios 2023-09-25 17:07:43 +04:00
spaced4ndy 5ebdf5dba9 Merge branch 'master-ghc8107' into master-android 2023-09-25 17:07:14 +04:00
spaced4ndy 8e045764df Merge branch 'master' into master-ghc8107 2023-09-25 16:40:08 +04:00
Evgeny Poberezkin 503d3d77e6 Merge branch 'master' into master-ghc8107 2023-09-23 08:47:28 +01:00
Evgeny Poberezkin 87aff89d5e Merge branch 'master-ghc8107' into master-ios 2023-09-22 17:23:28 +01:00
Evgeny Poberezkin 81bd7d97c5 Merge branch 'master' into master-ghc8107 2023-09-22 17:21:54 +01:00
Evgeny Poberezkin ff182c97ee Merge branch 'master-ghc8107' into master-ios 2023-09-22 14:15:01 +01:00
Evgeny Poberezkin 8f57925067 compatibility with GHC 8.10.7 2023-09-22 14:01:25 +01:00
Evgeny Poberezkin 9bf99db82e Merge branch 'master' into master-ghc8107 2023-09-22 13:46:50 +01:00
Evgeny Poberezkin 9f117a30db Merge branch 'master-android' into master-ios 2023-09-21 17:05:05 +01:00
Evgeny Poberezkin 5615cdbf1a Merge branch 'master' into master-android 2023-09-21 17:04:47 +01:00
Evgeny Poberezkin 2cab235888 Merge branch 'master-android' into master-ios 2023-09-21 12:06:35 +01:00
Evgeny Poberezkin d802ae0058 Merge branch 'master' into master-android 2023-09-21 12:06:10 +01:00
Evgeny Poberezkin d4201071e0 Merge branch 'master-android' into master-ios 2023-09-20 14:56:57 +01:00
Evgeny Poberezkin 8f2278198c Merge branch 'master' into master-android 2023-09-20 14:55:25 +01:00
spaced4ndy 10937a5a4e Merge branch 'master' into master-android 2023-09-20 17:36:53 +04:00
Evgeny Poberezkin c0e666770b Merge branch 'master-android' into master-ios 2023-09-18 21:58:07 +01:00
Evgeny Poberezkin 6aff6e9804 Merge branch 'master-ghc9' into master-ghc8107 2023-09-18 21:56:35 +01:00
Evgeny Poberezkin 95477cae7e core: use commit from simplexmq branch master-ghc8107 2023-09-18 21:45:50 +01:00
Evgeny Poberezkin 016f3c9670 Merge branch 'master' into master-ios 2023-09-18 21:30:32 +01:00
spaced4ndy 49116233a8 Merge branch 'master' into master-ios 2023-09-18 14:12:37 +04:00
Evgeny Poberezkin 51621ba11d Merge branch 'stable' into stable-ios 2023-09-17 23:21:46 +01:00
Evgeny Poberezkin e54419e4f0 Merge branch 'master' into master-ios 2023-09-17 22:41:16 +01:00
spaced4ndy d6c6da6edf Merge branch 'master' into master-ios 2023-09-12 18:14:58 +04:00
spaced4ndy e5828f4495 Merge branch 'master' into master-ios 2023-09-11 18:39:29 +04:00
Evgeny Poberezkin 5e23bbecf1 Merge branch 'master' into master-ios 2023-09-10 21:13:18 +01:00
Evgeny Poberezkin 17c734dcb9 Merge branch 'master' into master-ios 2023-09-07 20:19:20 +01:00
Evgeny Poberezkin 85adf2951e Merge branch 'master' into master-ios 2023-09-07 13:45:06 +01:00
Evgeny Poberezkin a250a8e6ca Merge branch 'master' into master-ios 2023-09-06 20:22:00 +01:00
Evgeny Poberezkin 38e10d843b Merge branch 'master' into master-ios 2023-09-06 19:54:48 +01:00
Evgeny Poberezkin 9153cb93de Merge branch 'master' into master-ios 2023-09-05 21:07:22 +01:00
Evgeny Poberezkin f978198fe6 Merge branch 'master' into master-ios 2023-09-04 23:20:27 +01:00
Evgeny Poberezkin 7b434d9f97 Merge branch 'master' into master-ios 2023-09-04 18:29:53 +01:00
Evgeny Poberezkin aaf6dd57c7 Merge branch 'master' into master-ios 2023-09-02 23:34:35 +01:00
Evgeny Poberezkin dc2272ac6e Merge branch 'master' into master-ios 2023-09-01 22:27:15 +01:00
Evgeny Poberezkin 4c172ac680 Merge branch 'master' into master-ios 2023-09-01 20:40:14 +01:00
Evgeny Poberezkin 096bd9ea72 Merge branch 'master' into master-ios 2023-08-26 18:37:12 +01:00
Evgeny Poberezkin f8b757ddf9 Merge branch 'master' into master-ios 2023-08-25 14:11:13 +01:00
Evgeny Poberezkin 62a4fd2c7c Merge branch 'master' into master-ios 2023-08-25 11:32:07 +01:00
Evgeny Poberezkin d6dba0e26a Merge branch 'master' into master-ios 2023-08-24 20:43:20 +01:00
Evgeny Poberezkin 20ebccdbf2 Merge branch 'master' into master-ios 2023-08-22 16:14:32 +01:00
Evgeny Poberezkin 2ac6c2dec9 Merge branch 'master' into master-ios 2023-08-18 21:36:18 +01:00
Evgeny Poberezkin 2babe6d548 Merge branch 'master' into master-ios 2023-08-17 23:16:40 +01:00
spaced4ndy 0a3f2444d9 Merge branch 'master' into master-ios 2023-08-16 10:44:15 +04:00
Evgeny Poberezkin ebfa9e699b Merge branch 'master' into master-ios 2023-08-12 22:00:40 +01:00
Evgeny Poberezkin 2cf07ae0a7 Merge branch 'master' into master-ios 2023-08-12 18:16:16 +01:00
Evgeny Poberezkin 5199cb9e82 Merge branch 'master' into master-ios 2023-08-12 15:26:18 +01:00
Evgeny Poberezkin c42b8373b0 Merge branch 'stable' into stable-ios 2023-08-12 14:33:39 +01:00
spaced4ndy 4bf43c3cde Merge branch 'master' into master-ios 2023-08-08 19:05:23 +04:00
Evgeny Poberezkin fe8c98d69a Merge branch 'stable' into stable-ios 2023-08-06 22:14:33 +01:00
Evgeny Poberezkin b2523e07f8 Merge branch 'master' into master-ios 2023-08-05 15:16:34 +01:00
spaced4ndy deb4fae07c Merge branch 'master' into master-ios 2023-08-03 18:28:23 +04:00
Evgeny Poberezkin 1f4e99f291 Merge branch 'master' into master-ios 2023-08-02 16:23:36 +01:00
Evgeny Poberezkin 439900dbdb Merge branch 'stable' into stable-ios 2023-08-02 16:22:58 +01:00
Evgeny Poberezkin 0f2ac69562 Merge branch 'master' into master-ios 2023-07-31 16:13:41 +01:00
spaced4ndy 8704a23c6f Merge branch 'stable' into stable-ios 2023-07-31 17:00:48 +04:00
spaced4ndy 2ee528c81c Merge branch 'stable' into stable-ios 2023-07-31 15:27:17 +04:00
spaced4ndy 5e1e6aed6b Merge branch 'stable' into stable-ios 2023-07-31 14:21:30 +04:00
spaced4ndy 96128dbf33 Merge branch 'stable' into stable-ios 2023-07-31 12:19:19 +04:00
spaced4ndy 4c3bab1402 Merge branch 'master' into master-ios 2023-07-28 11:10:08 +04:00
spaced4ndy 281f6d0bf9 Merge branch 'master' into master-ios 2023-07-26 15:03:26 +04:00
Evgeny Poberezkin 065bd27b36 Merge branch 'stable' into stable-ios 2023-07-22 16:27:13 +01:00
Evgeny Poberezkin 46043acb85 Merge branch 'stable' into stable-ios 2023-07-22 15:30:21 +01:00
Evgeny Poberezkin e956690390 Merge branch 'stable' into stable-ios 2023-07-22 13:30:47 +01:00
Evgeny Poberezkin 1022124f69 Merge branch 'stable' into master-ios 2023-07-21 23:31:58 +01:00
Evgeny Poberezkin 5803274950 Merge branch 'master' into master-ios 2023-07-20 18:20:12 +01:00
spaced4ndy 444c5e2fc4 Merge branch 'master' into master-ios 2023-07-20 18:44:13 +04:00
Evgeny Poberezkin 1038305802 Merge branch 'master' into master-ios 2023-07-17 14:45:16 +01:00
Evgeny Poberezkin f9530c70fc Merge branch 'master' into master-ios 2023-07-17 13:34:22 +01:00
Evgeny Poberezkin 5e0b2a8706 Merge branch 'master' into master-ios 2023-07-13 23:48:55 +01:00
spaced4ndy 7e9990d710 Merge branch 'master' into master-ios 2023-07-11 20:57:44 +04:00
spaced4ndy 27244da82a Merge branch 'master' into master-ios 2023-07-10 17:40:43 +04:00
Evgeny Poberezkin d89c6ae672 Merge branch 'master' into master-ios 2023-07-09 23:25:16 +01:00
spaced4ndy 121e0ede6c Merge branch 'master' into master-ios 2023-07-05 19:45:23 +04:00
Evgeny Poberezkin ae7a71c4a4 Merge branch 'master' into master-ios 2023-07-05 09:10:43 +01:00
spaced4ndy 8df46c8630 Merge branch 'master' into master-ios 2023-07-03 20:16:45 +04:00
spaced4ndy 55a17a564e Merge branch 'master' into master-ios 2023-06-28 16:49:43 +04:00
spaced4ndy e0956b656c Merge branch 'master' into master-ios 2023-06-28 16:13:45 +04:00
spaced4ndy ca0192abfc Merge branch 'master' into master-ios 2023-06-26 21:48:51 +04:00
Evgeny Poberezkin 3eec371c3f Merge branch 'master' into master-ios 2023-06-24 17:40:05 +01:00
spaced4ndy 1c21afddff Merge branch 'master' into master-ios 2023-06-20 10:19:40 +04:00
spaced4ndy 94b3adb515 Merge branch 'master' into master-ios 2023-06-19 16:07:45 +04:00
spaced4ndy 4c7784c9dc Merge branch 'master' into master-ios 2023-06-16 19:06:31 +04:00
spaced4ndy 99b39fda91 Merge branch 'master' into master-ios 2023-06-12 13:47:27 +04:00
spaced4ndy c16bd3f7b1 Merge branch 'master' into master-ios 2023-06-09 17:31:57 +04:00
spaced4ndy 1c49ba9eaf Merge branch 'master' into master-ios 2023-05-29 15:22:06 +04:00
Evgeny Poberezkin b83fb77b9e Merge branch 'master' into master-ios 2023-05-27 19:36:22 +01:00
spaced4ndy 5cac9c4594 Merge branch 'master' into master-ios 2023-05-26 17:36:58 +04:00
spaced4ndy d17623d20b Merge branch 'master' into master-ios 2023-05-26 14:06:07 +04:00
spaced4ndy 3b452ae4af Merge branch 'master' into master-ios 2023-05-26 14:04:05 +04:00
spaced4ndy 08ba0b2755 Merge branch 'master' into master-ios 2023-05-25 20:55:25 +04:00
spaced4ndy abd362f10a Merge branch 'master' into master-ios 2023-05-24 16:17:05 +04:00
spaced4ndy 31b47746e5 Merge branch 'master' into master-ios 2023-05-23 19:12:25 +04:00
spaced4ndy 76ce8bec1d Merge branch 'master' into master-ios 2023-05-23 18:40:52 +04:00
spaced4ndy eeb2fee659 Merge branch 'master' into master-ios 2023-05-23 15:55:11 +04:00
spaced4ndy d034cb08f9 Merge branch 'master' into master-ios 2023-05-23 13:51:52 +04:00
Evgeny Poberezkin 9d9761ad98 Merge branch 'master' into master-ios 2023-05-20 23:09:20 +01:00
spaced4ndy ffdd598a9c Merge branch 'master' into master-ios 2023-05-19 17:00:44 +04:00
Evgeny Poberezkin e4649c4776 Merge branch 'master' into master-ios 2023-05-19 13:53:30 +01:00
Evgeny Poberezkin 3e9df58955 Merge branch 'master' into master-ios 2023-05-18 17:13:13 +01:00
spaced4ndy b014bfcdb4 Merge branch 'master' into master-ios 2023-05-17 16:14:18 +04:00
Evgeny Poberezkin b8b4ef39e9 Merge branch 'master' into master-ios 2023-05-17 00:23:36 +01:00
spaced4ndy 2a9f98e43f Merge branch 'master' into master-ios 2023-05-16 15:06:01 +04:00
spaced4ndy 6e0ec89fdc Merge branch 'master' into master-ios 2023-05-15 21:23:22 +04:00
Evgeny Poberezkin 080ab90e26 Merge branch 'master' into master-ios 2023-05-15 13:28:27 +01:00
spaced4ndy a6d1f7d449 Merge branch 'master' into master-ios 2023-05-11 16:00:32 +04:00
Evgeny Poberezkin 19c94e59e6 Merge branch 'master' into master-ios 2023-05-09 09:34:05 +01:00
spaced4ndy 1bb4672815 Merge branch 'master' into master-ios 2023-05-09 11:00:46 +04:00
spaced4ndy 371db95950 Merge branch 'master' into master-ios 2023-05-05 15:55:54 +04:00
Evgeny Poberezkin 672c807e47 Merge branch 'master' into master-ios 2023-05-04 13:28:20 +01:00
spaced4ndy 512394b8a6 Merge branch 'master' into master-ios 2023-05-03 20:26:46 +04:00
spaced4ndy 7d3c188cd2 Merge branch 'master' into master-ios 2023-05-03 15:31:03 +04:00
spaced4ndy 2fe765b0bc Merge branch 'master' into master-ios 2023-05-02 10:25:26 +04:00
Evgeny Poberezkin 40cf101148 Merge branch 'master' into master-ios 2023-05-01 11:34:16 +01:00
spaced4ndy 680903a763 Merge branch 'master' into master-ios 2023-04-26 14:35:44 +04:00
spaced4ndy 6b6eff6b52 Merge branch 'master' into master-ios 2023-04-21 13:48:21 +04:00
spaced4ndy e949b4fc3c Merge branch 'master' into master-ios 2023-04-20 20:28:49 +04:00
Evgeny Poberezkin 6790362036 Merge branch 'master' into master-ios 2023-04-20 14:19:27 +01:00
spaced4ndy 623b84d3e1 Merge branch 'master' into master-ios 2023-04-19 15:21:51 +04:00
Evgeny Poberezkin 101ab76a33 Merge branch 'master' into master-ios 2023-04-18 21:04:45 +01:00
spaced4ndy c1909b91d0 Merge branch 'master' into master-ios 2023-04-18 19:49:26 +04:00
spaced4ndy cb9cf6229c Merge branch 'master' into master-ios 2023-04-18 13:49:47 +04:00
spaced4ndy 1561268c5e Merge branch 'master' into master-ios 2023-04-18 12:49:17 +04:00
Evgeny Poberezkin 80368f97ef Merge branch 'master' into master-ios 2023-04-17 10:18:27 +01:00
Evgeny Poberezkin 7ec6f2b421 Merge branch 'master' into master-ios 2023-04-16 18:31:05 +01:00
Evgeny Poberezkin d3a1aec7ad Merge branch 'master' into master-ios 2023-04-16 15:04:35 +01:00
spaced4ndy 990f96a19f Merge branch 'master' into master-ios 2023-04-14 18:06:36 +04:00
spaced4ndy 330c9afc1b Merge branch 'master' into master-ios 2023-04-14 15:55:38 +04:00
spaced4ndy 93922a592c Merge branch 'master' into master-ios 2023-04-13 20:49:23 +04:00
Evgeny Poberezkin a9b08e5c2f Merge branch 'master' into master-ios 2023-04-07 23:23:26 +01:00
Evgeny Poberezkin c677baaa22 Merge branch 'master' into master-ios 2023-04-06 23:16:48 +01:00
Evgeny Poberezkin 5448573d0b Merge branch 'master' into master-ios 2023-04-06 22:49:53 +01:00
Evgeny Poberezkin 1a1b5aade5 Merge branch 'master' into master-ios 2023-04-06 21:07:07 +01:00
Evgeny Poberezkin bd9a60af5a Merge branch 'master' into master-ios 2023-04-05 22:00:08 +01:00
Evgeny Poberezkin 617e12a3d4 Merge branch 'master' into master-ios 2023-04-04 18:43:42 +01:00
spaced4ndy 8c625faa5e Merge branch 'master' into master-ios 2023-03-31 19:20:03 +04:00
spaced4ndy cab7201b72 Merge branch 'master' into master-ios 2023-03-31 17:36:32 +04:00
spaced4ndy c2fd1d5d29 Merge branch 'master' into master-ios 2023-03-30 19:45:49 +04:00
spaced4ndy 5b541e296b Merge branch 'master' into master-ios 2023-03-30 18:42:13 +04:00
Evgeny Poberezkin daaecf11ec Merge branch 'master' into master-ios 2023-03-29 20:46:34 +01:00
spaced4ndy 9c4df591a7 Merge branch 'master' into master-ios 2023-03-29 19:17:15 +04:00
Evgeny Poberezkin c339e8290b Merge branch 'master' into master-ios 2023-03-28 19:26:26 +01:00
spaced4ndy ac606336ed Merge branch 'master' into master-ios 2023-03-28 19:29:30 +04:00
Evgeny Poberezkin f79e6fddec Merge branch 'master' into master-ios 2023-03-27 23:22:16 +01:00
Evgeny Poberezkin 2aab931589 nix: revert changes from 05c4a6c (support for ARMv7a and Android 8+) 2023-03-27 20:48:49 +01:00
269 changed files with 9264 additions and 5224 deletions
+8 -5
View File
@@ -9,6 +9,7 @@ on:
tags: tags:
- "v*" - "v*"
- "!*-fdroid" - "!*-fdroid"
- "!*-armv7a"
pull_request: pull_request:
jobs: jobs:
@@ -79,10 +80,10 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Haskell - name: Setup Haskell
uses: haskell-actions/setup@v2 uses: haskell/actions/setup@v2
with: with:
ghc-version: "9.6.3" ghc-version: "8.10.7"
cabal-version: "3.10.1.0" cabal-version: "latest"
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v3 uses: actions/cache@v3
@@ -188,7 +189,7 @@ jobs:
APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }} APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }} APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
run: | run: |
scripts/ci/build-desktop-mac.sh scripts/build-desktop-mac.sh
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg) path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
@@ -259,7 +260,9 @@ jobs:
# Unix / # Unix /
# / Windows # / Windows
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
# * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
- name: 'Setup MSYS2' - name: 'Setup MSYS2'
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
+2 -1
View File
@@ -42,6 +42,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
let m = ChatModel.shared let m = ChatModel.shared
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token) let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
m.deviceToken = deviceToken m.deviceToken = deviceToken
// savedToken is set in startChat, when it is started before this method is called
if m.savedToken != nil { if m.savedToken != nil {
registerToken(token: deviceToken) registerToken(token: deviceToken)
} }
@@ -80,7 +81,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
} }
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic { if m.ntfEnablePeriodic && allowBackgroundRefresh() && BGManager.shared.lastRanLongAgo {
receiveMessages(completionHandler) receiveMessages(completionHandler)
} else { } else {
completionHandler(.noData) completionHandler(.noData)
+84 -49
View File
@@ -14,11 +14,14 @@ struct ContentView: View {
@ObservedObject var alertManager = AlertManager.shared @ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared @ObservedObject var callController = CallController.shared
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Binding var doAuthenticate: Bool
@Binding var userAuthorized: Bool? var contentAccessAuthenticationExtended: Bool
@Binding var canConnectCall: Bool
@Binding var lastSuccessfulUnlock: TimeInterval? @Environment(\.scenePhase) var scenePhase
@Binding var showInitializationView: Bool @State private var automaticAuthenticationAttempted = false
@State private var canConnectViewCall = false
@State private var lastSuccessfulUnlock: TimeInterval? = nil
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@@ -40,9 +43,19 @@ struct ContentView: View {
} }
} }
private var accessAuthenticated: Bool {
chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended
}
var body: some View { var body: some View {
ZStack { ZStack {
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
// i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() }
if !prefPerformLA || accessAuthenticated {
contentView() contentView()
} else {
lockButton()
}
if chatModel.showCallView, let call = chatModel.activeCall { if chatModel.showCallView, let call = chatModel.activeCall {
callView(call) callView(call)
} }
@@ -50,6 +63,7 @@ struct ContentView: View {
LocalAuthView(authRequest: la) LocalAuthView(authRequest: la)
} else if showSetPasscode { } else if showSetPasscode {
SetAppPasscodeView { SetAppPasscodeView {
chatModel.contentViewAccessAuthenticated = true
prefPerformLA = true prefPerformLA = true
showSetPasscode = false showSetPasscode = false
privacyLocalAuthModeDefault.set(.passcode) privacyLocalAuthModeDefault.set(.passcode)
@@ -60,13 +74,9 @@ struct ContentView: View {
alertManager.showAlert(laPasscodeNotSetAlert()) alertManager.showAlert(laPasscodeNotSetAlert())
} }
} }
if chatModel.chatDbStatus == nil {
initializationView()
} }
.onAppear {
if prefPerformLA { requestNtfAuthorization() }
initAuthenticate()
}
.onChange(of: doAuthenticate) { _ in
initAuthenticate()
} }
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
@@ -76,14 +86,44 @@ struct ContentView: View {
Button("System authentication") { initialEnableLA() } Button("System authentication") { initialEnableLA() }
Button("Passcode entry") { showSetPasscode = true } Button("Passcode entry") { showSetPasscode = true }
} }
.onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
switch (phase) {
case .background:
// also see .onChange(of: scenePhase) in SimpleXApp: on entering background
// it remembers enteredBackgroundAuthenticated and sets chatModel.contentViewAccessAuthenticated to false
automaticAuthenticationAttempted = false
canConnectViewCall = false
case .active:
canConnectViewCall = !prefPerformLA || contentAccessAuthenticationExtended || unlockedRecently()
// condition `!chatModel.contentViewAccessAuthenticated` is required for when authentication is enabled in settings or on initial notice
if prefPerformLA && !chatModel.contentViewAccessAuthenticated {
if AppChatState.shared.value != .stopped {
if contentAccessAuthenticationExtended {
chatModel.contentViewAccessAuthenticated = true
} else {
if !automaticAuthenticationAttempted {
automaticAuthenticationAttempted = true
// authenticate if call kit call is not in progress
if !(CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil) {
authenticateContentViewAccess()
}
}
}
} else {
// when app is stopped automatic authentication is not attempted
chatModel.contentViewAccessAuthenticated = contentAccessAuthenticationExtended
}
}
default:
break
}
}
} }
@ViewBuilder private func contentView() -> some View { @ViewBuilder private func contentView() -> some View {
if prefPerformLA && userAuthorized != true { if let status = chatModel.chatDbStatus, status != .ok {
lockButton()
} else if chatModel.chatDbStatus == nil && showInitializationView {
initializationView()
} else if let status = chatModel.chatDbStatus, status != .ok {
DatabaseErrorView(status: status) DatabaseErrorView(status: status)
} else if !chatModel.v3DBMigration.startChat { } else if !chatModel.v3DBMigration.startChat {
MigrateToAppGroupView() MigrateToAppGroupView()
@@ -106,11 +146,11 @@ struct ContentView: View {
if CallController.useCallKit() { if CallController.useCallKit() {
ActiveCallView(call: call, canConnectCall: Binding.constant(true)) ActiveCallView(call: call, canConnectCall: Binding.constant(true))
.onDisappear { .onDisappear {
if userAuthorized == false && doAuthenticate { runAuthenticate() } if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() }
} }
} else { } else {
ActiveCallView(call: call, canConnectCall: $canConnectCall) ActiveCallView(call: call, canConnectCall: $canConnectViewCall)
if prefPerformLA && userAuthorized != true { if prefPerformLA && !accessAuthenticated {
Rectangle() Rectangle()
.fill(colorScheme == .dark ? .black : .white) .fill(colorScheme == .dark ? .black : .white)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -120,22 +160,27 @@ struct ContentView: View {
} }
private func lockButton() -> some View { private func lockButton() -> some View {
Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
} }
private func initializationView() -> some View { private func initializationView() -> some View {
VStack { VStack {
ProgressView().scaleEffect(2) ProgressView().scaleEffect(2)
Text("Opening database") Text("Opening app")
.padding() .padding()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity )
.background(
Rectangle()
.fill(.background)
)
} }
private func mainView() -> some View { private func mainView() -> some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen) ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
.onAppear { .onAppear {
if !prefPerformLA { requestNtfAuthorization() } requestNtfAuthorization()
// Local Authentication notice is to be shown on next start after onboarding is complete // Local Authentication notice is to be shown on next start after onboarding is complete
if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) { if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) {
prefLANoticeShown = true prefLANoticeShown = true
@@ -187,51 +232,40 @@ struct ContentView: View {
} }
} }
private func initAuthenticate() { private func unlockedRecently() -> Bool {
logger.debug("initAuthenticate") if let lastSuccessfulUnlock = lastSuccessfulUnlock {
if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil { return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
userAuthorized = false
} else if doAuthenticate {
runAuthenticate()
}
}
private func runAuthenticate() {
logger.debug("DEBUGGING: runAuthenticate")
if !prefPerformLA {
userAuthorized = true
} else { } else {
logger.debug("DEBUGGING: before dismissAllSheets") return false
dismissAllSheets(animated: false) {
logger.debug("DEBUGGING: in dismissAllSheets callback")
chatModel.chatId = nil
justAuthenticate()
}
} }
} }
private func justAuthenticate() { private func authenticateContentViewAccess() {
userAuthorized = false logger.debug("DEBUGGING: authenticateContentViewAccess")
let laMode = privacyLocalAuthModeDefault.get() dismissAllSheets(animated: false) {
logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback")
chatModel.chatId = nil
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
switch (laResult) { switch (laResult) {
case .success: case .success:
userAuthorized = true chatModel.contentViewAccessAuthenticated = true
canConnectCall = true canConnectViewCall = true
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
case .failed: case .failed:
if laMode == .passcode { chatModel.contentViewAccessAuthenticated = false
if privacyLocalAuthModeDefault.get() == .passcode {
AlertManager.shared.showAlert(laFailedAlert()) AlertManager.shared.showAlert(laFailedAlert())
} }
case .unavailable: case .unavailable:
userAuthorized = true
prefPerformLA = false prefPerformLA = false
canConnectCall = true canConnectViewCall = true
AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
} }
} }
} }
}
func requestNtfAuthorization() { func requestNtfAuthorization() {
NtfManager.shared.requestAuthorization( NtfManager.shared.requestAuthorization(
@@ -259,6 +293,7 @@ struct ContentView: View {
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
switch laResult { switch laResult {
case .success: case .success:
chatModel.contentViewAccessAuthenticated = true
prefPerformLA = true prefPerformLA = true
alertManager.showAlert(laTurnedOnAlert()) alertManager.showAlert(laTurnedOnAlert())
case .failed: case .failed:
+27 -5
View File
@@ -15,7 +15,13 @@ private let receiveTaskId = "chat.simplex.app.receive"
// TCP timeout + 2 sec // TCP timeout + 2 sec
private let waitForMessages: TimeInterval = 6 private let waitForMessages: TimeInterval = 6
private let bgRefreshInterval: TimeInterval = 450 // This is the smallest interval between refreshes, and also target interval in "off" mode
private let bgRefreshInterval: TimeInterval = 600 // 10 minutes
// This intervals are used for background refresh in instant and periodic modes
private let periodicBgRefreshInterval: TimeInterval = 1200 // 20 minutes
private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes
private let maxTimerCount = 9 private let maxTimerCount = 9
@@ -33,14 +39,14 @@ class BGManager {
} }
} }
func schedule() { func schedule(interval: TimeInterval? = nil) {
if !ChatModel.shared.ntfEnableLocal { if !ChatModel.shared.ntfEnableLocal {
logger.debug("BGManager.schedule: disabled") logger.debug("BGManager.schedule: disabled")
return return
} }
logger.debug("BGManager.schedule") logger.debug("BGManager.schedule")
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId) let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval) request.earliestBeginDate = Date(timeIntervalSinceNow: interval ?? runInterval)
do { do {
try BGTaskScheduler.shared.submit(request) try BGTaskScheduler.shared.submit(request)
} catch { } catch {
@@ -48,20 +54,34 @@ class BGManager {
} }
} }
var runInterval: TimeInterval {
switch ChatModel.shared.notificationMode {
case .instant: maxBgRefreshInterval
case .periodic: periodicBgRefreshInterval
case .off: bgRefreshInterval
}
}
var lastRanLongAgo: Bool {
Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval
}
private func handleRefresh(_ task: BGAppRefreshTask) { private func handleRefresh(_ task: BGAppRefreshTask) {
if !ChatModel.shared.ntfEnableLocal { if !ChatModel.shared.ntfEnableLocal {
logger.debug("BGManager.handleRefresh: disabled") logger.debug("BGManager.handleRefresh: disabled")
return return
} }
logger.debug("BGManager.handleRefresh") logger.debug("BGManager.handleRefresh")
let shouldRun_ = lastRanLongAgo
if allowBackgroundRefresh() && shouldRun_ {
schedule() schedule()
if appStateGroupDefault.get().inactive {
let completeRefresh = completionHandler { let completeRefresh = completionHandler {
task.setTaskCompleted(success: true) task.setTaskCompleted(success: true)
} }
task.expirationHandler = { completeRefresh("expirationHandler") } task.expirationHandler = { completeRefresh("expirationHandler") }
receiveMessages(completeRefresh) receiveMessages(completeRefresh)
} else { } else {
schedule(interval: shouldRun_ ? bgRefreshInterval : runInterval)
logger.debug("BGManager.completionHandler: already active, not started") logger.debug("BGManager.completionHandler: already active, not started")
task.setTaskCompleted(success: true) task.setTaskCompleted(success: true)
} }
@@ -90,20 +110,22 @@ class BGManager {
} }
self.completed = false self.completed = false
DispatchQueue.main.async { DispatchQueue.main.async {
chatLastBackgroundRunGroupDefault.set(Date.now)
let m = ChatModel.shared let m = ChatModel.shared
if (!m.chatInitialized) { if (!m.chatInitialized) {
setAppState(.bgRefresh)
do { do {
try initializeChat(start: true) try initializeChat(start: true)
} catch let error { } catch let error {
fatalError("Failed to start or load chats: \(responseError(error))") fatalError("Failed to start or load chats: \(responseError(error))")
} }
} }
activateChat(appState: .bgRefresh)
if m.currentUser == nil { if m.currentUser == nil {
completeReceiving("no current user") completeReceiving("no current user")
return return
} }
logger.debug("BGManager.receiveMessages: starting chat") logger.debug("BGManager.receiveMessages: starting chat")
activateChat(appState: .bgRefresh)
let cr = ChatReceiver() let cr = ChatReceiver()
self.chatReceiver = cr self.chatReceiver = cr
cr.start() cr.start()
+19 -6
View File
@@ -54,6 +54,8 @@ final class ChatModel: ObservableObject {
@Published var chatDbChanged = false @Published var chatDbChanged = false
@Published var chatDbEncrypted: Bool? @Published var chatDbEncrypted: Bool?
@Published var chatDbStatus: DBMigrationResult? @Published var chatDbStatus: DBMigrationResult?
// local authentication
@Published var contentViewAccessAuthenticated: Bool = false
@Published var laRequest: LocalAuthRequest? @Published var laRequest: LocalAuthRequest?
// list of chat "previews" // list of chat "previews"
@Published var chats: [Chat] = [] @Published var chats: [Chat] = []
@@ -83,7 +85,7 @@ final class ChatModel: ObservableObject {
// current WebRTC call // current WebRTC call
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:] @Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
@Published var activeCall: Call? @Published var activeCall: Call?
@Published var callCommand: WCallCommand? let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
@Published var showCallView = false @Published var showCallView = false
// remote desktop // remote desktop
@Published var remoteCtrlSession: RemoteCtrlSession? @Published var remoteCtrlSession: RemoteCtrlSession?
@@ -104,12 +106,10 @@ final class ChatModel: ObservableObject {
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
var ntfEnableLocal: Bool { let ntfEnableLocal = true
notificationMode == .off || ntfEnableLocalGroupDefault.get()
}
var ntfEnablePeriodic: Bool { var ntfEnablePeriodic: Bool {
notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() notificationMode != .off
} }
var activeRemoteCtrl: Bool { var activeRemoteCtrl: Bool {
@@ -267,7 +267,20 @@ final class ChatModel: ObservableObject {
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update previews // update previews
if let i = getChatIndex(cInfo.id) { if let i = getChatIndex(cInfo.id) {
chats[i].chatItems = [cItem] chats[i].chatItems = switch cInfo {
case .group:
if let currentPreviewItem = chats[i].chatItems.first {
if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
[cItem]
} else {
[currentPreviewItem]
}
} else {
[cItem]
}
default:
[cItem]
}
if case .rcvNew = cItem.meta.itemStatus { if case .rcvNew = cItem.meta.itemStatus {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1 chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
increaseUnreadCounter(user: currentUser!) increaseUnreadCounter(user: currentUser!)
+4 -4
View File
@@ -195,18 +195,18 @@ func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
} }
} }
func generateNewFileName(_ prefix: String, _ ext: String) -> String { func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)") uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
} }
private func uniqueCombine(_ fileName: String) -> String { private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String {
func tryCombine(_ fileName: String, _ n: Int) -> String { func tryCombine(_ fileName: String, _ n: Int) -> String {
let ns = fileName as NSString let ns = fileName as NSString
let name = ns.deletingPathExtension let name = ns.deletingPathExtension
let ext = ns.pathExtension let ext = ns.pathExtension
let suffix = (n == 0) ? "" : "_\(n)" let suffix = (n == 0) ? "" : "_\(n)"
let f = "\(name)\(suffix).\(ext)" let f = "\(name)\(suffix).\(ext)"
return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
} }
return tryCombine(fileName, 0) return tryCombine(fileName, 0)
} }
+83
View File
@@ -0,0 +1,83 @@
//
// NSESubscriber.swift
// SimpleXChat
//
// Created by Evgeny on 09/12/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import Foundation
import SimpleXChat
private var nseSubscribers: [UUID:NSESubscriber] = [:]
// timeout for active notification service extension going into "suspending" state.
// If in two seconds the state does not change, we assume that it was not running and proceed with app activation/answering call.
private let SUSPENDING_TIMEOUT: TimeInterval = 2
// timeout should be larger than SUSPENDING_TIMEOUT
func waitNSESuspended(timeout: TimeInterval, suspended: @escaping (Bool) -> Void) {
if timeout <= SUSPENDING_TIMEOUT {
logger.warning("waitNSESuspended: small timeout \(timeout), using \(SUSPENDING_TIMEOUT + 1)")
}
var state = nseStateGroupDefault.get()
if case .suspended = state {
DispatchQueue.main.async { suspended(true) }
return
}
let id = UUID()
var suspendedCalled = false
checkTimeout()
nseSubscribers[id] = nseMessageSubscriber { msg in
if case let .state(newState) = msg {
state = newState
logger.debug("waitNSESuspended state: \(state.rawValue)")
if case .suspended = newState {
notifySuspended(true)
}
}
}
return
func notifySuspended(_ ok: Bool) {
logger.debug("waitNSESuspended notifySuspended: \(ok)")
if !suspendedCalled {
logger.debug("waitNSESuspended notifySuspended: calling suspended(\(ok))")
suspendedCalled = true
nseSubscribers.removeValue(forKey: id)
DispatchQueue.main.async { suspended(ok) }
}
}
func checkTimeout() {
if !suspending() {
checkSuspendingTimeout()
} else if state == .suspending {
checkSuspendedTimeout()
}
}
func suspending() -> Bool {
suspendedCalled || state == .suspended || state == .suspending
}
func checkSuspendingTimeout() {
DispatchQueue.global().asyncAfter(deadline: .now() + SUSPENDING_TIMEOUT) {
logger.debug("waitNSESuspended check suspending timeout")
if !suspending() {
notifySuspended(false)
} else if state != .suspended {
checkSuspendedTimeout()
}
}
}
func checkSuspendedTimeout() {
DispatchQueue.global().asyncAfter(deadline: .now() + min(timeout - SUSPENDING_TIMEOUT, 1)) {
logger.debug("waitNSESuspended check suspended timeout")
if state != .suspended {
notifySuspended(false)
}
}
}
}
+32 -30
View File
@@ -211,7 +211,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
} }
func apiStartChat() throws -> Bool { func apiStartChat() throws -> Bool {
let r = chatSendCmdSync(.startChat(subscribe: true, expire: true, xftp: true)) let r = chatSendCmdSync(.startChat(mainApp: true))
switch r { switch r {
case .chatStarted: return true case .chatStarted: return true
case .chatRunning: return false case .chatRunning: return false
@@ -228,7 +228,8 @@ func apiStopChat() async throws {
} }
func apiActivateChat() { func apiActivateChat() {
let r = chatSendCmdSync(.apiActivateChat) chatReopenStore()
let r = chatSendCmdSync(.apiActivateChat(restoreChat: true))
if case .cmdOk = r { return } if case .cmdOk = r { return }
logger.error("apiActivateChat error: \(String(describing: r))") logger.error("apiActivateChat error: \(String(describing: r))")
} }
@@ -402,7 +403,7 @@ func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) {
case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode) case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode)
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off) case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off)
default: default:
logger.debug("apiGetNtfToken response: \(String(describing: r), privacy: .public)") logger.debug("apiGetNtfToken response: \(String(describing: r))")
return (nil, nil, .off) return (nil, nil, .off)
} }
} }
@@ -605,27 +606,29 @@ func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
throw r throw r
} }
func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? {
let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
if let alert = alert { if let alert = alert {
AlertManager.shared.showAlert(alert) AlertManager.shared.showAlert(alert)
return nil return nil
} else { } else {
return connReqType return r
} }
} }
func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) { func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, PendingContactConnection)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else { guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnect: no current user") logger.error("apiConnect: no current user")
return (nil, nil) return (nil, nil)
} }
let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq)) let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq))
switch r {
case .sentConfirmation: return (.invitation, nil)
case .sentInvitation: return (.contact, nil)
case let .contactAlreadyExists(_, contact):
let m = ChatModel.shared let m = ChatModel.shared
switch r {
case let .sentConfirmation(_, connection):
return ((.invitation, connection), nil)
case let .sentInvitation(_, connection):
return ((.contact, connection), nil)
case let .contactAlreadyExists(_, contact):
if let c = m.getContactChat(contact.contactId) { if let c = m.getContactChat(contact.contactId) {
await MainActor.run { m.chatId = c.id } await MainActor.run { m.chatId = c.id }
} }
@@ -1232,6 +1235,9 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
try startChat(refreshInvitations: refreshInvitations) try startChat(refreshInvitations: refreshInvitations)
} else { } else {
m.chatRunning = false m.chatRunning = false
try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
m.onboardingStage = onboardingStageDefault.get()
} }
} }
@@ -1248,6 +1254,8 @@ func startChat(refreshInvitations: Bool = true) throws {
try refreshCallInvitations() try refreshCallInvitations()
} }
(m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat
if let token = m.deviceToken { if let token = m.deviceToken {
registerToken(token: token) registerToken(token: token)
} }
@@ -1362,18 +1370,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
let m = ChatModel.shared let m = ChatModel.shared
logger.debug("processReceivedMsg: \(res.responseType)") logger.debug("processReceivedMsg: \(res.responseType)")
switch res { switch res {
case let .newContactConnection(user, connection):
if active(user) {
await MainActor.run {
m.updateContactConnection(connection)
}
}
case let .contactConnectionDeleted(user, connection):
if active(user) {
await MainActor.run {
m.removeChat(connection.id)
}
}
case let .contactDeletedByContact(user, contact): case let .contactDeletedByContact(user, contact):
if active(user) && contact.directOrUsed { if active(user) && contact.directOrUsed {
await MainActor.run { await MainActor.run {
@@ -1666,36 +1662,40 @@ func processReceivedMsg(_ res: ChatResponse) async {
activateCall(invitation) activateCall(invitation)
case let .callOffer(_, contact, callType, offer, sharedKey, _): case let .callOffer(_, contact, callType, offer, sharedKey, _):
await withCall(contact) { call in await withCall(contact) { call in
await MainActor.run {
call.callState = .offerReceived call.callState = .offerReceived
call.peerMedia = callType.media call.peerMedia = callType.media
call.sharedKey = sharedKey call.sharedKey = sharedKey
}
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers() let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)") logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))") logger.debug(".callOffer iceServers \(String(describing: iceServers))")
m.callCommand = .offer( await m.callCommand.processCommand(.offer(
offer: offer.rtcSession, offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates, iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey, media: callType.media, aesKey: sharedKey,
iceServers: iceServers, iceServers: iceServers,
relay: useRelay relay: useRelay
) ))
} }
case let .callAnswer(_, contact, answer): case let .callAnswer(_, contact, answer):
await withCall(contact) { call in await withCall(contact) { call in
await MainActor.run {
call.callState = .answerReceived call.callState = .answerReceived
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) }
await m.callCommand.processCommand(.answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates))
} }
case let .callExtraInfo(_, contact, extraInfo): case let .callExtraInfo(_, contact, extraInfo):
await withCall(contact) { _ in await withCall(contact) { _ in
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates))
} }
case let .callEnded(_, contact): case let .callEnded(_, contact):
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) { if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
CallController.shared.reportCallRemoteEnded(invitation: invitation) CallController.shared.reportCallRemoteEnded(invitation: invitation)
} }
await withCall(contact) { call in await withCall(contact) { call in
m.callCommand = .end await m.callCommand.processCommand(.end)
CallController.shared.reportCallRemoteEnded(call: call) CallController.shared.reportCallRemoteEnded(call: call)
} }
case .chatSuspended: case .chatSuspended:
@@ -1742,7 +1742,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
// This delay is needed to cancel the session that fails on network failure, // This delay is needed to cancel the session that fails on network failure,
// e.g. when user did not grant permission to access local network yet. // e.g. when user did not grant permission to access local network yet.
if let sess = m.remoteCtrlSession { if let sess = m.remoteCtrlSession {
await MainActor.run {
m.remoteCtrlSession = nil m.remoteCtrlSession = nil
}
if case .connected = sess.sessionState { if case .connected = sess.sessionState {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
switchToLocalSession() switchToLocalSession()
@@ -1753,9 +1755,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
logger.debug("unsupported event: \(res.responseType)") logger.debug("unsupported event: \(res.responseType)")
} }
func withCall(_ contact: Contact, _ perform: (Call) -> Void) async { func withCall(_ contact: Contact, _ perform: (Call) async -> Void) async {
if let call = m.activeCall, call.contact.apiId == contact.apiId { if let call = m.activeCall, call.contact.apiId == contact.apiId {
await MainActor.run { perform(call) } await perform(call)
} else { } else {
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
} }
+96 -23
View File
@@ -9,27 +9,30 @@
import Foundation import Foundation
import UIKit import UIKit
import SimpleXChat import SimpleXChat
import SwiftUI
private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock") private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock")
let appSuspendTimeout: Int = 15 // seconds
let bgSuspendTimeout: Int = 5 // seconds let bgSuspendTimeout: Int = 5 // seconds
let terminationTimeout: Int = 3 // seconds let terminationTimeout: Int = 3 // seconds
let activationDelay: TimeInterval = 1.5
let nseSuspendTimeout: TimeInterval = 5
private func _suspendChat(timeout: Int) { private func _suspendChat(timeout: Int) {
// this is a redundant check to prevent logical errors, like the one fixed in this PR // this is a redundant check to prevent logical errors, like the one fixed in this PR
let state = appStateGroupDefault.get() let state = AppChatState.shared.value
if !state.canSuspend { if !state.canSuspend {
logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)") logger.error("_suspendChat called, current state: \(state.rawValue)")
} else if ChatModel.ok { } else if ChatModel.ok {
appStateGroupDefault.set(.suspending) AppChatState.shared.set(.suspending)
apiSuspendChat(timeoutMicroseconds: timeout * 1000000) apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
let endTask = beginBGTask(chatSuspended) let endTask = beginBGTask(chatSuspended)
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask) DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
} else { } else {
appStateGroupDefault.set(.suspended) AppChatState.shared.set(.suspended)
} }
} }
@@ -41,18 +44,16 @@ func suspendChat() {
func suspendBgRefresh() { func suspendBgRefresh() {
suspendLockQueue.sync { suspendLockQueue.sync {
if case .bgRefresh = appStateGroupDefault.get() { if case .bgRefresh = AppChatState.shared.value {
_suspendChat(timeout: bgSuspendTimeout) _suspendChat(timeout: bgSuspendTimeout)
} }
} }
} }
private var terminating = false
func terminateChat() { func terminateChat() {
logger.debug("terminateChat") logger.debug("terminateChat")
suspendLockQueue.sync { suspendLockQueue.sync {
switch appStateGroupDefault.get() { switch AppChatState.shared.value {
case .suspending: case .suspending:
// suspend instantly if already suspending // suspend instantly if already suspending
_chatSuspended() _chatSuspended()
@@ -64,7 +65,6 @@ func terminateChat() {
case .stopped: case .stopped:
chatCloseStore() chatCloseStore()
default: default:
terminating = true
// the store will be closed in _chatSuspended when event is received // the store will be closed in _chatSuspended when event is received
_suspendChat(timeout: terminationTimeout) _suspendChat(timeout: terminationTimeout)
} }
@@ -73,7 +73,7 @@ func terminateChat() {
func chatSuspended() { func chatSuspended() {
suspendLockQueue.sync { suspendLockQueue.sync {
if case .suspending = appStateGroupDefault.get() { if case .suspending = AppChatState.shared.value {
_chatSuspended() _chatSuspended()
} }
} }
@@ -81,48 +81,121 @@ func chatSuspended() {
private func _chatSuspended() { private func _chatSuspended() {
logger.debug("_chatSuspended") logger.debug("_chatSuspended")
appStateGroupDefault.set(.suspended) AppChatState.shared.set(.suspended)
if ChatModel.shared.chatRunning == true { if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.stop() ChatReceiver.shared.stop()
} }
if terminating {
chatCloseStore() chatCloseStore()
}
func setAppState(_ appState: AppState) {
suspendLockQueue.sync {
AppChatState.shared.set(appState)
} }
} }
func activateChat(appState: AppState = .active) { func activateChat(appState: AppState = .active) {
logger.debug("DEBUGGING: activateChat") logger.debug("DEBUGGING: activateChat")
terminating = false
suspendLockQueue.sync { suspendLockQueue.sync {
appStateGroupDefault.set(appState) AppChatState.shared.set(appState)
if ChatModel.ok { apiActivateChat() } if ChatModel.ok { apiActivateChat() }
logger.debug("DEBUGGING: activateChat: after apiActivateChat") logger.debug("DEBUGGING: activateChat: after apiActivateChat")
} }
} }
func initChatAndMigrate(refreshInvitations: Bool = true) { func initChatAndMigrate(refreshInvitations: Bool = true) {
terminating = false
let m = ChatModel.shared let m = ChatModel.shared
if (!m.chatInitialized) { if (!m.chatInitialized) {
do {
m.v3DBMigration = v3DBMigrationDefault.get() m.v3DBMigration = v3DBMigrationDefault.get()
try initializeChat(start: m.v3DBMigration.startChat, refreshInvitations: refreshInvitations) if AppChatState.shared.value == .stopped {
AlertManager.shared.showAlert(Alert(
title: Text("Start chat?"),
message: Text("Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat."),
primaryButton: .default(Text("Ok")) {
AppChatState.shared.set(.active)
initialize(start: true)
},
secondaryButton: .cancel {
initialize(start: false)
}
))
} else {
initialize(start: true)
}
}
func initialize(start: Bool) {
do {
try initializeChat(start: m.v3DBMigration.startChat && start, refreshInvitations: refreshInvitations)
} catch let error { } catch let error {
fatalError("Failed to start or load chats: \(responseError(error))") AlertManager.shared.showAlertMsg(
title: start ? "Error starting chat" : "Error opening chat",
message: "Please contact developers.\nError: \(responseError(error))"
)
} }
} }
} }
func startChatAndActivate() { func startChatForCall() {
terminating = false logger.debug("DEBUGGING: startChatForCall")
if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.start()
logger.debug("DEBUGGING: startChatForCall: after ChatReceiver.shared.start")
}
if .active != AppChatState.shared.value {
logger.debug("DEBUGGING: startChatForCall: before activateChat")
activateChat()
logger.debug("DEBUGGING: startChatForCall: after activateChat")
}
}
func startChatAndActivate(_ completion: @escaping () -> Void) {
logger.debug("DEBUGGING: startChatAndActivate") logger.debug("DEBUGGING: startChatAndActivate")
if ChatModel.shared.chatRunning == true { if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.start() ChatReceiver.shared.start()
logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start") logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start")
} }
if .active != appStateGroupDefault.get() { if case .active = AppChatState.shared.value {
completion()
} else if nseStateGroupDefault.get().inactive {
activate()
} else {
// setting app state to "activating" to notify NSE that it should suspend
setAppState(.activating)
waitNSESuspended(timeout: nseSuspendTimeout) { ok in
if !ok {
// if for some reason NSE failed to suspend,
// e.g., it crashed previously without setting its state to "suspended",
// set it to "suspended" state anyway, so that next time app
// does not have to wait when activating.
nseStateGroupDefault.set(.suspended)
}
if AppChatState.shared.value == .activating {
activate()
}
}
}
func activate() {
logger.debug("DEBUGGING: startChatAndActivate: before activateChat") logger.debug("DEBUGGING: startChatAndActivate: before activateChat")
activateChat() activateChat()
completion()
logger.debug("DEBUGGING: startChatAndActivate: after activateChat") logger.debug("DEBUGGING: startChatAndActivate: after activateChat")
} }
} }
// appStateGroupDefault must not be used in the app directly, only via this singleton
class AppChatState {
static let shared = AppChatState()
private var value_ = appStateGroupDefault.get()
var value: AppState {
value_
}
func set(_ state: AppState) {
appStateGroupDefault.set(state)
sendAppState(state)
value_ = state
}
}
+26 -39
View File
@@ -16,14 +16,9 @@ struct SimpleXApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var chatModel = ChatModel.shared @StateObject private var chatModel = ChatModel.shared
@ObservedObject var alertManager = AlertManager.shared @ObservedObject var alertManager = AlertManager.shared
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @State private var enteredBackgroundAuthenticated: TimeInterval? = nil
@State private var userAuthorized: Bool?
@State private var doAuthenticate = false
@State private var enteredBackground: TimeInterval? = nil
@State private var canConnectCall = false
@State private var lastSuccessfulUnlock: TimeInterval? = nil
@State private var showInitializationView = false
init() { init() {
DispatchQueue.global(qos: .background).sync { DispatchQueue.global(qos: .background).sync {
@@ -39,22 +34,17 @@ struct SimpleXApp: App {
} }
var body: some Scene { var body: some Scene {
return WindowGroup { WindowGroup {
ContentView( // contentAccessAuthenticationExtended has to be passed to ContentView on view initialization,
doAuthenticate: $doAuthenticate, // so that it's computed by the time view renders, and not on event after rendering
userAuthorized: $userAuthorized, ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
canConnectCall: $canConnectCall,
lastSuccessfulUnlock: $lastSuccessfulUnlock,
showInitializationView: $showInitializationView
)
.environmentObject(chatModel) .environmentObject(chatModel)
.onOpenURL { url in .onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)") logger.debug("ContentView.onOpenURL: \(url)")
chatModel.appOpenUrl = url chatModel.appOpenUrl = url
} }
.onAppear() { .onAppear() {
showInitializationView = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
initChatAndMigrate() initChatAndMigrate()
} }
} }
@@ -62,30 +52,35 @@ struct SimpleXApp: App {
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
switch (phase) { switch (phase) {
case .background: case .background:
// --- authentication
// see ContentView .onChange(of: scenePhase) for remaining authentication logic
if chatModel.contentViewAccessAuthenticated {
enteredBackgroundAuthenticated = ProcessInfo.processInfo.systemUptime
}
chatModel.contentViewAccessAuthenticated = false
// authentication ---
if CallController.useCallKit() && chatModel.activeCall != nil { if CallController.useCallKit() && chatModel.activeCall != nil {
CallController.shared.shouldSuspendChat = true CallController.shared.shouldSuspendChat = true
} else { } else {
suspendChat() suspendChat()
BGManager.shared.schedule() BGManager.shared.schedule()
} }
if userAuthorized == true {
enteredBackground = ProcessInfo.processInfo.systemUptime
}
doAuthenticate = false
canConnectCall = false
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
case .active: case .active:
CallController.shared.shouldSuspendChat = false CallController.shared.shouldSuspendChat = false
let appState = appStateGroupDefault.get() let appState = AppChatState.shared.value
startChatAndActivate()
if appState != .stopped {
startChatAndActivate {
if appState.inactive && chatModel.chatRunning == true { if appState.inactive && chatModel.chatRunning == true {
updateChats() updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
updateCallInvitations() updateCallInvitations()
} }
} }
doAuthenticate = authenticationExpired() }
canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() }
default: default:
break break
} }
@@ -103,12 +98,12 @@ struct SimpleXApp: App {
if legacyDatabase, case .documents = dbContainerGroupDefault.get() { if legacyDatabase, case .documents = dbContainerGroupDefault.get() {
dbContainerGroupDefault.set(.documents) dbContainerGroupDefault.set(.documents)
setMigrationState(.offer) setMigrationState(.offer)
logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath(), privacy: .public)*.db") logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath())*.db")
} else { } else {
dbContainerGroupDefault.set(.group) dbContainerGroupDefault.set(.group)
setMigrationState(.ready) setMigrationState(.ready)
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db") logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath())*.db")
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present") logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not") present")
} }
} }
@@ -118,22 +113,14 @@ struct SimpleXApp: App {
} }
private func authenticationExpired() -> Bool { private func authenticationExpired() -> Bool {
if let enteredBackground = enteredBackground { if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated {
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY)) let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay
} else { } else {
return true return true
} }
} }
private func unlockedRecently() -> Bool {
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
} else {
return false
}
}
private func updateChats() { private func updateChats() {
do { do {
let chats = try apiGetChats() let chats = try apiGetChats()
@@ -38,21 +38,21 @@ struct ActiveCallView: View {
} }
} }
.onAppear { .onAppear {
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)") logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
AppDelegate.keepScreenOn(true) AppDelegate.keepScreenOn(true)
createWebRTCClient() createWebRTCClient()
dismissAllSheets() dismissAllSheets()
} }
.onChange(of: canConnectCall) { _ in .onChange(of: canConnectCall) { _ in
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)") logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)")
createWebRTCClient() createWebRTCClient()
} }
.onDisappear { .onDisappear {
logger.debug("ActiveCallView: disappear") logger.debug("ActiveCallView: disappear")
Task { await m.callCommand.setClient(nil) }
AppDelegate.keepScreenOn(false) AppDelegate.keepScreenOn(false)
client?.endCall() client?.endCall()
} }
.onChange(of: m.callCommand) { _ in sendCommandToClient()}
.background(.black) .background(.black)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
} }
@@ -60,19 +60,8 @@ struct ActiveCallView: View {
private func createWebRTCClient() { private func createWebRTCClient() {
if client == nil && canConnectCall { if client == nil && canConnectCall {
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
sendCommandToClient()
}
}
private func sendCommandToClient() {
if call == m.activeCall,
m.activeCall != nil,
let client = client,
let cmd = m.callCommand {
m.callCommand = nil
logger.debug("sendCallCommand: \(cmd.cmdType)")
Task { Task {
await client.sendCallCommand(command: cmd) await m.callCommand.setClient(client)
} }
} }
} }
@@ -168,8 +157,10 @@ struct ActiveCallView: View {
} }
case let .error(message): case let .error(message):
logger.debug("ActiveCallView: command error: \(message)") logger.debug("ActiveCallView: command error: \(message)")
AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message)))
case let .invalid(type): case let .invalid(type):
logger.debug("ActiveCallView: invalid response: \(type)") logger.debug("ActiveCallView: invalid response: \(type)")
AlertManager.shared.showAlert(Alert(title: Text("Invalid response"), message: Text(type)))
} }
} }
} }
@@ -255,7 +246,6 @@ struct ActiveCallOverlay: View {
HStack { HStack {
Text(call.encryptionStatus) Text(call.encryptionStatus)
if let connInfo = call.connectionInfo { if let connInfo = call.connectionInfo {
// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))")
Text("(") + Text(connInfo.text) + Text(")") Text("(") + Text(connInfo.text) + Text(")")
} }
} }
+28 -15
View File
@@ -130,7 +130,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
// The delay allows to accept the second call before suspending a chat // The delay allows to accept the second call before suspending a chat
// see `.onChange(of: scenePhase)` in SimpleXApp // see `.onChange(of: scenePhase)` in SimpleXApp
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat), privacy: .public)") logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat))")
if ChatModel.shared.activeCall == nil && self?.shouldSuspendChat == true { if ChatModel.shared.activeCall == nil && self?.shouldSuspendChat == true {
self?.shouldSuspendChat = false self?.shouldSuspendChat = false
suspendChat() suspendChat()
@@ -142,33 +142,46 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
@objc(pushRegistry:didUpdatePushCredentials:forType:) @objc(pushRegistry:didUpdatePushCredentials:forType:)
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue, privacy: .public)") logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
} }
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
logger.debug("CallController: did receive push with type \(type.rawValue, privacy: .public)") logger.debug("CallController: did receive push with type \(type.rawValue)")
if type != .voIP { if type != .voIP {
completion() completion()
return return
} }
logger.debug("CallController: initializing chat") if AppChatState.shared.value == .stopped {
if (!ChatModel.shared.chatInitialized) { self.reportExpiredCall(payload: payload, completion)
initChatAndMigrate(refreshInvitations: false) return
} }
startChatAndActivate() if (!ChatModel.shared.chatInitialized) {
shouldSuspendChat = true logger.debug("CallController: initializing chat")
do {
try initializeChat(start: true, refreshInvitations: false)
} catch let error {
logger.error("CallController: initializing chat error: \(error)")
self.reportExpiredCall(payload: payload, completion)
return
}
}
logger.debug("CallController: initialized chat")
startChatForCall()
logger.debug("CallController: started chat")
self.shouldSuspendChat = true
// There are no invitations in the model, as it was processed by NSE // There are no invitations in the model, as it was processed by NSE
_ = try? justRefreshCallInvitations() _ = try? justRefreshCallInvitations()
logger.debug("CallController: updated call invitations chat")
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))") // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
// Extract the call information from the push notification payload // Extract the call information from the push notification payload
let m = ChatModel.shared let m = ChatModel.shared
if let contactId = payload.dictionaryPayload["contactId"] as? String, if let contactId = payload.dictionaryPayload["contactId"] as? String,
let invitation = m.callInvitations[contactId] { let invitation = m.callInvitations[contactId] {
let update = cxCallUpdate(invitation: invitation) let update = self.cxCallUpdate(invitation: invitation)
if let uuid = invitation.callkitUUID { if let uuid = invitation.callkitUUID {
logger.debug("CallController: report pushkit call via CallKit") logger.debug("CallController: report pushkit call via CallKit")
let update = cxCallUpdate(invitation: invitation) let update = self.cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update) { error in self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
if error != nil { if error != nil {
m.callInvitations.removeValue(forKey: contactId) m.callInvitations.removeValue(forKey: contactId)
} }
@@ -176,10 +189,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
completion() completion()
} }
} else { } else {
reportExpiredCall(update: update, completion) self.reportExpiredCall(update: update, completion)
} }
} else { } else {
reportExpiredCall(payload: payload, completion) self.reportExpiredCall(payload: payload, completion)
} }
} }
@@ -210,7 +223,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
} }
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)") logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
if CallController.useCallKit(), let uuid = invitation.callkitUUID { if CallController.useCallKit(), let uuid = invitation.callkitUUID {
if invitation.callTs.timeIntervalSinceNow >= -180 { if invitation.callTs.timeIntervalSinceNow >= -180 {
let update = cxCallUpdate(invitation: invitation) let update = cxCallUpdate(invitation: invitation)
@@ -350,7 +363,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) { private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) {
controller.request(CXTransaction(action: action)) { error in controller.request(CXTransaction(action: action)) { error in
if let error = error { if let error = error {
logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription, privacy: .public)") logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)")
} else { } else {
logger.debug("CallController.requestTransaction requested transaction successfully") logger.debug("CallController.requestTransaction requested transaction successfully")
onSuccess() onSuccess()
+10 -6
View File
@@ -22,7 +22,7 @@ class CallManager {
let m = ChatModel.shared let m = ChatModel.shared
if let call = m.activeCall, call.callkitUUID == callUUID { if let call = m.activeCall, call.callkitUUID == callUUID {
m.showCallView = true m.showCallView = true
m.callCommand = .capabilities(media: call.localMedia) Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
return true return true
} }
return false return false
@@ -57,19 +57,21 @@ class CallManager {
m.activeCall = call m.activeCall = call
m.showCallView = true m.showCallView = true
m.callCommand = .start( Task {
await m.callCommand.processCommand(.start(
media: invitation.callType.media, media: invitation.callType.media,
aesKey: invitation.sharedKey, aesKey: invitation.sharedKey,
iceServers: iceServers, iceServers: iceServers,
relay: useRelay relay: useRelay
) ))
}
} }
} }
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool { func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID { if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
let m = ChatModel.shared let m = ChatModel.shared
m.callCommand = .media(media: media, enable: enable) Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
return true return true
} }
return false return false
@@ -94,11 +96,13 @@ class CallManager {
completed() completed()
} else { } else {
logger.debug("CallManager.endCall: ending call...") logger.debug("CallManager.endCall: ending call...")
m.callCommand = .end Task {
await m.callCommand.processCommand(.end)
await MainActor.run {
m.activeCall = nil m.activeCall = nil
m.showCallView = false m.showCallView = false
completed() completed()
Task { }
do { do {
try await apiEndCall(call.contact) try await apiEndCall(call.contact)
} catch { } catch {
+44 -14
View File
@@ -335,6 +335,50 @@ extension WCallResponse: Encodable {
} }
} }
actor WebRTCCommandProcessor {
private var client: WebRTCClient? = nil
private var commands: [WCallCommand] = []
private var running: Bool = false
func setClient(_ client: WebRTCClient?) async {
logger.debug("WebRTC: setClient, commands count \(self.commands.count)")
self.client = client
if client != nil {
await processAllCommands()
} else {
commands.removeAll()
}
}
func processCommand(_ c: WCallCommand) async {
// logger.debug("WebRTC: process command \(c.cmdType)")
commands.append(c)
if !running && client != nil {
await processAllCommands()
}
}
func processAllCommands() async {
logger.debug("WebRTC: process all commands, commands count \(self.commands.count), client == nil \(self.client == nil)")
if let client = client {
running = true
while let c = commands.first, shouldRunCommand(client, c) {
commands.remove(at: 0)
await client.sendCallCommand(command: c)
logger.debug("WebRTC: processed cmd \(c.cmdType)")
}
running = false
}
}
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
switch c {
case .capabilities, .start, .offer, .end: true
default: client.activeCall.wrappedValue != nil
}
}
}
struct ConnectionState: Codable, Equatable { struct ConnectionState: Codable, Equatable {
var connectionState: String var connectionState: String
var iceConnectionState: String var iceConnectionState: String
@@ -358,26 +402,12 @@ struct ConnectionInfo: Codable, Equatable {
return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)" return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)"
} }
} }
var protocolText: String {
let unknown = NSLocalizedString("unknown", comment: "connection info")
let local = localCandidate?.protocol?.uppercased() ?? unknown
let localRelay = localCandidate?.relayProtocol?.uppercased() ?? unknown
let remote = remoteCandidate?.protocol?.uppercased() ?? unknown
let localText = localRelay == local || localCandidate?.relayProtocol == nil
? local
: "\(local) (\(localRelay))"
return local == remote
? localText
: "\(localText) / \(remote)"
}
} }
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
struct RTCIceCandidate: Codable, Equatable { struct RTCIceCandidate: Codable, Equatable {
var candidateType: RTCIceCandidateType? var candidateType: RTCIceCandidateType?
var `protocol`: String? var `protocol`: String?
var relayProtocol: String?
var sdpMid: String? var sdpMid: String?
var sdpMLineIndex: Int? var sdpMLineIndex: Int?
var candidate: String var candidate: String
+108 -67
View File
@@ -18,10 +18,11 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}() }()
private static let ivTagBytes: Int = 28 private static let ivTagBytes: Int = 28
private static let enableEncryption: Bool = true private static let enableEncryption: Bool = true
private var chat_ctrl = getChatCtrl()
struct Call { struct Call {
var connection: RTCPeerConnection var connection: RTCPeerConnection
var iceCandidates: [RTCIceCandidate] var iceCandidates: IceCandidates
var localMedia: CallMediaType var localMedia: CallMediaType
var localCamera: RTCVideoCapturer? var localCamera: RTCVideoCapturer?
var localVideoSource: RTCVideoSource? var localVideoSource: RTCVideoSource?
@@ -33,10 +34,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
var frameDecryptor: RTCFrameDecryptor? var frameDecryptor: RTCFrameDecryptor?
} }
actor IceCandidates {
private var candidates: [RTCIceCandidate] = []
func getAndClear() async -> [RTCIceCandidate] {
let cs = candidates
candidates = []
return cs
}
func append(_ c: RTCIceCandidate) async {
candidates.append(c)
}
}
private let rtcAudioSession = RTCAudioSession.sharedInstance() private let rtcAudioSession = RTCAudioSession.sharedInstance()
private let audioQueue = DispatchQueue(label: "audio") private let audioQueue = DispatchQueue(label: "audio")
private var sendCallResponse: (WVAPIMessage) async -> Void private var sendCallResponse: (WVAPIMessage) async -> Void
private var activeCall: Binding<Call?> var activeCall: Binding<Call?>
private var localRendererAspectRatio: Binding<CGFloat?> private var localRendererAspectRatio: Binding<CGFloat?>
@available(*, unavailable) @available(*, unavailable)
@@ -60,7 +75,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"), WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
] ]
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ remoteIceCandidates: [RTCIceCandidate], _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay) let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
connection.delegate = self connection.delegate = self
createAudioSender(connection) createAudioSender(connection)
@@ -87,7 +102,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
return Call( return Call(
connection: connection, connection: connection,
iceCandidates: remoteIceCandidates, iceCandidates: IceCandidates(),
localMedia: mediaType, localMedia: mediaType,
localCamera: localCamera, localCamera: localCamera,
localVideoSource: localVideoSource, localVideoSource: localVideoSource,
@@ -144,26 +159,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
logger.debug("starting incoming call - create webrtc session") logger.debug("starting incoming call - create webrtc session")
if activeCall.wrappedValue != nil { endCall() } if activeCall.wrappedValue != nil { endCall() }
let encryption = WebRTCClient.enableEncryption let encryption = WebRTCClient.enableEncryption
let call = initializeCall(iceServers?.toWebRTCIceServers(), [], media, encryption ? aesKey : nil, relay) let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
activeCall.wrappedValue = call activeCall.wrappedValue = call
call.connection.offer { answer in let (offer, error) = await call.connection.offer()
Task { if let offer = offer {
let gotCandidates = await self.waitWithTimeout(10_000, stepMs: 1000, until: { self.activeCall.wrappedValue?.iceCandidates.count ?? 0 > 0 }) resp = .offer(
if gotCandidates { offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
await self.sendCallResponse(.init( iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
corrId: nil,
resp: .offer(
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(self.activeCall.wrappedValue?.iceCandidates ?? [])),
capabilities: CallCapabilities(encryption: encryption) capabilities: CallCapabilities(encryption: encryption)
),
command: command)
) )
self.waitForMoreIceCandidates()
} else { } else {
self.endCall() resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
}
}
} }
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
if activeCall.wrappedValue != nil { if activeCall.wrappedValue != nil {
@@ -172,26 +179,21 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
resp = .error(message: "accept: encryption is not supported") resp = .error(message: "accept: encryption is not supported")
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)), } else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
let call = initializeCall(iceServers?.toWebRTCIceServers(), remoteIceCandidates, media, WebRTCClient.enableEncryption ? aesKey : nil, relay) let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
activeCall.wrappedValue = call activeCall.wrappedValue = call
let pc = call.connection let pc = call.connection
if let type = offer.type, let sdp = offer.sdp { if let type = offer.type, let sdp = offer.sdp {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil { if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
pc.answer { answer in let (answer, error) = await pc.answer()
if let answer = answer {
self.addIceCandidates(pc, remoteIceCandidates) self.addIceCandidates(pc, remoteIceCandidates)
// Task { resp = .answer(
// try? await Task.sleep(nanoseconds: 32_000 * 1000000)
Task {
await self.sendCallResponse(.init(
corrId: nil,
resp: .answer(
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))), answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(call.iceCandidates)) iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates()))
),
command: command)
) )
} self.waitForMoreIceCandidates()
// } } else {
resp = .error(message: "answer error: \(error?.localizedDescription ?? "unknown error")")
} }
} else { } else {
resp = .error(message: "accept: remote description is not set") resp = .error(message: "accept: remote description is not set")
@@ -234,6 +236,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
resp = .ok resp = .ok
} }
case .end: case .end:
// TODO possibly, endCall should be called before returning .ok
await sendCallResponse(.init(corrId: nil, resp: .ok, command: command)) await sendCallResponse(.init(corrId: nil, resp: .ok, command: command))
endCall() endCall()
} }
@@ -242,6 +245,33 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
} }
func getInitialIceCandidates() async -> [RTCIceCandidate] {
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
return candidates
}
func waitForMoreIceCandidates() {
Task {
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
if candidates.count > 0 {
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
await self.sendIceCandidates(candidates)
}
}
}
}
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
await self.sendCallResponse(.init(
corrId: nil,
resp: .ice(iceCandidates: compressToBase64(input: encodeJSON(candidates))),
command: nil)
)
}
func enableMedia(_ media: CallMediaType, _ enable: Bool) { func enableMedia(_ media: CallMediaType, _ enable: Bool) {
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)") logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable) media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
@@ -279,7 +309,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count) memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
let isKeyFrame = unencrypted[0] & 1 == 0 let isKeyFrame = unencrypted[0] & 1 == 0
let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3 let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3
logCrypto("encrypt", chat_encrypt_media(&key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize))) logCrypto("encrypt", chat_encrypt_media(chat_ctrl, &key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize)))
return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes) return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes)
} else { } else {
return nil return nil
@@ -387,12 +417,13 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
audioSessionToDefaults() audioSessionToDefaults()
} }
func waitWithTimeout(_ timeoutMs: UInt64, stepMs: UInt64, until success: () -> Bool) async -> Bool { func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
let startedAt = DispatchTime.now() var t: UInt64 = 0
while !success() && startedAt.uptimeNanoseconds + timeoutMs * 1000000 > DispatchTime.now().uptimeNanoseconds { repeat {
guard let _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) else { break } _ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
} t += stepMs
return success() await action()
} while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
} }
} }
@@ -405,25 +436,33 @@ extension WebRTC.RTCPeerConnection {
optionalConstraints: nil) optionalConstraints: nil)
} }
func offer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { func offer() async -> (RTCSessionDescription?, Error?) {
await withCheckedContinuation { cont in
offer(for: mediaConstraints()) { (sdp, error) in offer(for: mediaConstraints()) { (sdp, error) in
guard let sdp = sdp else { self.processSDP(cont, sdp, error)
return
} }
self.setLocalDescription(sdp, completionHandler: { (error) in
completion(sdp)
})
} }
} }
func answer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { func answer() async -> (RTCSessionDescription?, Error?) {
await withCheckedContinuation { cont in
answer(for: mediaConstraints()) { (sdp, error) in answer(for: mediaConstraints()) { (sdp, error) in
guard let sdp = sdp else { self.processSDP(cont, sdp, error)
return
} }
}
}
private func processSDP(_ cont: CheckedContinuation<(RTCSessionDescription?, Error?), Never>, _ sdp: RTCSessionDescription?, _ error: Error?) {
if let sdp = sdp {
self.setLocalDescription(sdp, completionHandler: { (error) in self.setLocalDescription(sdp, completionHandler: { (error) in
completion(sdp) if let error = error {
cont.resume(returning: (nil, error))
} else {
cont.resume(returning: (sdp, nil))
}
}) })
} else {
cont.resume(returning: (nil, error))
} }
} }
} }
@@ -479,6 +518,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
default: enableSpeaker = false default: enableSpeaker = false
} }
setSpeakerEnabledAndConfigureSession(enableSpeaker) setSpeakerEnabledAndConfigureSession(enableSpeaker)
case .connected: sendConnectedEvent(connection)
case .disconnected, .failed: endCall() case .disconnected, .failed: endCall()
default: do {} default: do {}
} }
@@ -491,7 +531,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) { func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
// logger.debug("Connection generated candidate \(candidate.debugDescription)") // logger.debug("Connection generated candidate \(candidate.debugDescription)")
activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil, nil)) Task {
await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
}
} }
func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) { func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) {
@@ -506,10 +548,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
lastReceivedMs lastDataReceivedMs: Int32, lastReceivedMs lastDataReceivedMs: Int32,
changeReason reason: String) { changeReason reason: String) {
// logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)") // logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)")
sendConnectedEvent(connection, local: local, remote: remote)
} }
func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection, local: WebRTC.RTCIceCandidate, remote: WebRTC.RTCIceCandidate) { func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection) {
connection.statistics { (stats: RTCStatisticsReport) in connection.statistics { (stats: RTCStatisticsReport) in
stats.statistics.values.forEach { stat in stats.statistics.values.forEach { stat in
// logger.debug("Stat \(stat.debugDescription)") // logger.debug("Stat \(stat.debugDescription)")
@@ -517,24 +558,25 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
let localId = stat.values["localCandidateId"] as? String, let localId = stat.values["localCandidateId"] as? String,
let remoteId = stat.values["remoteCandidateId"] as? String, let remoteId = stat.values["remoteCandidateId"] as? String,
let localStats = stats.statistics[localId], let localStats = stats.statistics[localId],
let remoteStats = stats.statistics[remoteId], let remoteStats = stats.statistics[remoteId]
local.sdp.contains("\((localStats.values["ip"] as? String ?? "--")) \((localStats.values["port"] as? String ?? "--"))") &&
remote.sdp.contains("\((remoteStats.values["ip"] as? String ?? "--")) \((remoteStats.values["port"] as? String ?? "--"))")
{ {
Task { Task {
await self.sendCallResponse(.init( await self.sendCallResponse(.init(
corrId: nil, corrId: nil,
resp: .connected(connectionInfo: ConnectionInfo( resp: .connected(connectionInfo: ConnectionInfo(
localCandidate: local.toCandidate( localCandidate: RTCIceCandidate(
RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String), candidateType: RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
localStats.values["protocol"] as? String, protocol: localStats.values["protocol"] as? String,
localStats.values["relayProtocol"] as? String sdpMid: nil,
sdpMLineIndex: nil,
candidate: ""
), ),
remoteCandidate: remote.toCandidate( remoteCandidate: RTCIceCandidate(
RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String), candidateType: RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
remoteStats.values["protocol"] as? String, protocol: remoteStats.values["protocol"] as? String,
remoteStats.values["relayProtocol"] as? String sdpMid: nil,
))), sdpMLineIndex: nil,
candidate: ""))),
command: nil) command: nil)
) )
} }
@@ -634,11 +676,10 @@ extension RTCIceCandidate {
} }
extension WebRTC.RTCIceCandidate { extension WebRTC.RTCIceCandidate {
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?, _ relayProtocol: String?) -> RTCIceCandidate { func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?) -> RTCIceCandidate {
RTCIceCandidate( RTCIceCandidate(
candidateType: candidateType, candidateType: candidateType,
protocol: `protocol`, protocol: `protocol`,
relayProtocol: relayProtocol,
sdpMid: sdpMid, sdpMid: sdpMid,
sdpMLineIndex: Int(sdpMLineIndex), sdpMLineIndex: Int(sdpMLineIndex),
candidate: sdp candidate: sdp
+6 -1
View File
@@ -723,9 +723,14 @@ struct ChatView: View {
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live { if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
menu.append(replyUIAction(ci)) menu.append(replyUIAction(ci))
} }
let fileSource = getLoadedFileSource(ci.file)
let fileExists = if let fs = fileSource, FileManager.default.fileExists(atPath: getAppFilePath(fs.filePath).path) { true } else { false }
let copyAndShareAllowed = !ci.content.text.isEmpty || (ci.content.msgContent?.isImage == true && fileExists)
if copyAndShareAllowed {
menu.append(shareUIAction(ci)) menu.append(shareUIAction(ci))
menu.append(copyUIAction(ci)) menu.append(copyUIAction(ci))
if let fileSource = getLoadedFileSource(ci.file) { }
if let fileSource = fileSource, fileExists {
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
if image.imageData != nil { if image.imageData != nil {
menu.append(saveFileAction(fileSource)) menu.append(saveFileAction(fileSource))
@@ -104,7 +104,7 @@ struct ComposeState {
var sendEnabled: Bool { var sendEnabled: Bool {
switch preview { switch preview {
case .mediaPreviews: return true case let .mediaPreviews(media): return !media.isEmpty
case .voicePreview: return voiceMessageRecordingState == .finished case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true case .filePreview: return true
default: return !message.isEmpty || liveMessage != nil default: return !message.isEmpty || liveMessage != nil
@@ -384,10 +384,10 @@ struct ComposeView: View {
} }
} }
.sheet(isPresented: $showMediaPicker) { .sheet(isPresented: $showMediaPicker) {
LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in LibraryMediaListPicker(addMedia: addMediaContent, selectionLimit: 10, finishedPreprocessing: finishedPreprocessingMediaContent) { itemsSelected in
await MainActor.run {
showMediaPicker = false showMediaPicker = false
if itemsSelected { if itemsSelected {
DispatchQueue.main.async {
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: [])) composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
} }
} }
@@ -488,6 +488,30 @@ struct ComposeView: View {
} }
} }
private func addMediaContent(_ content: UploadContent) async {
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
var newMedia: [(String, UploadContent?)] = []
if case var .mediaPreviews(media) = composeState.preview {
media.append((img, content))
newMedia = media
} else {
newMedia = [(img, content)]
}
await MainActor.run {
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: newMedia))
}
}
}
// When error occurs while converting video, remove media preview
private func finishedPreprocessingMediaContent() {
if case let .mediaPreviews(media) = composeState.preview, media.isEmpty {
DispatchQueue.main.async {
composeState = composeState.copy(preview: .noPreview)
}
}
}
private var maxFileSize: Int64 { private var maxFileSize: Int64 {
getMaxFileSize(.xftp) getMaxFileSize(.xftp)
} }
@@ -16,7 +16,6 @@ struct GroupChatInfoView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
@ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil @State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String? @State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkMemberRole: GroupMemberRole = .member
@@ -188,6 +188,8 @@ struct GroupMemberInfoView: View {
// this condition prevents re-setting picker // this condition prevents re-setting picker
if !justOpened { return } if !justOpened { return }
} }
justOpened = false
DispatchQueue.main.async {
newRole = member.memberRole newRole = member.memberRole
do { do {
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
@@ -198,7 +200,7 @@ struct GroupMemberInfoView: View {
} catch let error { } catch let error {
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))") logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
} }
justOpened = false }
} }
.onChange(of: newRole) { newRole in .onChange(of: newRole) { newRole in
if newRole != member.memberRole { if newRole != member.memberRole {
@@ -103,8 +103,10 @@ struct GroupProfileView: View {
} }
} }
.sheet(isPresented: $showImagePicker) { .sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) { LibraryImagePicker(image: $chosenImage) { _ in
didSelectItem in showImagePicker = false await MainActor.run {
showImagePicker = false
}
} }
} }
.onChange(of: chosenImage) { image in .onChange(of: chosenImage) { image in
@@ -17,7 +17,7 @@ struct ScanCodeView: View {
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
CodeScannerView(codeTypes: [.qr], completion: processQRCode) CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.cornerRadius(12) .cornerRadius(12)
Text("Scan security code from your contact's app.") Text("Scan security code from your contact's app.")
@@ -415,7 +415,7 @@ struct DatabaseView: View {
do { do {
try initializeChat(start: true) try initializeChat(start: true)
m.chatDbChanged = false m.chatDbChanged = false
appStateGroupDefault.set(.active) AppChatState.shared.set(.active)
} catch let error { } catch let error {
fatalError("Error starting chat \(responseError(error))") fatalError("Error starting chat \(responseError(error))")
} }
@@ -427,7 +427,7 @@ struct DatabaseView: View {
m.chatRunning = true m.chatRunning = true
ChatReceiver.shared.start() ChatReceiver.shared.start()
chatLastStartGroupDefault.set(Date.now) chatLastStartGroupDefault.set(Date.now)
appStateGroupDefault.set(.active) AppChatState.shared.set(.active)
} catch let error { } catch let error {
runChat = false runChat = false
alert = .error(title: "Error starting chat", error: responseError(error)) alert = .error(title: "Error starting chat", error: responseError(error))
@@ -477,7 +477,7 @@ func stopChatAsync() async throws {
try await apiStopChat() try await apiStopChat()
ChatReceiver.shared.stop() ChatReceiver.shared.stop()
await MainActor.run { ChatModel.shared.chatRunning = false } await MainActor.run { ChatModel.shared.chatRunning = false }
appStateGroupDefault.set(.stopped) AppChatState.shared.set(.stopped)
} }
func deleteChatAsync() async throws { func deleteChatAsync() async throws {
+83 -65
View File
@@ -13,112 +13,130 @@ import SimpleXChat
struct LibraryImagePicker: View { struct LibraryImagePicker: View {
@Binding var image: UIImage? @Binding var image: UIImage?
var didFinishPicking: (_ didSelectItems: Bool) -> Void var didFinishPicking: (_ didSelectImage: Bool) async -> Void
@State var images: [UploadContent] = [] @State var mediaAdded = false
var body: some View { var body: some View {
LibraryMediaListPicker(media: $images, selectionLimit: 1, didFinishPicking: didFinishPicking) LibraryMediaListPicker(addMedia: addMedia, selectionLimit: 1, didFinishPicking: didFinishPicking)
.onChange(of: images) { _ in
if let img = images.first {
image = img.uiImage
} }
private func addMedia(_ content: UploadContent) async {
if mediaAdded { return }
await MainActor.run {
mediaAdded = true
image = content.uiImage
} }
} }
} }
struct LibraryMediaListPicker: UIViewControllerRepresentable { struct LibraryMediaListPicker: UIViewControllerRepresentable {
typealias UIViewControllerType = PHPickerViewController typealias UIViewControllerType = PHPickerViewController
@Binding var media: [UploadContent] var addMedia: (_ content: UploadContent) async -> Void
var selectionLimit: Int var selectionLimit: Int
var didFinishPicking: (_ didSelectItems: Bool) -> Void var finishedPreprocessing: () -> Void = {}
var didFinishPicking: (_ didSelectItems: Bool) async -> Void
class Coordinator: PHPickerViewControllerDelegate { class Coordinator: PHPickerViewControllerDelegate {
let parent: LibraryMediaListPicker let parent: LibraryMediaListPicker
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker") let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker")
var media: [UploadContent] = []
var mediaCount: Int = 0
init(_ parent: LibraryMediaListPicker) { init(_ parent: LibraryMediaListPicker) {
self.parent = parent self.parent = parent
} }
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
parent.didFinishPicking(!results.isEmpty) Task {
guard !results.isEmpty else { await parent.didFinishPicking(!results.isEmpty)
return if results.isEmpty { return }
for r in results {
await loadItem(r.itemProvider)
}
parent.finishedPreprocessing()
}
} }
parent.media = [] private func loadItem(_ p: NSItemProvider) async {
media = [] logger.debug("LibraryMediaListPicker result")
mediaCount = results.count
for result in results {
logger.log("LibraryMediaListPicker result")
let p = result.itemProvider
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in if let video = await loadVideo(p) {
if let url = url { await self.parent.addMedia(video)
let tempUrl = URL(fileURLWithPath: getTempFilesDirectory().path + "/" + generateNewFileName("video", url.pathExtension)) logger.debug("LibraryMediaListPicker: added video")
if ((try? FileManager.default.copyItem(at: url, to: tempUrl)) != nil) {
ChatModel.shared.filesToDelete.insert(tempUrl)
self.loadVideo(url: tempUrl, error: error)
}
}
} }
} else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) { } else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in if let img = await loadImageData(p) {
self.loadImage(object: url, error: error) await self.parent.addMedia(img)
logger.debug("LibraryMediaListPicker: added image")
} }
} else if p.canLoadObject(ofClass: UIImage.self) { } else if p.canLoadObject(ofClass: UIImage.self) {
p.loadObject(ofClass: UIImage.self) { image, error in if let img = await loadImage(p) {
DispatchQueue.main.async { await self.parent.addMedia(.simpleImage(image: img))
self.loadImage(object: image, error: error) logger.debug("LibraryMediaListPicker: added image")
} }
} }
}
private func loadImageData(_ p: NSItemProvider) async -> UploadContent? {
await withCheckedContinuation { cont in
loadFileURL(p, type: UTType.data) { url in
if let url = url {
let img = UploadContent.loadFromURL(url: url)
cont.resume(returning: img)
} else { } else {
dispatchQueue.sync { self.mediaCount -= 1} cont.resume(returning: nil)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
self.dispatchQueue.sync {
if self.parent.media.count == 0 {
logger.log("LibraryMediaListPicker: added \(self.media.count) images out of \(results.count)")
self.parent.media = self.media
} }
} }
} }
} }
func loadImage(object: Any?, error: Error? = nil) { private func loadImage(_ p: NSItemProvider) async -> UIImage? {
if let error = error { await withCheckedContinuation { cont in
logger.error("LibraryMediaListPicker: couldn't load image with error: \(error.localizedDescription)") p.loadObject(ofClass: UIImage.self) { obj, err in
} else if let image = object as? UIImage { if let err = err {
media.append(.simpleImage(image: image)) logger.error("LibraryMediaListPicker result image error: \(err.localizedDescription)")
logger.log("LibraryMediaListPicker: added image") cont.resume(returning: nil)
} else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) { } else {
media.append(image) cont.resume(returning: obj as? UIImage)
} }
dispatchQueue.sync {
self.mediaCount -= 1
if self.mediaCount == 0 && self.parent.media.count == 0 {
logger.log("LibraryMediaListPicker: added all media")
self.parent.media = self.media
self.media = []
} }
} }
} }
func loadVideo(url: URL?, error: Error? = nil) { private func loadVideo(_ p: NSItemProvider) async -> UploadContent? {
if let error = error { await withCheckedContinuation { cont in
logger.error("LibraryMediaListPicker: couldn't load video with error: \(error.localizedDescription)") loadFileURL(p, type: UTType.movie) { url in
} else if let url = url as URL?, let video = UploadContent.loadVideoFromURL(url: url) { if let url = url {
media.append(video) let tempUrl = URL(fileURLWithPath: generateNewFileName(getTempFilesDirectory().path + "/" + "rawvideo", url.pathExtension, fullPath: true))
let convertedVideoUrl = URL(fileURLWithPath: generateNewFileName(getTempFilesDirectory().path + "/" + "video", "mp4", fullPath: true))
do {
// logger.debug("LibraryMediaListPicker copyItem \(url) to \(tempUrl)")
try FileManager.default.copyItem(at: url, to: tempUrl)
} catch let err {
logger.error("LibraryMediaListPicker copyItem error: \(err.localizedDescription)")
return cont.resume(returning: nil)
} }
dispatchQueue.sync { Task {
self.mediaCount -= 1 let success = await makeVideoQualityLower(tempUrl, outputUrl: convertedVideoUrl)
if self.mediaCount == 0 && self.parent.media.count == 0 { try? FileManager.default.removeItem(at: tempUrl)
logger.log("LibraryMediaListPicker: added all media") if success {
self.parent.media = self.media _ = ChatModel.shared.filesToDelete.insert(convertedVideoUrl)
self.media = [] let video = UploadContent.loadVideoFromURL(url: convertedVideoUrl)
return cont.resume(returning: video)
}
try? FileManager.default.removeItem(at: convertedVideoUrl)
cont.resume(returning: nil)
}
}
}
}
}
private func loadFileURL(_ p: NSItemProvider, type: UTType, completion: @escaping (URL?) -> Void) {
p.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, err in
if let err = err {
logger.error("LibraryMediaListPicker loadFileURL error: \(err.localizedDescription)")
completion(nil)
} else {
completion(url)
} }
} }
} }
@@ -0,0 +1,26 @@
//
// VideoUtils.swift
// SimpleX (iOS)
//
// Created by Avently on 25.12.2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import AVFoundation
import Foundation
import SimpleXChat
func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
s.outputURL = outputUrl
s.outputFileType = .mp4
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
await s.export()
if let err = s.error {
logger.error("Failed to export video with error: \(err)")
}
return s.status == .completed
}
return false
}
@@ -52,7 +52,7 @@ struct LocalAuthView: View {
resetChatCtrl() resetChatCtrl()
try initializeChat(start: true) try initializeChat(start: true)
m.chatDbChanged = false m.chatDbChanged = false
appStateGroupDefault.set(.active) AppChatState.shared.set(.active)
if m.currentUser != nil { return } if m.currentUser != nil { return }
var profile: Profile? = nil var profile: Profile? = nil
if let displayName = displayName, displayName != "" { if let displayName = displayName, displayName != "" {
@@ -130,8 +130,10 @@ struct AddGroupView: View {
} }
} }
.sheet(isPresented: $showImagePicker) { .sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) { LibraryImagePicker(image: $chosenImage) { _ in
didSelectItem in showImagePicker = false await MainActor.run {
showImagePicker = false
}
} }
} }
.alert(isPresented: $showInvalidNameAlert) { .alert(isPresented: $showInvalidNameAlert) {
@@ -73,6 +73,7 @@ struct CreateLinkView: View {
Task { Task {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
await MainActor.run { await MainActor.run {
m.updateContactConnection(pcc)
connReqInvitation = connReq connReqInvitation = connReq
contactConnection = pcc contactConnection = pcc
m.connReqInv = connReq m.connReqInv = connReq
@@ -52,6 +52,9 @@ struct NewChatButton: View {
func addContactAction() { func addContactAction() {
Task { Task {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
await MainActor.run {
ChatModel.shared.updateContactConnection(pcc)
}
actionSheet = .createLink(link: connReq, connection: pcc) actionSheet = .createLink(link: connReq, connection: pcc)
} }
} }
@@ -346,7 +349,10 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn
private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) { private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) {
Task { Task {
if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) {
await MainActor.run {
ChatModel.shared.updateContactConnection(pcc)
}
let crt: ConnReqType let crt: ConnReqType
if let plan = connectionPlan { if let plan = connectionPlan {
crt = planToConnReqType(plan) crt = planToConnReqType(plan)
+13 -20
View File
@@ -11,20 +11,12 @@ import CoreImage.CIFilterBuiltins
struct MutableQRCode: View { struct MutableQRCode: View {
@Binding var uri: String @Binding var uri: String
@State private var image: UIImage? var withLogo: Bool = true
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
var body: some View { var body: some View {
ZStack { QRCode(uri: uri, withLogo: withLogo, tintColor: tintColor)
if let image = image { .id("simplex-qrcode-view-for-\(uri)")
qrCodeImage(image)
}
}
.onAppear {
image = generateImage(uri)
}
.onChange(of: uri) { _ in
image = generateImage(uri)
}
} }
} }
@@ -49,7 +41,7 @@ struct QRCode: View {
var withLogo: Bool = true var withLogo: Bool = true
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
@State private var image: UIImage? = nil @State private var image: UIImage? = nil
@State private var makeScreenshotBinding: () -> Void = {} @State private var makeScreenshotFunc: () -> Void = {}
var body: some View { var body: some View {
ZStack { ZStack {
@@ -70,18 +62,19 @@ struct QRCode: View {
} }
} }
.onAppear { .onAppear {
makeScreenshotBinding = { makeScreenshotFunc = {
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale) let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])} showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
}
} }
.frame(width: geo.size.width, height: geo.size.height) .frame(width: geo.size.width, height: geo.size.height)
} }
} }
.onTapGesture(perform: makeScreenshotBinding) .onTapGesture(perform: makeScreenshotFunc)
.onAppear { .onAppear {
image = image ?? generateImage(uri)?.replaceColor(UIColor.black, tintColor) image = image ?? generateImage(uri, tintColor: tintColor)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
@@ -93,13 +86,13 @@ private func qrCodeImage(_ image: UIImage) -> some View {
.textSelection(.enabled) .textSelection(.enabled)
} }
private func generateImage(_ uri: String) -> UIImage? { private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? {
let context = CIContext() let context = CIContext()
let filter = CIFilter.qrCodeGenerator() let filter = CIFilter.qrCodeGenerator()
filter.message = Data(uri.utf8) filter.message = Data(uri.utf8)
if let outputImage = filter.outputImage, if let outputImage = filter.outputImage,
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) { let cgImage = context.createCGImage(outputImage, from: outputImage.extent) {
return UIImage(cgImage: cgImage) return UIImage(cgImage: cgImage).replaceColor(UIColor.black, tintColor)
} }
return nil return nil
} }
@@ -25,7 +25,7 @@ struct ScanToConnectView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(.vertical) .padding(.vertical)
CodeScannerView(codeTypes: [.qr], completion: processQRCode) CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.cornerRadius(12) .cornerRadius(12)
@@ -81,11 +81,6 @@ struct CreateSimpleXAddress: View {
DispatchQueue.main.async { DispatchQueue.main.async {
m.userAddress = UserContactLink(connReqContact: connReqContact) m.userAddress = UserContactLink(connReqContact: connReqContact)
} }
if let u = try await apiSetProfileAddress(on: true) {
DispatchQueue.main.async {
m.updateUser(u)
}
}
await MainActor.run { progressIndicator = false } await MainActor.run { progressIndicator = false }
} catch let error { } catch let error {
logger.error("CreateSimpleXAddress create address: \(responseError(error))") logger.error("CreateSimpleXAddress create address: \(responseError(error))")
@@ -100,7 +95,7 @@ struct CreateSimpleXAddress: View {
} label: { } label: {
Text("Create SimpleX address").font(.title) Text("Create SimpleX address").font(.title)
} }
Text("Your contacts in SimpleX will see it.\nYou can change it in Settings.") Text("You can make it visible to your SimpleX contacts via Settings.")
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.font(.footnote) .font(.footnote)
.padding(.horizontal, 32) .padding(.horizontal, 32)
@@ -332,7 +332,7 @@ struct ConnectDesktopView: View {
private func scanDesctopAddressView() -> some View { private func scanDesctopAddressView() -> some View {
Section("Scan QR code from desktop") { Section("Scan QR code from desktop") {
CodeScannerView(codeTypes: [.qr], completion: processDesktopQRCode) CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processDesktopQRCode)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.cornerRadius(12) .cornerRadius(12)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
@@ -51,9 +51,9 @@ struct AdvancedNetworkSettings: View {
} }
.disabled(currentNetCfg == NetCfg.proxyDefaults) .disabled(currentNetCfg == NetCfg.proxyDefaults)
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel) timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 60_000, 90_000, 120_000], label: secondsLabel) timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 45_000, 60_000, 90_000, 120_000], label: secondsLabel)
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel) timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "") intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "")
Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive) Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive)
@@ -14,9 +14,6 @@ struct NotificationsView: View {
@State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode @State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode
@State private var showAlert: NotificationAlert? @State private var showAlert: NotificationAlert?
@State private var legacyDatabase = dbContainerGroupDefault.get() == .documents @State private var legacyDatabase = dbContainerGroupDefault.get() == .documents
// @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_LOCAL, store: groupDefaults) private var ntfEnableLocal = false
// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_PERIODIC, store: groupDefaults) private var ntfEnablePeriodic = false
var body: some View { var body: some View {
List { List {
@@ -88,13 +85,6 @@ struct NotificationsView: View {
.padding(.top, 1) .padding(.top, 1)
} }
} }
// if developerTools {
// Section(String("Experimental")) {
// Toggle(String("Always enable local"), isOn: $ntfEnableLocal)
// Toggle(String("Always enable periodic"), isOn: $ntfEnablePeriodic)
// }
// }
} }
.disabled(legacyDatabase) .disabled(legacyDatabase)
} }
@@ -119,7 +109,7 @@ struct NotificationsView: View {
private func ntfModeAlertTitle(_ mode: NotificationsMode) -> LocalizedStringKey { private func ntfModeAlertTitle(_ mode: NotificationsMode) -> LocalizedStringKey {
switch mode { switch mode {
case .off: return "Turn off notifications?" case .off: return "Use only local notifications?"
case .periodic: return "Enable periodic notifications?" case .periodic: return "Enable periodic notifications?"
case .instant: return "Enable instant notifications?" case .instant: return "Enable instant notifications?"
} }
@@ -467,6 +467,7 @@ struct SimplexLockView: View {
switch a { switch a {
case .enableAuth: case .enableAuth:
SetAppPasscodeView { SetAppPasscodeView {
m.contentViewAccessAuthenticated = true
laLockDelay = 30 laLockDelay = 30
prefPerformLA = true prefPerformLA = true
showChangePassword = true showChangePassword = true
@@ -619,6 +620,7 @@ struct SimplexLockView: View {
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
switch laResult { switch laResult {
case .success: case .success:
m.contentViewAccessAuthenticated = true
prefPerformLA = true prefPerformLA = true
laAlert = .laTurnedOnAlert laAlert = .laTurnedOnAlert
case .failed: case .failed:
@@ -21,7 +21,7 @@ struct ScanProtocolServer: View {
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
.padding(.vertical) .padding(.vertical)
CodeScannerView(codeTypes: [.qr], completion: processQRCode) CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.cornerRadius(12) .cornerRadius(12)
.padding(.top) .padding(.top)
@@ -190,7 +190,8 @@ struct UserAddressView: View {
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
Section { Section {
MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact))) SimpleXLinkQRCode(uri: userAddress.connReqContact)
.id("simplex-contact-address-qrcode-\(userAddress.connReqContact)")
shareQRCodeButton(userAddress) shareQRCodeButton(userAddress)
if MFMailComposeViewController.canSendMail() { if MFMailComposeViewController.canSendMail() {
shareViaEmailButton(userAddress) shareViaEmailButton(userAddress)
@@ -120,8 +120,10 @@ struct UserProfile: View {
} }
} }
.sheet(isPresented: $showImagePicker) { .sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) { LibraryImagePicker(image: $chosenImage) { _ in
didSelectItem in showImagePicker = false await MainActor.run {
showImagePicker = false
}
} }
} }
.onChange(of: chosenImage) { image in .onChange(of: chosenImage) { image in
@@ -0,0 +1,64 @@
//
// ConcurrentQueue.swift
// SimpleX NSE
//
// Created by Evgeny on 08/12/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import Foundation
struct DequeueElement<T> {
var elementId: UUID?
var task: Task<T?, Never>
}
class ConcurrentQueue<T> {
private var queue: [T] = []
private var queueLock = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.concurrent-queue.lock.\(UUID())")
private var continuations = [(elementId: UUID, continuation: CheckedContinuation<T?, Never>)]()
func enqueue(_ el: T) {
resumeContinuation(el) { self.queue.append(el) }
}
func frontEnqueue(_ el: T) {
resumeContinuation(el) { self.queue.insert(el, at: 0) }
}
private func resumeContinuation(_ el: T, add: @escaping () -> Void) {
queueLock.sync {
if let (_, cont) = continuations.first {
continuations.remove(at: 0)
cont.resume(returning: el)
} else {
add()
}
}
}
func dequeue() -> DequeueElement<T> {
queueLock.sync {
if queue.isEmpty {
let elementId = UUID()
let task = Task {
await withCheckedContinuation { cont in
continuations.append((elementId, cont))
}
}
return DequeueElement(elementId: elementId, task: task)
} else {
let el = queue.remove(at: 0)
return DequeueElement(task: Task { el })
}
}
}
func cancelDequeue(_ elementId: UUID) {
queueLock.sync {
let cancelled = continuations.filter { $0.elementId == elementId }
continuations.removeAll { $0.elementId == elementId }
cancelled.forEach { $0.continuation.resume(returning: nil) }
}
}
}
+479 -91
View File
@@ -14,91 +14,233 @@ import SimpleXChat
let logger = Logger() let logger = Logger()
let suspendingDelay: UInt64 = 2_000_000_000 let appSuspendingDelay: UInt64 = 2_500_000_000
typealias NtfStream = AsyncStream<NSENotification> typealias SuspendSchedule = (delay: TimeInterval, timeout: Int)
let nseSuspendSchedule: SuspendSchedule = (2, 4)
let fastNSESuspendSchedule: SuspendSchedule = (1, 1)
typealias NtfStream = ConcurrentQueue<NSENotification>
// Notifications are delivered via concurrent queues, as they are all received from chat controller in a single loop that
// writes to ConcurrentQueue and when notification is processed, the instance of Notification service extension reads from the queue.
// One queue per connection (entity) is used.
// The concurrent queues allow read cancellation, to ensure that notifications are not lost in case the current thread completes
// before expected notification is read (multiple notifications can be expected, because one notification can be delivered for several messages).
actor PendingNtfs { actor PendingNtfs {
static let shared = PendingNtfs() static let shared = PendingNtfs()
private var ntfStreams: [String: NtfStream] = [:] private var ntfStreams: [String: NtfStream] = [:]
private var ntfConts: [String: NtfStream.Continuation] = [:]
func createStream(_ id: String) { func createStream(_ id: String) async {
logger.debug("PendingNtfs.createStream: \(id, privacy: .public)") logger.debug("NotificationService PendingNtfs.createStream: \(id)")
if ntfStreams.index(forKey: id) == nil { if ntfStreams[id] == nil {
ntfStreams[id] = AsyncStream { cont in ntfStreams[id] = ConcurrentQueue()
ntfConts[id] = cont logger.debug("NotificationService PendingNtfs.createStream: created ConcurrentQueue")
logger.debug("PendingNtfs.createStream: store continuation")
}
} }
} }
func readStream(_ id: String, for nse: NotificationService, msgCount: Int = 1, showNotifications: Bool) async { func readStream(_ id: String, for nse: NotificationService, ntfInfo: NtfMessages) async {
logger.debug("PendingNtfs.readStream: \(id, privacy: .public) \(msgCount, privacy: .public)") logger.debug("NotificationService PendingNtfs.readStream: \(id) \(ntfInfo.ntfMessages.count)")
if !ntfInfo.user.showNotifications {
nse.setBestAttemptNtf(.empty)
}
if let s = ntfStreams[id] { if let s = ntfStreams[id] {
logger.debug("PendingNtfs.readStream: has stream") logger.debug("NotificationService PendingNtfs.readStream: has stream")
var rcvCount = max(1, msgCount) var expected = Set(ntfInfo.ntfMessages.map { $0.msgId })
for await ntf in s { logger.debug("NotificationService PendingNtfs.readStream: expecting: \(expected)")
nse.setBestAttemptNtf(showNotifications ? ntf : .empty) var readCancelled = false
rcvCount -= 1 var dequeued: DequeueElement<NSENotification>?
if rcvCount == 0 || ntf.categoryIdentifier == ntfCategoryCallInvitation { break } nse.cancelRead = {
readCancelled = true
if let elementId = dequeued?.elementId {
s.cancelDequeue(elementId)
} }
logger.debug("PendingNtfs.readStream: exiting") }
while !readCancelled {
dequeued = s.dequeue()
if let ntf = await dequeued?.task.value {
if readCancelled {
logger.debug("NotificationService PendingNtfs.readStream: read cancelled, put ntf to queue front")
s.frontEnqueue(ntf)
break
} else if case let .msgInfo(info) = ntf {
let found = expected.remove(info.msgId)
if found != nil {
logger.debug("NotificationService PendingNtfs.readStream: msgInfo, last: \(expected.isEmpty)")
if expected.isEmpty { break }
} else if let msgTs = ntfInfo.msgTs, info.msgTs > msgTs {
logger.debug("NotificationService PendingNtfs.readStream: unexpected msgInfo")
s.frontEnqueue(ntf)
break
}
} else if ntfInfo.user.showNotifications {
logger.debug("NotificationService PendingNtfs.readStream: setting best attempt")
nse.setBestAttemptNtf(ntf)
if ntf.isCallInvitation { break }
}
} else {
break
}
}
nse.cancelRead = nil
logger.debug("NotificationService PendingNtfs.readStream: exiting")
} }
} }
func writeStream(_ id: String, _ ntf: NSENotification) { func writeStream(_ id: String, _ ntf: NSENotification) async {
logger.debug("PendingNtfs.writeStream: \(id, privacy: .public)") logger.debug("NotificationService PendingNtfs.writeStream: \(id)")
if let cont = ntfConts[id] { if let s = ntfStreams[id] {
logger.debug("PendingNtfs.writeStream: writing ntf") logger.debug("NotificationService PendingNtfs.writeStream: writing ntf")
cont.yield(ntf) s.enqueue(ntf)
}
}
}
// The current implementation assumes concurrent notification delivery and uses semaphores
// to process only one notification per connection (entity) at a time.
class NtfStreamSemaphores {
static let shared = NtfStreamSemaphores()
private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-semaphores.lock")
private var semaphores: [String: DispatchSemaphore] = [:]
func waitForStream(_ id: String) {
streamSemaphore(id, value: 0)?.wait()
}
func signalStreamReady(_ id: String) {
streamSemaphore(id, value: 1)?.signal()
}
// this function returns nil if semaphore is just created, so passed value shoud be coordinated with the desired end value of the semaphore
private func streamSemaphore(_ id: String, value: Int) -> DispatchSemaphore? {
NtfStreamSemaphores.queue.sync {
if let s = semaphores[id] {
return s
} else {
semaphores[id] = DispatchSemaphore(value: value)
return nil
}
} }
} }
} }
enum NSENotification { enum NSENotification {
case nse(notification: UNMutableNotificationContent) case nse(UNMutableNotificationContent)
case callkit(invitation: RcvCallInvitation) case callkit(RcvCallInvitation)
case empty case empty
case msgInfo(NtfMsgInfo)
var categoryIdentifier: String? { var isCallInvitation: Bool {
switch self { switch self {
case let .nse(ntf): return ntf.categoryIdentifier case let .nse(ntf): ntf.categoryIdentifier == ntfCategoryCallInvitation
case .callkit: return ntfCategoryCallInvitation case .callkit: true
case .empty: return nil case .empty: false
case .msgInfo: false
} }
} }
} }
// Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid
// background crashes and contention for database with the application (both UI and background fetch triggered either on schedule
// or when background notification is received.
class NSEThreads {
static let shared = NSEThreads()
private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock")
private var allThreads: Set<UUID> = []
private var activeThreads: Set<UUID> = []
func newThread() -> UUID {
NSEThreads.queue.sync {
let (_, t) = allThreads.insert(UUID())
return t
}
}
func startThread(_ t: UUID) {
NSEThreads.queue.sync {
if allThreads.contains(t) {
_ = activeThreads.insert(t)
} else {
logger.warning("NotificationService startThread: thread \(t) was removed before it started")
}
}
}
func endThread(_ t: UUID) -> Bool {
NSEThreads.queue.sync {
let tActive = activeThreads.remove(t)
let t = allThreads.remove(t)
if tActive != nil && activeThreads.isEmpty {
return true
}
if t != nil && allThreads.isEmpty {
NSEChatState.shared.set(.suspended)
}
return false
}
}
var noThreads: Bool {
allThreads.isEmpty
}
}
// Notification service extension creates a new instance of the class and calls didReceive for each notification.
// Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never
// more than one process of notification service extension exists at a time.
// Soon after notification service delivers the last notification it is either suspended or terminated.
class NotificationService: UNNotificationServiceExtension { class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)? var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptNtf: NSENotification? var bestAttemptNtf: NSENotification?
var badgeCount: Int = 0 var badgeCount: Int = 0
// thread is added to allThreads here - if thread did not start chat,
// chat does not need to be suspended but NSE state still needs to be set to "suspended".
var threadId: UUID? = NSEThreads.shared.newThread()
var receiveEntityId: String?
var cancelRead: (() -> Void)?
var appSubscriber: AppSubscriber?
var returnedSuspension = false
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
logger.debug("DEBUGGING: NotificationService.didReceive") logger.debug("DEBUGGING: NotificationService.didReceive")
if let ntf = request.content.mutableCopy() as? UNMutableNotificationContent { let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() }
setBestAttemptNtf(ntf) setBestAttemptNtf(ntf)
}
self.contentHandler = contentHandler self.contentHandler = contentHandler
registerGroupDefaults() registerGroupDefaults()
let appState = appStateGroupDefault.get() let appState = appStateGroupDefault.get()
logger.debug("NotificationService: app is \(appState.rawValue)")
switch appState { switch appState {
case .stopped:
setBadgeCount()
setBestAttemptNtf(createAppStoppedNtf())
deliverBestAttemptNtf()
case .suspended: case .suspended:
logger.debug("NotificationService: app is suspended")
setBadgeCount() setBadgeCount()
receiveNtfMessages(request, contentHandler) receiveNtfMessages(request, contentHandler)
case .suspending: case .suspending:
logger.debug("NotificationService: app is suspending")
setBadgeCount() setBadgeCount()
Task { Task {
var state = appState let state: AppState = await withCheckedContinuation { cont in
for _ in 1...5 { appSubscriber = appStateSubscriber { s in
_ = try await Task.sleep(nanoseconds: suspendingDelay) if s == .suspended { appSuspension(s) }
state = appStateGroupDefault.get()
if state == .suspended || state != .suspending { break }
} }
logger.debug("NotificationService: app state is \(state.rawValue, privacy: .public)") DispatchQueue.global().asyncAfter(deadline: .now() + Double(appSuspendTimeout) + 1) {
logger.debug("NotificationService: appSuspension timeout")
appSuspension(appStateGroupDefault.get())
}
@Sendable
func appSuspension(_ s: AppState) {
if !self.returnedSuspension {
self.returnedSuspension = true
self.appSubscriber = nil // this disposes of appStateSubscriber
cont.resume(returning: s)
}
}
}
logger.debug("NotificationService: app state is now \(state.rawValue)")
if state.inactive { if state.inactive {
receiveNtfMessages(request, contentHandler) receiveNtfMessages(request, contentHandler)
} else { } else {
@@ -106,7 +248,6 @@ class NotificationService: UNNotificationServiceExtension {
} }
} }
default: default:
logger.debug("NotificationService: app state is \(appState.rawValue, privacy: .public)")
deliverBestAttemptNtf() deliverBestAttemptNtf()
} }
} }
@@ -121,27 +262,35 @@ class NotificationService: UNNotificationServiceExtension {
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
let nonce = ntfData["nonce"] as? String, let nonce = ntfData["nonce"] as? String,
let encNtfInfo = ntfData["message"] as? String, let encNtfInfo = ntfData["message"] as? String,
let dbStatus = startChat() { // check it here again
appStateGroupDefault.get().inactive {
// thread is added to activeThreads tracking set here - if thread started chat it needs to be suspended
if let t = threadId { NSEThreads.shared.startThread(t) }
let dbStatus = startChat()
if case .ok = dbStatus, if case .ok = dbStatus,
let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo.ntfMessages.count))")
if let connEntity = ntfMsgInfo.connEntity { if let connEntity = ntfInfo.connEntity_ {
setBestAttemptNtf( setBestAttemptNtf(
ntfMsgInfo.ntfsEnabled ntfInfo.ntfsEnabled
? .nse(notification: createConnectionEventNtf(ntfMsgInfo.user, connEntity)) ? .nse(createConnectionEventNtf(ntfInfo.user, connEntity))
: .empty : .empty
) )
if let id = connEntity.id { if let id = connEntity.id {
receiveEntityId = id
NtfStreamSemaphores.shared.waitForStream(id)
if receiveEntityId != nil {
Task { Task {
logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id)")
await PendingNtfs.shared.createStream(id) await PendingNtfs.shared.createStream(id)
await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count, showNotifications: ntfMsgInfo.user.showNotifications) await PendingNtfs.shared.readStream(id, for: self, ntfInfo: ntfInfo)
deliverBestAttemptNtf() deliverBestAttemptNtf()
} }
} }
}
return return
} else { }
}
} else if let dbStatus = dbStatus {
setBestAttemptNtf(createErrorNtf(dbStatus)) setBestAttemptNtf(createErrorNtf(dbStatus))
} }
} }
@@ -150,7 +299,7 @@ class NotificationService: UNNotificationServiceExtension {
override func serviceExtensionTimeWillExpire() { override func serviceExtensionTimeWillExpire() {
logger.debug("DEBUGGING: NotificationService.serviceExtensionTimeWillExpire") logger.debug("DEBUGGING: NotificationService.serviceExtensionTimeWillExpire")
deliverBestAttemptNtf() deliverBestAttemptNtf(urgent: true)
} }
func setBadgeCount() { func setBadgeCount() {
@@ -159,91 +308,301 @@ class NotificationService: UNNotificationServiceExtension {
} }
func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) { func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) {
setBestAttemptNtf(.nse(notification: ntf)) setBestAttemptNtf(.nse(ntf))
} }
func setBestAttemptNtf(_ ntf: NSENotification) { func setBestAttemptNtf(_ ntf: NSENotification) {
logger.debug("NotificationService.setBestAttemptNtf") logger.debug("NotificationService.setBestAttemptNtf")
if case let .nse(notification) = ntf { if case let .nse(notification) = ntf {
notification.badge = badgeCount as NSNumber notification.badge = badgeCount as NSNumber
bestAttemptNtf = .nse(notification: notification) bestAttemptNtf = .nse(notification)
} else { } else {
bestAttemptNtf = ntf bestAttemptNtf = ntf
} }
} }
private func deliverBestAttemptNtf() { private func deliverBestAttemptNtf(urgent: Bool = false) {
logger.debug("NotificationService.deliverBestAttemptNtf") logger.debug("NotificationService.deliverBestAttemptNtf")
if let cancel = cancelRead {
cancelRead = nil
cancel()
}
if let id = receiveEntityId {
receiveEntityId = nil
NtfStreamSemaphores.shared.signalStreamReady(id)
}
let suspend: Bool
if let t = threadId {
threadId = nil
suspend = NSEThreads.shared.endThread(t) && NSEThreads.shared.noThreads
} else {
suspend = false
}
deliverCallkitOrNotification(urgent: urgent, suspend: suspend)
}
private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false) {
if case .callkit = bestAttemptNtf {
logger.debug("NotificationService.deliverCallkitOrNotification: will suspend, callkit")
if urgent {
// suspending NSE even though there may be other notifications
// to allow the app to process callkit call
suspendChat(0)
deliverNotification()
} else {
// suspending NSE with delay and delivering after the suspension
// because pushkit notification must be processed without delay
// to avoid app termination
DispatchQueue.global().asyncAfter(deadline: .now() + fastNSESuspendSchedule.delay) {
suspendChat(fastNSESuspendSchedule.timeout)
DispatchQueue.global().asyncAfter(deadline: .now() + Double(fastNSESuspendSchedule.timeout)) {
self.deliverNotification()
}
}
}
} else {
if suspend {
logger.debug("NotificationService.deliverCallkitOrNotification: will suspend")
if urgent {
suspendChat(0)
} else {
// suspension is delayed to allow chat core finalise any processing
// (e.g., send delivery receipts)
DispatchQueue.global().asyncAfter(deadline: .now() + nseSuspendSchedule.delay) {
if NSEThreads.shared.noThreads {
suspendChat(nseSuspendSchedule.timeout)
}
}
}
}
deliverNotification()
}
}
private func deliverNotification() {
if let handler = contentHandler, let ntf = bestAttemptNtf { if let handler = contentHandler, let ntf = bestAttemptNtf {
contentHandler = nil
bestAttemptNtf = nil
let deliver: (UNMutableNotificationContent?) -> Void = { ntf in
let useNtf = if let ntf = ntf {
appStateGroupDefault.get().running ? UNMutableNotificationContent() : ntf
} else {
UNMutableNotificationContent()
}
handler(useNtf)
}
switch ntf { switch ntf {
case let .nse(content): handler(content) case let .nse(content): deliver(content)
case let .callkit(invitation): case let .callkit(invitation):
logger.debug("NotificationService reportNewIncomingVoIPPushPayload for \(invitation.contact.id)")
CXProvider.reportNewIncomingVoIPPushPayload([ CXProvider.reportNewIncomingVoIPPushPayload([
"displayName": invitation.contact.displayName, "displayName": invitation.contact.displayName,
"contactId": invitation.contact.id, "contactId": invitation.contact.id,
"media": invitation.callType.media.rawValue "media": invitation.callType.media.rawValue
]) { error in ]) { error in
if error == nil { logger.debug("reportNewIncomingVoIPPushPayload result: \(error)")
handler(UNMutableNotificationContent()) deliver(error == nil ? nil : createCallInvitationNtf(invitation))
} else {
logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)")
handler(createCallInvitationNtf(invitation))
} }
case .empty: deliver(nil) // used to mute notifications that did not unsubscribe yet
case .msgInfo: deliver(nil) // unreachable, the best attempt is never set to msgInfo
} }
case .empty: handler(UNMutableNotificationContent())
}
bestAttemptNtf = nil
} }
} }
} }
var chatStarted = false // nseStateGroupDefault must not be used in NSE directly, only via this singleton
var networkConfig: NetCfg = getNetCfg() class NSEChatState {
var xftpConfig: XFTPFileConfig? = getXFTPCfg() static let shared = NSEChatState()
private var value_ = NSEState.created
var value: NSEState {
value_
}
func set(_ state: NSEState) {
nseStateGroupDefault.set(state)
sendNSEState(state)
value_ = state
}
init() {
// This is always set to .created state, as in case previous start of NSE crashed in .active state, it is stored correctly.
// Otherwise the app will be activating slower
set(.created)
}
}
var appSubscriber: AppSubscriber = appStateSubscriber { state in
logger.debug("NotificationService: appSubscriber")
if state.running && NSEChatState.shared.value.canSuspend {
logger.debug("NotificationService: appSubscriber app state \(state.rawValue), suspending")
suspendChat(fastNSESuspendSchedule.timeout)
}
}
func appStateSubscriber(onState: @escaping (AppState) -> Void) -> AppSubscriber {
appMessageSubscriber { msg in
if case let .state(state) = msg {
logger.debug("NotificationService: appStateSubscriber \(state.rawValue)")
onState(state)
}
}
}
var receiverStarted = false
let startLock = DispatchSemaphore(value: 1)
let suspendLock = DispatchSemaphore(value: 1)
var networkConfig: NetCfg = getNetCfg()
let xftpConfig: XFTPFileConfig? = getXFTPCfg()
// startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller
// Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active
func startChat() -> DBMigrationResult? { func startChat() -> DBMigrationResult? {
hs_init(0, nil) logger.debug("NotificationService: startChat")
if chatStarted { return .ok } // only skip creating if there is chat controller
let (_, dbStatus) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation()) if case .active = NSEChatState.shared.value, hasChatCtrl() { return .ok }
startLock.wait()
defer { startLock.signal() }
if hasChatCtrl() {
return switch NSEChatState.shared.value {
case .created: doStartChat()
case .starting: .ok // it should never get to this branch, as it would be waiting for start on startLock
case .active: .ok
case .suspending: activateChat()
case .suspended: activateChat()
}
} else {
// Ignore state in preference if there is no chat controller.
// State in preference may have failed to update e.g. because of a crash.
NSEChatState.shared.set(.created)
return doStartChat()
}
}
func doStartChat() -> DBMigrationResult? {
logger.debug("NotificationService: doStartChat")
haskell_init_nse()
let (_, dbStatus) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation(), backgroundMode: true)
logger.debug("NotificationService: doStartChat \(String(describing: dbStatus))")
if dbStatus != .ok { if dbStatus != .ok {
resetChatCtrl() resetChatCtrl()
NSEChatState.shared.set(.created)
return dbStatus return dbStatus
} }
let state = NSEChatState.shared.value
NSEChatState.shared.set(.starting)
if let user = apiGetActiveUser() { if let user = apiGetActiveUser() {
logger.debug("active user \(String(describing: user))") logger.debug("NotificationService active user \(String(describing: user))")
do { do {
try setNetworkConfig(networkConfig) try setNetworkConfig(networkConfig)
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try setXFTPConfig(xftpConfig) try setXFTPConfig(xftpConfig)
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
// prevent suspension while starting chat
suspendLock.wait()
defer { suspendLock.signal() }
if NSEChatState.shared.value == .starting {
updateNetCfg()
let justStarted = try apiStartChat() let justStarted = try apiStartChat()
chatStarted = true NSEChatState.shared.set(.active)
if justStarted { if justStarted {
chatLastStartGroupDefault.set(Date.now) chatLastStartGroupDefault.set(Date.now)
Task { await receiveMessages() } Task {
if !receiverStarted {
receiverStarted = true
await receiveMessages()
}
}
} }
return .ok return .ok
}
} catch { } catch {
logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)") logger.error("NotificationService startChat error: \(responseError(error))")
} }
} else { } else {
logger.debug("no active user") logger.debug("NotificationService: no active user")
} }
if NSEChatState.shared.value == .starting { NSEChatState.shared.set(state) }
return nil return nil
} }
func activateChat() -> DBMigrationResult? {
logger.debug("NotificationService: activateChat")
let state = NSEChatState.shared.value
NSEChatState.shared.set(.active)
if apiActivateChat() {
logger.debug("NotificationService: activateChat: after apiActivateChat")
return .ok
} else {
NSEChatState.shared.set(state)
return nil
}
}
// suspendChat uses semaphore suspendLock to ensure that only one suspension can happen.
func suspendChat(_ timeout: Int) {
logger.debug("NotificationService: suspendChat")
let state = NSEChatState.shared.value
if !state.canSuspend {
logger.error("NotificationService suspendChat called, current state: \(state.rawValue)")
} else if hasChatCtrl() {
// only suspend if we have chat controller to avoid crashes when suspension is
// attempted when chat controller was not created
suspendLock.wait()
defer { suspendLock.signal() }
NSEChatState.shared.set(.suspending)
if apiSuspendChat(timeoutMicroseconds: timeout * 1000000) {
logger.debug("NotificationService: suspendChat: after apiSuspendChat")
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: chatSuspended)
} else {
NSEChatState.shared.set(state)
}
}
}
func chatSuspended() {
logger.debug("NotificationService chatSuspended")
if case .suspending = NSEChatState.shared.value {
NSEChatState.shared.set(.suspended)
chatCloseStore()
logger.debug("NotificationService chatSuspended: suspended")
}
}
// A single loop is used per Notification service extension process to receive and process all messages depending on the NSE state
// If the extension is not active yet, or suspended/suspending, or the app is running, the notifications will no be received.
func receiveMessages() async { func receiveMessages() async {
logger.debug("NotificationService receiveMessages") logger.debug("NotificationService receiveMessages")
while true { while true {
updateNetCfg() switch NSEChatState.shared.value {
// it should never get to "created" and "starting" branches, as NSE state is set to .active before the loop start
case .created: await delayWhenInactive()
case .starting: await delayWhenInactive()
case .active: await receiveMsg()
case .suspending: await receiveMsg()
case .suspended: await delayWhenInactive()
}
}
func receiveMsg() async {
if let msg = await chatRecvMsg() { if let msg = await chatRecvMsg() {
logger.debug("NotificationService receiveMsg: message")
if let (id, ntf) = await receivedMsgNtf(msg) { if let (id, ntf) = await receivedMsgNtf(msg) {
logger.debug("NotificationService receiveMsg: notification")
await PendingNtfs.shared.createStream(id) await PendingNtfs.shared.createStream(id)
await PendingNtfs.shared.writeStream(id, ntf) await PendingNtfs.shared.writeStream(id, ntf)
} }
} }
} }
func delayWhenInactive() async {
logger.debug("NotificationService delayWhenInactive")
_ = try? await Task.sleep(nanoseconds: 1000_000000)
}
} }
func chatRecvMsg() async -> ChatResponse? { func chatRecvMsg() async -> ChatResponse? {
@@ -257,14 +616,14 @@ private let isInChina = SKStorefront().countryCode == "CHN"
private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() }
func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
logger.debug("NotificationService processReceivedMsg: \(res.responseType)") logger.debug("NotificationService receivedMsgNtf: \(res.responseType)")
switch res { switch res {
case let .contactConnected(user, contact, _): case let .contactConnected(user, contact, _):
return (contact.id, .nse(notification: createContactConnectedNtf(user, contact))) return (contact.id, .nse(createContactConnectedNtf(user, contact)))
// case let .contactConnecting(contact): // case let .contactConnecting(contact):
// TODO profile update // TODO profile update
case let .receivedContactRequest(user, contactRequest): case let .receivedContactRequest(user, contactRequest):
return (UserContact(contactRequest: contactRequest).id, .nse(notification: createContactRequestNtf(user, contactRequest))) return (UserContact(contactRequest: contactRequest).id, .nse(createContactRequestNtf(user, contactRequest)))
case let .newChatItem(user, aChatItem): case let .newChatItem(user, aChatItem):
let cInfo = aChatItem.chatInfo let cInfo = aChatItem.chatInfo
var cItem = aChatItem.chatItem var cItem = aChatItem.chatItem
@@ -274,7 +633,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
if let file = cItem.autoReceiveFile() { if let file = cItem.autoReceiveFile() {
cItem = autoReceiveFile(file, encrypted: cItem.encryptLocalFile) ?? cItem cItem = autoReceiveFile(file, encrypted: cItem.encryptLocalFile) ?? cItem
} }
let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(notification: createMessageReceivedNtf(user, cInfo, cItem)) : .empty let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty
return cItem.showNotification ? (aChatItem.chatId, ntf) : nil return cItem.showNotification ? (aChatItem.chatId, ntf) : nil
case let .rcvFileSndCancelled(_, aChatItem, _): case let .rcvFileSndCancelled(_, aChatItem, _):
cleanupFile(aChatItem) cleanupFile(aChatItem)
@@ -292,10 +651,18 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
// Do not post it without CallKit support, iOS will stop launching the app without showing CallKit // Do not post it without CallKit support, iOS will stop launching the app without showing CallKit
return ( return (
invitation.contact.id, invitation.contact.id,
useCallKit() ? .callkit(invitation: invitation) : .nse(notification: createCallInvitationNtf(invitation)) useCallKit() ? .callkit(invitation) : .nse(createCallInvitationNtf(invitation))
) )
case let .ntfMessage(_, connEntity, ntfMessage):
return if let id = connEntity.id { (id, .msgInfo(ntfMessage)) } else { nil }
case .chatSuspended:
chatSuspended()
return nil
case let .chatError(_, err):
logger.error("NotificationService receivedMsgNtf error: \(String(describing: err))")
return nil
default: default:
logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)") logger.debug("NotificationService receivedMsgNtf ignored event: \(res.responseType)")
return nil return nil
} }
} }
@@ -308,17 +675,22 @@ func updateNetCfg() {
try setNetworkConfig(networkConfig) try setNetworkConfig(networkConfig)
networkConfig = newNetConfig networkConfig = newNetConfig
} catch { } catch {
logger.error("NotificationService apply changed network config error: \(responseError(error), privacy: .public)") logger.error("NotificationService apply changed network config error: \(responseError(error))")
} }
} }
} }
func apiGetActiveUser() -> User? { func apiGetActiveUser() -> User? {
let r = sendSimpleXCmd(.showActiveUser) let r = sendSimpleXCmd(.showActiveUser)
logger.debug("apiGetActiveUser sendSimpleXCmd response: \(String(describing: r))") logger.debug("apiGetActiveUser sendSimpleXCmd response: \(r.responseType)")
switch r { switch r {
case let .activeUser(user): return user case let .activeUser(user): return user
case .chatCmdError(_, .error(.noActiveUser)): return nil case .chatCmdError(_, .error(.noActiveUser)):
logger.debug("apiGetActiveUser sendSimpleXCmd no active user")
return nil
case let .chatCmdError(_, err):
logger.debug("apiGetActiveUser sendSimpleXCmd error: \(String(describing: err))")
return nil
default: default:
logger.error("NotificationService apiGetActiveUser unexpected response: \(String(describing: r))") logger.error("NotificationService apiGetActiveUser unexpected response: \(String(describing: r))")
return nil return nil
@@ -326,7 +698,7 @@ func apiGetActiveUser() -> User? {
} }
func apiStartChat() throws -> Bool { func apiStartChat() throws -> Bool {
let r = sendSimpleXCmd(.startChat(subscribe: false, expire: false, xftp: false)) let r = sendSimpleXCmd(.startChat(mainApp: false))
switch r { switch r {
case .chatStarted: return true case .chatStarted: return true
case .chatRunning: return false case .chatRunning: return false
@@ -334,6 +706,21 @@ func apiStartChat() throws -> Bool {
} }
} }
func apiActivateChat() -> Bool {
chatReopenStore()
let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false))
if case .cmdOk = r { return true }
logger.error("NotificationService apiActivateChat error: \(String(describing: r))")
return false
}
func apiSuspendChat(timeoutMicroseconds: Int) -> Bool {
let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
if case .cmdOk = r { return true }
logger.error("NotificationService apiSuspendChat error: \(String(describing: r))")
return false
}
func apiSetTempFolder(tempFolder: String) throws { func apiSetTempFolder(tempFolder: String) throws {
let r = sendSimpleXCmd(.setTempFolder(tempFolder: tempFolder)) let r = sendSimpleXCmd(.setTempFolder(tempFolder: tempFolder))
if case .cmdOk = r { return } if case .cmdOk = r { return }
@@ -364,12 +751,13 @@ func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
return nil return nil
} }
let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo))
if case let .ntfMessages(user, connEntity, msgTs, ntfMessages) = r, let user = user { if case let .ntfMessages(user, connEntity_, msgTs, ntfMessages) = r, let user = user {
return NtfMessages(user: user, connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) logger.debug("apiGetNtfMessage response ntfMessages: \(ntfMessages.count)")
return NtfMessages(user: user, connEntity_: connEntity_, msgTs: msgTs, ntfMessages: ntfMessages)
} else if case let .chatCmdError(_, error) = r { } else if case let .chatCmdError(_, error) = r {
logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))") logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))")
} else { } else {
logger.debug("apiGetNtfMessage ignored response: \(r.responseType, privacy: .public) \(String.init(describing: r), privacy: .private)") logger.debug("apiGetNtfMessage ignored response: \(r.responseType) \(String.init(describing: r))")
} }
return nil return nil
} }
@@ -405,11 +793,11 @@ func setNetworkConfig(_ cfg: NetCfg) throws {
struct NtfMessages { struct NtfMessages {
var user: User var user: User
var connEntity: ConnectionEntity? var connEntity_: ConnectionEntity?
var msgTs: Date? var msgTs: Date?
var ntfMessages: [NtfMsgInfo] var ntfMessages: [NtfMsgInfo]
var ntfsEnabled: Bool { var ntfsEnabled: Bool {
user.showNotifications && (connEntity?.ntfsEnabled ?? false) user.showNotifications && (connEntity_?.ntfsEnabled ?? false)
} }
} }
+58 -32
View File
@@ -30,6 +30,11 @@
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; }; 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; };
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
5C245F232B4EAA5E001CC39F /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C245F1E2B4EAA5E001CC39F /* libgmpxx.a */; };
5C245F242B4EAA5E001CC39F /* libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C245F1F2B4EAA5E001CC39F /* libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH-ghc9.6.3.a */; };
5C245F252B4EAA5E001CC39F /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C245F202B4EAA5E001CC39F /* libgmp.a */; };
5C245F262B4EAA5E001CC39F /* libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C245F212B4EAA5E001CC39F /* libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH.a */; };
5C245F272B4EAA5E001CC39F /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C245F222B4EAA5E001CC39F /* libffi.a */; };
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; };
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; };
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; };
@@ -120,11 +125,6 @@
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; };
5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; };
5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */ = {isa = PBXBuildFile; fileRef = 5CD67B8E2B0E858A00C510B1 /* hs_init.c */; }; 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */ = {isa = PBXBuildFile; fileRef = 5CD67B8E2B0E858A00C510B1 /* hs_init.c */; };
5CD67BA02B120ADF00C510B1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9B2B120ADF00C510B1 /* libgmp.a */; };
5CD67BA12B120ADF00C510B1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */; };
5CD67BA22B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */; };
5CD67BA32B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */; };
5CD67BA42B120ADF00C510B1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9F2B120ADF00C510B1 /* libffi.a */; };
5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; };
5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@@ -150,6 +150,9 @@
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; };
5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; }; 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; };
5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */; };
5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */; };
5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF937212B25034A00E1D781 /* NSESubscriber.swift */; };
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; };
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; };
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
@@ -165,6 +168,11 @@
64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */; }; 64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */; };
64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; }; 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; };
6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; }; 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; };
6449333A2AF8E51000AC506E /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933352AF8E51000AC506E /* libgmpxx.a */; };
6449333B2AF8E51000AC506E /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933362AF8E51000AC506E /* libgmp.a */; };
6449333C2AF8E51000AC506E /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933372AF8E51000AC506E /* libffi.a */; };
6449333D2AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933382AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */; };
6449333E2AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933392AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */; };
644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; }; 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; };
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; }; 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; };
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; }; 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; };
@@ -185,6 +193,7 @@
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; }; 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; };
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; };
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; }; D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; };
@@ -276,6 +285,11 @@
5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = "<group>"; }; 5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = "<group>"; };
5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = "<group>"; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = "<group>"; };
5C245F1E2B4EAA5E001CC39F /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C245F1F2B4EAA5E001CC39F /* libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH-ghc9.6.3.a"; sourceTree = "<group>"; };
5C245F202B4EAA5E001CC39F /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C245F212B4EAA5E001CC39F /* libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH.a"; sourceTree = "<group>"; };
5C245F222B4EAA5E001CC39F /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = "<group>"; }; 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = "<group>"; };
5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = "<group>"; }; 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = "<group>"; };
5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; }; 5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
@@ -403,11 +417,6 @@
5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = "<group>"; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = "<group>"; };
5CD67B8D2B0E858A00C510B1 /* hs_init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hs_init.h; sourceTree = "<group>"; }; 5CD67B8D2B0E858A00C510B1 /* hs_init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hs_init.h; sourceTree = "<group>"; };
5CD67B8E2B0E858A00C510B1 /* hs_init.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hs_init.c; sourceTree = "<group>"; }; 5CD67B8E2B0E858A00C510B1 /* hs_init.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hs_init.c; sourceTree = "<group>"; };
5CD67B9B2B120ADF00C510B1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a"; sourceTree = "<group>"; };
5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a"; sourceTree = "<group>"; };
5CD67B9F2B120ADF00C510B1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -434,6 +443,9 @@
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = "<group>"; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = "<group>"; };
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = "<group>"; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = "<group>"; };
5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.swift; sourceTree = "<group>"; };
5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFileSubscriber.swift; sourceTree = "<group>"; };
5CF937212B25034A00E1D781 /* NSESubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSESubscriber.swift; sourceTree = "<group>"; };
5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = "<group>"; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = "<group>"; };
5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = "<group>"; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = "<group>"; };
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
@@ -448,6 +460,11 @@
64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; }; 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = "<group>"; }; 64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = "<group>"; };
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = "<group>"; }; 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = "<group>"; };
644933352AF8E51000AC506E /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
644933362AF8E51000AC506E /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
644933372AF8E51000AC506E /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
644933382AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a"; sourceTree = "<group>"; };
644933392AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a"; sourceTree = "<group>"; };
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = "<group>"; }; 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = "<group>"; };
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = "<group>"; }; 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = "<group>"; };
644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = "<group>"; }; 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = "<group>"; };
@@ -470,6 +487,7 @@
64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; }; 64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; }; 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = "<group>"; };
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; };
@@ -511,13 +529,13 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5CD67BA12B120ADF00C510B1 /* libgmpxx.a in Frameworks */, 5C245F232B4EAA5E001CC39F /* libgmpxx.a in Frameworks */,
5CD67BA22B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a in Frameworks */, 5C245F262B4EAA5E001CC39F /* libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH.a in Frameworks */,
5C245F252B4EAA5E001CC39F /* libgmp.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5CD67BA02B120ADF00C510B1 /* libgmp.a in Frameworks */, 5C245F272B4EAA5E001CC39F /* libffi.a in Frameworks */,
5CD67BA42B120ADF00C510B1 /* libffi.a in Frameworks */,
5CD67BA32B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5C245F242B4EAA5E001CC39F /* libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH-ghc9.6.3.a in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -579,11 +597,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = { 5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5CD67B9F2B120ADF00C510B1 /* libffi.a */, 5C245F222B4EAA5E001CC39F /* libffi.a */,
5CD67B9B2B120ADF00C510B1 /* libgmp.a */, 5C245F202B4EAA5E001CC39F /* libgmp.a */,
5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */, 5C245F1E2B4EAA5E001CC39F /* libgmpxx.a */,
5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */, 5C245F1F2B4EAA5E001CC39F /* libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH-ghc9.6.3.a */,
5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */, 5C245F212B4EAA5E001CC39F /* libHSsimplex-chat-5.4.3.0-BrNBQIZf2Ju9TtaZoeJyIH.a */,
); );
path = Libraries; path = Libraries;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -608,6 +626,7 @@
5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFC727B2782E00FB6C6D /* BGManager.swift */,
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */,
5CB346E42868AA7F001FD2EF /* SuspendChat.swift */, 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */,
5CF937212B25034A00E1D781 /* NSESubscriber.swift */,
5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */, 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */,
5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */, 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */,
5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */,
@@ -635,6 +654,7 @@
64466DCB29FFE3E800E3D48D /* MailView.swift */, 64466DCB29FFE3E800E3D48D /* MailView.swift */,
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */, 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */,
5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */, 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */,
8C05382D2B39887E006436DC /* VideoUtils.swift */,
); );
path = Helpers; path = Helpers;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -788,6 +808,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */,
5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */,
5CDCAD472818589900503DA2 /* NotificationService.swift */, 5CDCAD472818589900503DA2 /* NotificationService.swift */,
5CDCAD492818589900503DA2 /* Info.plist */, 5CDCAD492818589900503DA2 /* Info.plist */,
5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */,
@@ -808,6 +829,7 @@
64DAE1502809D9F5000DA960 /* FileUtils.swift */, 64DAE1502809D9F5000DA960 /* FileUtils.swift */,
5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */, 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */,
5C00168028C4FE760094D739 /* KeyChain.swift */, 5C00168028C4FE760094D739 /* KeyChain.swift */,
5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */,
5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */,
5CE2BA8A2845332200EC33A6 /* SimpleX.h */, 5CE2BA8A2845332200EC33A6 /* SimpleX.h */,
5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */, 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */,
@@ -1180,6 +1202,7 @@
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */,
5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */,
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */,
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */,
64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */, 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */,
@@ -1202,6 +1225,7 @@
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */,
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */, 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */,
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */, 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */, 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */,
@@ -1259,6 +1283,7 @@
files = ( files = (
5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */,
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */, 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */,
5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -1266,6 +1291,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */,
5C00168128C4FE760094D739 /* KeyChain.swift in Sources */, 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */,
5CE2BA97284537A800EC33A6 /* dummy.m in Sources */, 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */,
5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */, 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */,
@@ -1502,7 +1528,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 184; CURRENT_PROJECT_VERSION = 188;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -1524,7 +1550,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1545,7 +1571,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 184; CURRENT_PROJECT_VERSION = 188;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -1567,7 +1593,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1626,7 +1652,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 184; CURRENT_PROJECT_VERSION = 188;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1639,7 +1665,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.3;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1658,7 +1684,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 184; CURRENT_PROJECT_VERSION = 188;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1671,7 +1697,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.3;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1690,7 +1716,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 184; CURRENT_PROJECT_VERSION = 188;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1714,7 +1740,7 @@
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1736,7 +1762,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 184; CURRENT_PROJECT_VERSION = 188;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1760,7 +1786,7 @@
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -41,7 +41,7 @@
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Release" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0" launchStyle = "0"
@@ -2,7 +2,7 @@
<Scheme <Scheme
LastUpgradeVersion = "1400" LastUpgradeVersion = "1400"
wasCreatedForAppExtension = "YES" wasCreatedForAppExtension = "YES"
version = "2.0"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
@@ -47,16 +47,14 @@
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0" launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
allowLocationSimulation = "YES" allowLocationSimulation = "YES">
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
+14 -3
View File
@@ -12,12 +12,16 @@ private var chatController: chat_ctrl?
private var migrationResult: (Bool, DBMigrationResult)? private var migrationResult: (Bool, DBMigrationResult)?
public func getChatCtrl(_ useKey: String? = nil) -> chat_ctrl { public func hasChatCtrl() -> Bool {
chatController != nil
}
public func getChatCtrl() -> chat_ctrl {
if let controller = chatController { return controller } if let controller = chatController { return controller }
fatalError("chat controller not initialized") fatalError("chat controller not initialized")
} }
public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: MigrationConfirmation? = nil) -> (Bool, DBMigrationResult) { public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: MigrationConfirmation? = nil, backgroundMode: Bool = false) -> (Bool, DBMigrationResult) {
if let res = migrationResult { return res } if let res = migrationResult { return res }
let dbPath = getAppDatabasePath().path let dbPath = getAppDatabasePath().path
var dbKey = "" var dbKey = ""
@@ -41,7 +45,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
var cKey = dbKey.cString(using: .utf8)! var cKey = dbKey.cString(using: .utf8)!
var cConfirm = confirm.rawValue.cString(using: .utf8)! var cConfirm = confirm.rawValue.cString(using: .utf8)!
// the last parameter of chat_migrate_init is used to return the pointer to chat controller // the last parameter of chat_migrate_init is used to return the pointer to chat controller
let cjson = chat_migrate_init(&cPath, &cKey, &cConfirm, &chatController)! let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, backgroundMode ? 1 : 0, &chatController)!
let dbRes = dbMigrationResult(fromCString(cjson)) let dbRes = dbMigrationResult(fromCString(cjson))
let encrypted = dbKey != "" let encrypted = dbKey != ""
let keychainErr = dbRes == .ok && useKeychain && encrypted && !kcDatabasePassword.set(dbKey) let keychainErr = dbRes == .ok && useKeychain && encrypted && !kcDatabasePassword.set(dbKey)
@@ -57,6 +61,13 @@ public func chatCloseStore() {
} }
} }
public func chatReopenStore() {
let err = fromCString(chat_reopen_store(getChatCtrl()))
if err != "" {
logger.error("chatReopenStore error: \(err)")
}
}
public func resetChatCtrl() { public func resetChatCtrl() {
chatController = nil chatController = nil
migrationResult = nil migrationResult = nil
+29 -20
View File
@@ -25,9 +25,9 @@ public enum ChatCommand {
case apiMuteUser(userId: Int64) case apiMuteUser(userId: Int64)
case apiUnmuteUser(userId: Int64) case apiUnmuteUser(userId: Int64)
case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?)
case startChat(subscribe: Bool, expire: Bool, xftp: Bool) case startChat(mainApp: Bool)
case apiStopChat case apiStopChat
case apiActivateChat case apiActivateChat(restoreChat: Bool)
case apiSuspendChat(timeoutMicroseconds: Int) case apiSuspendChat(timeoutMicroseconds: Int)
case setTempFolder(tempFolder: String) case setTempFolder(tempFolder: String)
case setFilesFolder(filesFolder: String) case setFilesFolder(filesFolder: String)
@@ -154,9 +154,9 @@ public enum ChatCommand {
case let .apiMuteUser(userId): return "/_mute user \(userId)" case let .apiMuteUser(userId): return "/_mute user \(userId)"
case let .apiUnmuteUser(userId): return "/_unmute user \(userId)" case let .apiUnmuteUser(userId): return "/_unmute user \(userId)"
case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))"
case let .startChat(subscribe, expire, xftp): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire)) xftp=\(onOff(xftp))" case let .startChat(mainApp): return "/_start main=\(onOff(mainApp))"
case .apiStopChat: return "/_stop" case .apiStopChat: return "/_stop"
case .apiActivateChat: return "/_app activate" case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))"
case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)"
case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)" case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)"
case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)"
@@ -505,8 +505,8 @@ public enum ChatResponse: Decodable, Error {
case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection)
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan)
case sentConfirmation(user: UserRef) case sentConfirmation(user: UserRef, connection: PendingContactConnection)
case sentInvitation(user: UserRef) case sentInvitation(user: UserRef, connection: PendingContactConnection)
case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?)
case contactAlreadyExists(user: UserRef, contact: Contact) case contactAlreadyExists(user: UserRef, contact: Contact)
case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact)
@@ -604,8 +604,8 @@ public enum ChatResponse: Decodable, Error {
case callInvitations(callInvitations: [RcvCallInvitation]) case callInvitations(callInvitations: [RcvCallInvitation])
case ntfTokenStatus(status: NtfTknStatus) case ntfTokenStatus(status: NtfTknStatus)
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode)
case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
case newContactConnection(user: UserRef, connection: PendingContactConnection) case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
// remote desktop responses/events // remote desktop responses/events
case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo])
@@ -613,7 +613,7 @@ public enum ChatResponse: Decodable, Error {
case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String)
case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String)
case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo)
case remoteCtrlStopped case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason)
// misc // misc
case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration])
case cmdOk(user: UserRef?) case cmdOk(user: UserRef?)
@@ -752,7 +752,7 @@ public enum ChatResponse: Decodable, Error {
case .ntfTokenStatus: return "ntfTokenStatus" case .ntfTokenStatus: return "ntfTokenStatus"
case .ntfToken: return "ntfToken" case .ntfToken: return "ntfToken"
case .ntfMessages: return "ntfMessages" case .ntfMessages: return "ntfMessages"
case .newContactConnection: return "newContactConnection" case .ntfMessage: return "ntfMessage"
case .contactConnectionDeleted: return "contactConnectionDeleted" case .contactConnectionDeleted: return "contactConnectionDeleted"
case .remoteCtrlList: return "remoteCtrlList" case .remoteCtrlList: return "remoteCtrlList"
case .remoteCtrlFound: return "remoteCtrlFound" case .remoteCtrlFound: return "remoteCtrlFound"
@@ -803,11 +803,11 @@ public enum ChatResponse: Decodable, Error {
case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)")
case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)")
case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation) case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)")
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan))
case .sentConfirmation: return noDetails case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection))
case .sentInvitation: return noDetails case let .sentInvitation(u, connection): return withUser(u, String(describing: connection))
case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact))
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact))
@@ -900,7 +900,7 @@ public enum ChatResponse: Decodable, Error {
case let .ntfTokenStatus(status): return String(describing: status) case let .ntfTokenStatus(status): return String(describing: status)
case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)"
case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))")
case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls)
case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)"
@@ -1207,9 +1207,9 @@ public struct NetCfg: Codable, Equatable {
public static let defaults: NetCfg = NetCfg( public static let defaults: NetCfg = NetCfg(
socksProxy: nil, socksProxy: nil,
sessionMode: TransportSessionMode.user, sessionMode: TransportSessionMode.user,
tcpConnectTimeout: 15_000_000, tcpConnectTimeout: 20_000_000,
tcpTimeout: 10_000_000, tcpTimeout: 15_000_000,
tcpTimeoutPerKb: 30_000, tcpTimeoutPerKb: 45_000,
tcpKeepAlive: KeepAliveOpts.defaults, tcpKeepAlive: KeepAliveOpts.defaults,
smpPingInterval: 1200_000_000, smpPingInterval: 1200_000_000,
smpPingCount: 3, smpPingCount: 3,
@@ -1498,6 +1498,8 @@ public enum PushProvider: String, Decodable {
} }
} }
// This notification mode is for app core, UI uses AppNotificationsMode.off to mean completely disable,
// and .local for periodic background checks
public enum NotificationsMode: String, Decodable, SelectableItem { public enum NotificationsMode: String, Decodable, SelectableItem {
case off = "OFF" case off = "OFF"
case periodic = "PERIODIC" case periodic = "PERIODIC"
@@ -1505,9 +1507,9 @@ public enum NotificationsMode: String, Decodable, SelectableItem {
public var label: LocalizedStringKey { public var label: LocalizedStringKey {
switch self { switch self {
case .off: return "Off (Local)" case .off: "Local"
case .periodic: return "Periodically" case .periodic: "Periodically"
case .instant: return "Instantly" case .instant: "Instantly"
} }
} }
@@ -1552,6 +1554,13 @@ public enum RemoteCtrlSessionState: Decodable {
case connected(sessionCode: String) case connected(sessionCode: String)
} }
public enum RemoteCtrlStopReason: Decodable {
case discoveryFailed(chatError: ChatError)
case connectionFailed(chatError: ChatError)
case setupFailed(chatError: ChatError)
case disconnected
}
public struct CtrlAppInfo: Decodable { public struct CtrlAppInfo: Decodable {
public var appVersionRange: AppVersionRange public var appVersionRange: AppVersionRange
public var deviceName: String public var deviceName: String
+53 -7
View File
@@ -9,12 +9,16 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
public let appSuspendTimeout: Int = 15 // seconds
let GROUP_DEFAULT_APP_STATE = "appState" let GROUP_DEFAULT_APP_STATE = "appState"
let GROUP_DEFAULT_NSE_STATE = "nseState"
let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" let GROUP_DEFAULT_DB_CONTAINER = "dbContainer"
public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart"
public let GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN = "chatLastBackgroundRun"
let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode"
public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" // no longer used
public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer used
let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used
public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles" public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles"
@@ -66,13 +70,23 @@ public func registerGroupDefaults() {
]) ])
} }
public enum AppState: String { public enum AppState: String, Codable {
case active case active
case activating
case bgRefresh case bgRefresh
case suspending case suspending
case suspended case suspended
case stopped case stopped
public var running: Bool {
switch self {
case .active: return true
case .activating: return true
case .bgRefresh: return true
default: return false
}
}
public var inactive: Bool { public var inactive: Bool {
switch self { switch self {
case .suspending: return true case .suspending: return true
@@ -84,23 +98,57 @@ public enum AppState: String {
public var canSuspend: Bool { public var canSuspend: Bool {
switch self { switch self {
case .active: return true case .active: return true
case .activating: return true
case .bgRefresh: return true case .bgRefresh: return true
default: return false default: return false
} }
} }
} }
public enum NSEState: String, Codable {
case created
case starting
case active
case suspending
case suspended
public var inactive: Bool {
switch self {
case .created: true
case .suspended: true
default: false
}
}
public var canSuspend: Bool {
if case .active = self { true } else { false }
}
}
public enum DBContainer: String { public enum DBContainer: String {
case documents case documents
case group case group
} }
// appStateGroupDefault must not be used in the app directly, only via AppChatState singleton
public let appStateGroupDefault = EnumDefault<AppState>( public let appStateGroupDefault = EnumDefault<AppState>(
defaults: groupDefaults, defaults: groupDefaults,
forKey: GROUP_DEFAULT_APP_STATE, forKey: GROUP_DEFAULT_APP_STATE,
withDefault: .active withDefault: .active
) )
// nseStateGroupDefault must not be used in NSE directly, only via NSEChatState singleton
public let nseStateGroupDefault = EnumDefault<NSEState>(
defaults: groupDefaults,
forKey: GROUP_DEFAULT_NSE_STATE,
withDefault: .suspended // so that NSE that was never launched does not delay the app from resuming
)
// inactive app states do not include "stopped" state
public func allowBackgroundRefresh() -> Bool {
appStateGroupDefault.get().inactive && nseStateGroupDefault.get().inactive
}
public let dbContainerGroupDefault = EnumDefault<DBContainer>( public let dbContainerGroupDefault = EnumDefault<DBContainer>(
defaults: groupDefaults, defaults: groupDefaults,
forKey: GROUP_DEFAULT_DB_CONTAINER, forKey: GROUP_DEFAULT_DB_CONTAINER,
@@ -109,6 +157,8 @@ public let dbContainerGroupDefault = EnumDefault<DBContainer>(
public let chatLastStartGroupDefault = DateDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CHAT_LAST_START) public let chatLastStartGroupDefault = DateDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CHAT_LAST_START)
public let chatLastBackgroundRunGroupDefault = DateDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN)
public let ntfPreviewModeGroupDefault = EnumDefault<NotificationPreviewMode>( public let ntfPreviewModeGroupDefault = EnumDefault<NotificationPreviewMode>(
defaults: groupDefaults, defaults: groupDefaults,
forKey: GROUP_DEFAULT_NTF_PREVIEW_MODE, forKey: GROUP_DEFAULT_NTF_PREVIEW_MODE,
@@ -117,10 +167,6 @@ public let ntfPreviewModeGroupDefault = EnumDefault<NotificationPreviewMode>(
public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO) public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO)
public let ntfEnableLocalGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_ENABLE_LOCAL)
public let ntfEnablePeriodicGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_ENABLE_PERIODIC)
public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES)
public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES) public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES)
+2 -1
View File
@@ -2016,7 +2016,8 @@ public enum ConnectionEntity: Decodable {
} }
public struct NtfMsgInfo: Decodable { public struct NtfMsgInfo: Decodable {
public var msgId: String
public var msgTs: Date
} }
public struct AChatItem: Decodable { public struct AChatItem: Decodable {
+2 -2
View File
@@ -17,7 +17,7 @@ public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs {
let ptr: UnsafeMutableRawPointer = malloc(data.count) let ptr: UnsafeMutableRawPointer = malloc(data.count)
memcpy(ptr, (data as NSData).bytes, data.count) memcpy(ptr, (data as NSData).bytes, data.count)
var cPath = path.cString(using: .utf8)! var cPath = path.cString(using: .utf8)!
let cjson = chat_write_file(&cPath, ptr, Int32(data.count))! let cjson = chat_write_file(getChatCtrl(), &cPath, ptr, Int32(data.count))!
let d = fromCString(cjson).data(using: .utf8)! let d = fromCString(cjson).data(using: .utf8)!
switch try jsonDecoder.decode(WriteFileResult.self, from: d) { switch try jsonDecoder.decode(WriteFileResult.self, from: d) {
case let .result(cfArgs): return cfArgs case let .result(cfArgs): return cfArgs
@@ -50,7 +50,7 @@ public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> D
public func encryptCryptoFile(fromPath: String, toPath: String) throws -> CryptoFileArgs { public func encryptCryptoFile(fromPath: String, toPath: String) throws -> CryptoFileArgs {
var cFromPath = fromPath.cString(using: .utf8)! var cFromPath = fromPath.cString(using: .utf8)!
var cToPath = toPath.cString(using: .utf8)! var cToPath = toPath.cString(using: .utf8)!
let cjson = chat_encrypt_file(&cFromPath, &cToPath)! let cjson = chat_encrypt_file(getChatCtrl(), &cFromPath, &cToPath)!
let d = fromCString(cjson).data(using: .utf8)! let d = fromCString(cjson).data(using: .utf8)!
switch try jsonDecoder.decode(WriteFileResult.self, from: d) { switch try jsonDecoder.decode(WriteFileResult.self, from: d) {
case let .result(cfArgs): return cfArgs case let .result(cfArgs): return cfArgs
+7
View File
@@ -146,6 +146,13 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult) -> UNMutableNotificati
) )
} }
public func createAppStoppedNtf() -> UNMutableNotificationContent {
return createNotification(
categoryIdentifier: ntfCategoryConnectionEvent,
title: NSLocalizedString("Encrypted message: app is stopped", comment: "notification")
)
}
private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember, hideContent: Bool) -> String { private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember, hideContent: Bool) -> String {
hideContent hideContent
? NSLocalizedString("Group message:", comment: "notification") ? NSLocalizedString("Group message:", comment: "notification")
@@ -0,0 +1,99 @@
//
// SharedFileSubscriber.swift
// SimpleXChat
//
// Created by Evgeny on 09/12/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import Foundation
public typealias AppSubscriber = SharedFileSubscriber<ProcessMessage<AppProcessMessage>>
public typealias NSESubscriber = SharedFileSubscriber<ProcessMessage<NSEProcessMessage>>
public class SharedFileSubscriber<Message: Codable>: NSObject, NSFilePresenter {
var fileURL: URL
public var presentedItemURL: URL?
public var presentedItemOperationQueue: OperationQueue = .main
var subscriber: (Message) -> Void
init(fileURL: URL, onMessage: @escaping (Message) -> Void) {
self.fileURL = fileURL
presentedItemURL = fileURL
subscriber = onMessage
super.init()
NSFileCoordinator.addFilePresenter(self)
}
public func presentedItemDidChange() {
do {
let data = try Data(contentsOf: fileURL)
let msg = try jsonDecoder.decode(Message.self, from: data)
subscriber(msg)
} catch let error {
logger.error("presentedItemDidChange error: \(error)")
}
}
static func notify(url: URL, message: Message) {
let fc = NSFileCoordinator(filePresenter: nil)
fc.coordinate(writingItemAt: url, options: [], error: nil) { newURL in
do {
let data = try jsonEncoder.encode(message)
try data.write(to: newURL, options: [.atomic])
} catch {
logger.error("notifyViaSharedFile error: \(error)")
}
}
}
deinit {
NSFileCoordinator.removeFilePresenter(self)
}
}
let appMessagesSharedFile = getGroupContainerDirectory().appendingPathComponent("chat.simplex.app.messages", isDirectory: false)
let nseMessagesSharedFile = getGroupContainerDirectory().appendingPathComponent("chat.simplex.app.SimpleX-NSE.messages", isDirectory: false)
public struct ProcessMessage<Message: Codable>: Codable {
var createdAt: Date = Date.now
var message: Message
}
public enum AppProcessMessage: Codable {
case state(state: AppState)
}
public enum NSEProcessMessage: Codable {
case state(state: NSEState)
}
public func sendAppProcessMessage(_ message: AppProcessMessage) {
SharedFileSubscriber.notify(url: appMessagesSharedFile, message: ProcessMessage(message: message))
}
public func sendNSEProcessMessage(_ message: NSEProcessMessage) {
SharedFileSubscriber.notify(url: nseMessagesSharedFile, message: ProcessMessage(message: message))
}
public func appMessageSubscriber(onMessage: @escaping (AppProcessMessage) -> Void) -> AppSubscriber {
SharedFileSubscriber(fileURL: appMessagesSharedFile) { (msg: ProcessMessage<AppProcessMessage>) in
onMessage(msg.message)
}
}
public func nseMessageSubscriber(onMessage: @escaping (NSEProcessMessage) -> Void) -> NSESubscriber {
SharedFileSubscriber(fileURL: nseMessagesSharedFile) { (msg: ProcessMessage<NSEProcessMessage>) in
onMessage(msg.message)
}
}
public func sendAppState(_ state: AppState) {
sendAppProcessMessage(.state(state: state))
}
public func sendNSEState(_ state: NSEState) {
sendNSEProcessMessage(.state(state: state))
}
+5 -5
View File
@@ -16,20 +16,20 @@ extern void hs_init(int argc, char **argv[]);
typedef void* chat_ctrl; typedef void* chat_ctrl;
// the last parameter is used to return the pointer to chat controller // the last parameter is used to return the pointer to chat controller
extern char *chat_migrate_init(char *path, char *key, char *confirm, chat_ctrl *ctrl); extern char *chat_migrate_init_key(char *path, char *key, int keepKey, char *confirm, int backgroundMode, chat_ctrl *ctrl);
extern char *chat_close_store(chat_ctrl ctl); extern char *chat_close_store(chat_ctrl ctl);
extern char *chat_reopen_store(chat_ctrl ctl);
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
extern char *chat_recv_msg(chat_ctrl ctl);
extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait);
extern char *chat_parse_markdown(char *str); extern char *chat_parse_markdown(char *str);
extern char *chat_parse_server(char *str); extern char *chat_parse_server(char *str);
extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_password_hash(char *pwd, char *salt);
extern char *chat_valid_name(char *name); extern char *chat_valid_name(char *name);
extern char *chat_encrypt_media(char *key, char *frame, int len); extern char *chat_encrypt_media(chat_ctrl ctl, char *key, char *frame, int len);
extern char *chat_decrypt_media(char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len);
// chat_write_file returns null-terminated string with JSON of WriteFileResult // chat_write_file returns null-terminated string with JSON of WriteFileResult
extern char *chat_write_file(char *path, char *data, int len); extern char *chat_write_file(chat_ctrl ctl, char *path, char *data, int len);
// chat_read_file returns a buffer with: // chat_read_file returns a buffer with:
// result status (1 byte), then if // result status (1 byte), then if
@@ -38,7 +38,7 @@ extern char *chat_write_file(char *path, char *data, int len);
extern char *chat_read_file(char *path, char *key, char *nonce); extern char *chat_read_file(char *path, char *key, char *nonce);
// chat_encrypt_file returns null-terminated string with JSON of WriteFileResult // chat_encrypt_file returns null-terminated string with JSON of WriteFileResult
extern char *chat_encrypt_file(char *fromPath, char *toPath); extern char *chat_encrypt_file(chat_ctrl ctl, char *fromPath, char *toPath);
// chat_decrypt_file returns null-terminated string with the error message // chat_decrypt_file returns null-terminated string with the error message
extern char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); extern char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath);
+16
View File
@@ -23,3 +23,19 @@ void haskell_init(void) {
char **pargv = argv; char **pargv = argv;
hs_init_with_rtsopts(&argc, &pargv); hs_init_with_rtsopts(&argc, &pargv);
} }
void haskell_init_nse(void) {
int argc = 7;
char *argv[] = {
"simplex",
"+RTS", // requires `hs_init_with_rtsopts`
"-A1m", // chunk size for new allocations
"-H1m", // initial heap size
"-F0.5", // heap growth triggering GC
"-Fd1", // memory return
"-c", // compacting garbage collector
0
};
char **pargv = argv;
hs_init_with_rtsopts(&argc, &pargv);
}
+2
View File
@@ -11,4 +11,6 @@
void haskell_init(void); void haskell_init(void);
void haskell_init_nse(void);
#endif /* hs_init_h */ #endif /* hs_init_h */
+1 -1
View File
@@ -12,7 +12,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "chat.simplex.app" applicationId = "chat.simplex.app"
minSdkVersion(26) minSdkVersion(28)
targetSdkVersion(33) targetSdkVersion(33)
// !!! // !!!
// skip version code after release to F-Droid, as it uses two version codes // skip version code after release to F-Droid, as it uses two version codes
@@ -39,6 +39,7 @@
android:exported="true" android:exported="true"
android:label="${app_name}" android:label="${app_name}"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:configChanges="uiMode"
android:theme="@style/Theme.SimpleX"> android:theme="@style/Theme.SimpleX">
<intent-filter> <intent-filter>
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -5,6 +5,7 @@ import android.net.Uri
import android.os.* import android.os.*
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.getUserIdFromIntent import chat.simplex.app.model.NtfManager.getUserIdFromIntent
@@ -22,6 +23,7 @@ import java.lang.ref.WeakReference
class MainActivity: FragmentActivity() { class MainActivity: FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
platform.androidSetNightModeIfSupported()
applyAppLocale(ChatModel.controller.appPrefs.appLanguage) applyAppLocale(ChatModel.controller.appPrefs.appLanguage)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// testJson() // testJson()
@@ -41,10 +43,8 @@ class MainActivity: FragmentActivity() {
) )
} }
setContent { setContent {
SimpleXTheme {
AppScreen() AppScreen()
} }
}
SimplexApp.context.schedulePeriodicServiceRestartWorker() SimplexApp.context.schedulePeriodicServiceRestartWorker()
SimplexApp.context.schedulePeriodicWakeUp() SimplexApp.context.schedulePeriodicWakeUp()
} }
@@ -126,7 +126,9 @@ fun processIntent(intent: Intent?) {
when (intent?.action) { when (intent?.action) {
"android.intent.action.VIEW" -> { "android.intent.action.VIEW" -> {
val uri = intent.data val uri = intent.data
if (uri != null) connectIfOpenedViaUri(chatModel.remoteHostId(), uri.toURI(), ChatModel) if (uri != null) {
chatModel.appOpenUrl.value = null to uri.toURI()
}
} }
} }
} }
@@ -1,7 +1,8 @@
package chat.simplex.app package chat.simplex.app
import android.app.Application import android.app.Application
import chat.simplex.common.platform.Log import android.app.UiModeManager
import android.os.*
import androidx.lifecycle.* import androidx.lifecycle.*
import androidx.work.* import androidx.work.*
import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager
@@ -10,10 +11,12 @@ import chat.simplex.common.helpers.requiresIgnoringBattery
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.ui.theme.DefaultTheme
import chat.simplex.common.views.call.RcvCallInvitation
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.RcvCallInvitation
import com.jakewharton.processphoenix.ProcessPhoenix import com.jakewharton.processphoenix.ProcessPhoenix
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -32,7 +35,24 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (ProcessPhoenix.isPhoenixProcess(this)) { if (ProcessPhoenix.isPhoenixProcess(this)) {
return; return
} else {
registerGlobalErrorHandler()
Handler(Looper.getMainLooper()).post {
while (true) {
try {
Looper.loop()
} catch (e: Throwable) {
if (e.message != null && e.message!!.startsWith("Unable to start activity")) {
android.os.Process.killProcess(android.os.Process.myPid())
break
} else {
// Send it to our exception handled because it will not get the exception otherwise
Thread.getDefaultUncaughtExceptionHandler()?.uncaughtException(Looper.getMainLooper().thread, e)
}
}
}
}
} }
context = this context = this
initHaskell() initHaskell()
@@ -75,7 +95,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
} }
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
isAppOnForeground = true isAppOnForeground = true
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.currentUser.value != null) {
SimplexService.showBackgroundServiceNoticeIfNeeded() SimplexService.showBackgroundServiceNoticeIfNeeded()
} }
/** /**
@@ -144,13 +164,14 @@ class SimplexApp: Application(), LifecycleEventObserver {
androidAppContext = this androidAppContext = this
APPLICATION_ID = BuildConfig.APPLICATION_ID APPLICATION_ID = BuildConfig.APPLICATION_ID
ntfManager = object : chat.simplex.common.platform.NtfManager() { ntfManager = object : chat.simplex.common.platform.NtfManager() {
override fun notifyCallInvitation(invitation: RcvCallInvitation) = NtfManager.notifyCallInvitation(invitation) override fun notifyCallInvitation(invitation: RcvCallInvitation): Boolean = NtfManager.notifyCallInvitation(invitation)
override fun hasNotificationsForChat(chatId: String): Boolean = NtfManager.hasNotificationsForChat(chatId) override fun hasNotificationsForChat(chatId: String): Boolean = NtfManager.hasNotificationsForChat(chatId)
override fun cancelNotificationsForChat(chatId: String) = NtfManager.cancelNotificationsForChat(chatId) override fun cancelNotificationsForChat(chatId: String) = NtfManager.cancelNotificationsForChat(chatId)
override fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String?, actions: List<Pair<NotificationAction, () -> Unit>>) = NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions.map { it.first }) override fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String?, actions: List<Pair<NotificationAction, () -> Unit>>) = NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions.map { it.first })
override fun androidCreateNtfChannelsMaybeShowAlert() = NtfManager.createNtfChannelsMaybeShowAlert() override fun androidCreateNtfChannelsMaybeShowAlert() = NtfManager.createNtfChannelsMaybeShowAlert()
override fun cancelCallNotification() = NtfManager.cancelCallNotification() override fun cancelCallNotification() = NtfManager.cancelCallNotification()
override fun cancelAllNotifications() = NtfManager.cancelAllNotifications() override fun cancelAllNotifications() = NtfManager.cancelAllNotifications()
override fun showMessage(title: String, text: String) = NtfManager.showMessage(title, text)
} }
platform = object : PlatformInterface { platform = object : PlatformInterface {
override suspend fun androidServiceStart() { override suspend fun androidServiceStart() {
@@ -206,6 +227,23 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun androidIsBackgroundCallAllowed(): Boolean = !SimplexService.isBackgroundRestricted() override fun androidIsBackgroundCallAllowed(): Boolean = !SimplexService.isBackgroundRestricted()
override fun androidSetNightModeIfSupported() {
if (Build.VERSION.SDK_INT < 31) return
val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM.name) {
null
} else {
CurrentColors.value.colors.isLight
}
val mode = when (light) {
null -> UiModeManager.MODE_NIGHT_AUTO
true -> UiModeManager.MODE_NIGHT_NO
false -> UiModeManager.MODE_NIGHT_YES
}
val uiModeManager = androidAppContext.getSystemService(UI_MODE_SERVICE) as UiModeManager
uiModeManager.setApplicationNightMode(mode)
}
override suspend fun androidAskToAllowBackgroundCalls(): Boolean { override suspend fun androidAskToAllowBackgroundCalls(): Boolean {
if (SimplexService.isBackgroundRestricted()) { if (SimplexService.isBackgroundRestricted()) {
val userChoice: CompletableDeferred<Boolean> = CompletableDeferred() val userChoice: CompletableDeferred<Boolean> = CompletableDeferred()
@@ -30,7 +30,7 @@ object NtfManager {
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS" const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
// DO NOT change notification channel settings / names // DO NOT change notification channel settings / names
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1" const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2"
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL" const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL" const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
const val CallNotificationId: Int = -1 const val CallNotificationId: Int = -1
@@ -59,7 +59,7 @@ object NtfManager {
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build() .build()
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once) val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/ring_once")
Log.d(TAG, "callNotificationChannel sound: $soundUri") Log.d(TAG, "callNotificationChannel sound: $soundUri")
callChannel.setSound(soundUri, attrs) callChannel.setSound(soundUri, attrs)
callChannel.enableVibration(true) callChannel.enableVibration(true)
@@ -140,7 +140,7 @@ object NtfManager {
} }
} }
fun notifyCallInvitation(invitation: RcvCallInvitation) { fun notifyCallInvitation(invitation: RcvCallInvitation): Boolean {
val keyguardManager = getKeyguardManager(context) val keyguardManager = getKeyguardManager(context)
Log.d( Log.d(
TAG, TAG,
@@ -149,7 +149,7 @@ object NtfManager {
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " + "callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
"onForeground ${isAppOnForeground}" "onForeground ${isAppOnForeground}"
) )
if (isAppOnForeground) return if (isAppOnForeground) return false
val contactId = invitation.contact.id val contactId = invitation.contact.id
Log.d(TAG, "notifyCallInvitation $contactId") Log.d(TAG, "notifyCallInvitation $contactId")
val image = invitation.contact.image val image = invitation.contact.image
@@ -163,7 +163,7 @@ object NtfManager {
.setFullScreenIntent(fullScreenPendingIntent, true) .setFullScreenIntent(fullScreenPendingIntent, true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
} else { } else {
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once) val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/ring_once")
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CallChannel) NotificationCompat.Builder(context, CallChannel)
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.user.userId, invitation.contact.id)) .setContentIntent(chatPendingIntent(OpenChatAction, invitation.user.userId, invitation.contact.id))
@@ -206,6 +206,39 @@ object NtfManager {
notify(CallNotificationId, notification) notify(CallNotificationId, notification)
} }
} }
return true
}
fun showMessage(title: String, text: String) {
val builder = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setSmallIcon(R.drawable.ntf_icon)
.setLargeIcon(null)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setVibrate(null)
.setContentIntent(chatPendingIntent(ShowChatsAction, null, null))
.setSilent(false)
val summary = NotificationCompat.Builder(context, MessageChannel)
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setGroupSummary(true)
.setContentIntent(chatPendingIntent(ShowChatsAction, null))
.build()
with(NotificationManagerCompat.from(context)) {
if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
notify("MESSAGE".hashCode(), builder.build())
notify(0, summary)
}
}
} }
fun cancelCallNotification() { fun cancelCallNotification() {
@@ -248,6 +281,7 @@ object NtfManager {
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(MR.strings.ntf_channel_calls))) manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(MR.strings.ntf_channel_calls)))
// Remove old channels since they can't be edited // Remove old channels since they can't be edited
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION") manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION_1")
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION") manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
} }
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SimpleX" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:statusBarColor">@color/black</item>
<item name="android:windowBackground">@color/window_background_dark</item>
</style>
</resources>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.SimpleX" parent="android:Theme.Material.Light.NoActionBar"> <style name="Theme.SimpleX" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:statusBarColor">@color/black</item> <item name="android:statusBarColor">@color/black</item>
</style> </style>
</resources> </resources>
+61 -5
View File
@@ -110,7 +110,7 @@ android {
compileSdkVersion(34) compileSdkVersion(34)
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
defaultConfig { defaultConfig {
minSdkVersion(26) minSdkVersion(28)
targetSdkVersion(33) targetSdkVersion(33)
} }
compileOptions { compileOptions {
@@ -155,6 +155,34 @@ afterEvaluate {
val endTagRegex = Regex("</") val endTagRegex = Regex("</")
val anyHtmlRegex = Regex("[^>]*>.*(<|>).*</string>|[^>]*>.*(&lt;|&gt;).*</string>") val anyHtmlRegex = Regex("[^>]*>.*(<|>).*</string>|[^>]*>.*(&lt;|&gt;).*</string>")
val correctHtmlRegex = Regex("[^>]*>.*<b>.*</b>.*</string>|[^>]*>.*<i>.*</i>.*</string>|[^>]*>.*<u>.*</u>.*</string>|[^>]*>.*<font[^>]*>.*</font>.*</string>") val correctHtmlRegex = Regex("[^>]*>.*<b>.*</b>.*</string>|[^>]*>.*<i>.*</i>.*</string>|[^>]*>.*<u>.*</u>.*</string>|[^>]*>.*<font[^>]*>.*</font>.*</string>")
val possibleFormat = listOf("s", "d", "1\$s", "1\$d", "2s", "f")
fun String.id(): String = replace("<string name=\"", "").trim().substringBefore("\"")
fun String.formatting(filepath: String): List<String> {
if (!contains("%")) return emptyList()
val value = substringAfter("\">").substringBeforeLast("</string>")
val formats = ArrayList<String>()
var substring = value.substringAfter("%")
while (true) {
var foundFormat = false
for (format in possibleFormat) {
if (substring.startsWith(format)) {
formats.add(format)
foundFormat = true
break
}
}
if (!foundFormat) {
throw Exception("Unknown formatting in string. Add it to 'possibleFormat' in common/build.gradle.kts if needed: $this \nin $filepath")
}
val was = substring
substring = substring.substringAfter("%")
if (was.length == substring.length) break
}
return formats
}
fun String.removeCDATA(): String = fun String.removeCDATA(): String =
if (contains("<![CDATA")) { if (contains("<![CDATA")) {
@@ -195,20 +223,44 @@ afterEvaluate {
return this return this
} }
val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$") val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$")
kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath) }.asFileTree.forEach { file -> val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree
val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found")
val treeList = ArrayList(tree.toList())
treeList.remove(baseStringsFile)
treeList.add(0, baseStringsFile)
val baseFormatting = mutableMapOf<String, List<String>>()
treeList.forEachIndexed { index, file ->
val isBase = index == 0
val initialLines = ArrayList<String>() val initialLines = ArrayList<String>()
val finalLines = ArrayList<String>() val finalLines = ArrayList<String>()
val errors = ArrayList<String>()
file.useLines { lines -> file.useLines { lines ->
val multiline = ArrayList<String>() val multiline = ArrayList<String>()
lines.forEach { line -> lines.forEach { line ->
initialLines.add(line) initialLines.add(line)
if (stringRegex.matches(line)) { if (stringRegex.matches(line)) {
finalLines.add(line.removeCDATA().addCDATA(file.absolutePath)) val fixedLine = line.removeCDATA().addCDATA(file.absolutePath)
val lineId = fixedLine.id()
if (isBase) {
baseFormatting[lineId] = fixedLine.formatting(file.absolutePath)
} else if (baseFormatting[lineId] != fixedLine.formatting(file.absolutePath)) {
errors.add("Incorrect formatting in string: $fixedLine \nin ${file.absolutePath}")
}
finalLines.add(fixedLine)
} else if (multiline.isEmpty() && startStringRegex.containsMatchIn(line)) { } else if (multiline.isEmpty() && startStringRegex.containsMatchIn(line)) {
multiline.add(line) multiline.add(line)
} else if (multiline.isNotEmpty() && endStringRegex.containsMatchIn(line)) { } else if (multiline.isNotEmpty() && endStringRegex.containsMatchIn(line)) {
multiline.add(line) multiline.add(line)
finalLines.addAll(multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n")) val fixedLines = multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n")
val fixedLinesJoined = fixedLines.joinToString("")
val lineId = fixedLinesJoined.id()
if (isBase) {
baseFormatting[lineId] = fixedLinesJoined.formatting(file.absolutePath)
} else if (baseFormatting[lineId] != fixedLinesJoined.formatting(file.absolutePath)) {
errors.add("Incorrect formatting in string: $fixedLinesJoined \nin ${file.absolutePath}")
}
finalLines.addAll(fixedLines)
multiline.clear() multiline.clear()
} else if (multiline.isNotEmpty()) { } else if (multiline.isNotEmpty()) {
multiline.add(line) multiline.add(line)
@@ -217,10 +269,14 @@ afterEvaluate {
} }
} }
if (multiline.isNotEmpty()) { if (multiline.isNotEmpty()) {
throw Exception("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}") errors.add("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}")
} }
} }
if (errors.isNotEmpty()) {
throw Exception("Found errors: \n\n${errors.joinToString("\n\n")}")
}
if (!debug && finalLines != initialLines) { if (!debug && finalLines != initialLines) {
file.writer().use { file.writer().use {
finalLines.forEachIndexed { index, line -> finalLines.forEachIndexed { index, line ->
@@ -27,7 +27,6 @@ import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doOnTextChanged
import chat.simplex.common.*
import chat.simplex.common.R import chat.simplex.common.R
import chat.simplex.common.helpers.toURI import chat.simplex.common.helpers.toURI
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.ChatModel
@@ -45,6 +44,7 @@ import java.net.URI
actual fun PlatformTextField( actual fun PlatformTextField(
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
sendMsgEnabled: Boolean, sendMsgEnabled: Boolean,
sendMsgButtonDisabled: Boolean,
textStyle: MutableState<TextStyle>, textStyle: MutableState<TextStyle>,
showDeleteTextButton: MutableState<Boolean>, showDeleteTextButton: MutableState<Boolean>,
userIsObserver: Boolean, userIsObserver: Boolean,
@@ -4,14 +4,17 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.*
import android.view.* import android.view.*
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import chat.simplex.common.views.helpers.KeyboardState import chat.simplex.common.AppScreen
import chat.simplex.common.views.helpers.*
import androidx.compose.ui.platform.LocalContext as LocalContext1 import androidx.compose.ui.platform.LocalContext as LocalContext1
import chat.simplex.res.MR
actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show() actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show()
@@ -71,3 +74,44 @@ actual fun hideKeyboard(view: Any?) {
} }
actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFinishing == true) actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFinishing == true)
actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler {
actual override fun uncaughtException(thread: Thread, e: Throwable) {
Log.e(TAG, "App crashed, thread name: " + thread.name + ", exception: " + e.stackTraceToString())
includeMoreFailedComposables()
if (ModalManager.start.hasModalsOpen()) {
ModalManager.start.closeModal()
} else if (chatModel.chatId.value != null) {
// Since no modals are open, the problem is probably in ChatView
chatModel.chatId.value = null
chatModel.chatItems.clear()
} else {
// ChatList, nothing to do. Maybe to show other view except ChatList
}
chatModel.activeCall.value?.let {
withBGApi {
chatModel.callManager.endCall(it)
}
}
if (thread.name == "main") {
mainActivity.get()?.recreate()
} else {
mainActivity.get()?.apply {
window
?.decorView
?.findViewById<ViewGroup>(android.R.id.content)
?.removeViewAt(0)
setContent {
AppScreen()
}
}
}
// Wait until activity recreates to prevent showing two alerts (in case `main` was crashed)
Handler(Looper.getMainLooper()).post {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.app_was_crashed),
text = e.stackTraceToString()
)
}
}
}
@@ -370,7 +370,6 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
InfoText(call.callState.text) InfoText(call.callState.text)
val connInfo = call.connectionInfo val connInfo = call.connectionInfo
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
InfoText(call.encryptionStatus + connInfoText) InfoText(call.encryptionStatus + connInfoText)
} }
@@ -585,8 +584,8 @@ fun PreviewActiveCallOverlayVideo() {
localMedia = CallMediaType.Video, localMedia = CallMediaType.Video,
peerMedia = CallMediaType.Video, peerMedia = CallMediaType.Video,
connectionInfo = ConnectionInfo( connectionInfo = ConnectionInfo(
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null), RTCIceCandidate(RTCIceCandidateType.Host, "tcp"),
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null) RTCIceCandidate(RTCIceCandidateType.Host, "tcp")
) )
), ),
speakerCanBeEnabled = true, speakerCanBeEnabled = true,
@@ -611,8 +610,8 @@ fun PreviewActiveCallOverlayAudio() {
localMedia = CallMediaType.Audio, localMedia = CallMediaType.Audio,
peerMedia = CallMediaType.Audio, peerMedia = CallMediaType.Audio,
connectionInfo = ConnectionInfo( connectionInfo = ConnectionInfo(
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null), RTCIceCandidate(RTCIceCandidateType.Host, "udp"),
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null) RTCIceCandidate(RTCIceCandidateType.Host, "udp")
) )
), ),
speakerCanBeEnabled = true, speakerCanBeEnabled = true,
@@ -0,0 +1,7 @@
package chat.simplex.common.views.database
import chat.simplex.common.views.usersettings.restartApp
actual fun restartChatOrApp() {
restartApp()
}
@@ -0,0 +1,15 @@
package chat.simplex.common.views.onboarding
import androidx.compose.runtime.Composable
import chat.simplex.common.model.SharedPreference
import chat.simplex.common.model.User
import chat.simplex.res.MR
@Composable
actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)?) {
if (user == null) {
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick = onclick)
} else {
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick = onclick)
}
}
@@ -28,7 +28,7 @@ actual fun SettingsSectionApp(
} }
private fun restartApp() { fun restartApp() {
ProcessPhoenix.triggerRebirth(androidAppContext) ProcessPhoenix.triggerRebirth(androidAppContext)
shutdownApp() shutdownApp()
} }
@@ -65,9 +65,9 @@ extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str); extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_valid_name(const char *name); extern char *chat_valid_name(const char *name);
extern char *chat_write_file(const char *path, char *ptr, int length); extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length);
extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_read_file(const char *path, const char *key, const char *nonce);
extern char *chat_encrypt_file(const char *from_path, const char *to_path); extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path);
extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path);
JNIEXPORT jobjectArray JNICALL JNIEXPORT jobjectArray JNICALL
@@ -157,11 +157,11 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz
} }
JNIEXPORT jstring JNICALL JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jlong controller, jstring path, jobject buffer) {
const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);
jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer);
jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer);
jstring res = (*env)->NewStringUTF(env, chat_write_file(_path, buff, capacity)); jstring res = (*env)->NewStringUTF(env, chat_write_file((void*)controller, _path, buff, capacity));
(*env)->ReleaseStringUTFChars(env, path, _path); (*env)->ReleaseStringUTFChars(env, path, _path);
return res; return res;
} }
@@ -206,10 +206,10 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz,
} }
JNIEXPORT jstring JNICALL JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) { Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jlong controller, jstring from_path, jstring to_path) {
const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE); const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE);
const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_encrypt_file(_from_path, _to_path)); jstring res = (*env)->NewStringUTF(env, chat_encrypt_file((void*)controller, _from_path, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path); (*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res; return res;
@@ -71,7 +71,7 @@ if(NOT APPLE)
else() else()
# Without direct linking it can't find hs_init in linking step # Without direct linking it can't find hs_init in linking step
add_library( rts SHARED IMPORTED ) add_library( rts SHARED IMPORTED )
FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSrts*_thr-*.${OS_LIB_EXT}) FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/deps/libHSrts*_thr-*.${OS_LIB_EXT})
set_target_properties( rts PROPERTIES IMPORTED_LOCATION ${RTSLIB}) set_target_properties( rts PROPERTIES IMPORTED_LOCATION ${RTSLIB})
target_link_libraries(app-lib rts simplex) target_link_libraries(app-lib rts simplex)
@@ -38,9 +38,9 @@ extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str); extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_valid_name(const char *name); extern char *chat_valid_name(const char *name);
extern char *chat_write_file(const char *path, char *ptr, int length); extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length);
extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_read_file(const char *path, const char *key, const char *nonce);
extern char *chat_encrypt_file(const char *from_path, const char *to_path); extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path);
extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path);
// As a reference: https://stackoverflow.com/a/60002045 // As a reference: https://stackoverflow.com/a/60002045
@@ -167,11 +167,11 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz
} }
JNIEXPORT jstring JNICALL JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jlong controller, jstring path, jobject buffer) {
const char *_path = encode_to_utf8_chars(env, path); const char *_path = encode_to_utf8_chars(env, path);
jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer);
jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer);
jstring res = decode_to_utf8_string(env, chat_write_file(_path, buff, capacity)); jstring res = decode_to_utf8_string(env, chat_write_file((void*)controller, _path, buff, capacity));
(*env)->ReleaseStringUTFChars(env, path, _path); (*env)->ReleaseStringUTFChars(env, path, _path);
return res; return res;
} }
@@ -216,10 +216,10 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz,
} }
JNIEXPORT jstring JNICALL JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) { Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jlong controller, jstring from_path, jstring to_path) {
const char *_from_path = encode_to_utf8_chars(env, from_path); const char *_from_path = encode_to_utf8_chars(env, from_path);
const char *_to_path = encode_to_utf8_chars(env, to_path); const char *_to_path = encode_to_utf8_chars(env, to_path);
jstring res = decode_to_utf8_string(env, chat_encrypt_file(_from_path, _to_path)); jstring res = decode_to_utf8_string(env, chat_encrypt_file((void*)controller, _from_path, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path); (*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res; return res;
@@ -37,17 +37,18 @@ import kotlinx.coroutines.flow.*
data class SettingsViewState( data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>, val userPickerState: MutableStateFlow<AnimatedViewState>,
val scaffoldState: ScaffoldState, val scaffoldState: ScaffoldState
val switchingUsersAndHosts: MutableState<Boolean>
) )
@Composable @Composable
fun AppScreen() { fun AppScreen() {
SimpleXTheme {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Surface(color = MaterialTheme.colors.background) { Surface(color = MaterialTheme.colors.background) {
MainScreen() MainScreen()
} }
} }
}
} }
@Composable @Composable
@@ -102,11 +103,8 @@ fun MainScreen() {
} }
Box { Box {
var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) } val onboarding by remember { chatModel.controller.appPrefs.onboardingStage.state }
LaunchedEffect(Unit) { val localUserCreated = chatModel.localUserCreated.value
snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it }
}
val userCreated = chatModel.userCreated.value
var showInitializationView by remember { mutableStateOf(false) } var showInitializationView by remember { mutableStateOf(false) }
when { when {
chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView() chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView()
@@ -115,14 +113,18 @@ fun MainScreen() {
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
} }
} }
remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView() remember { chatModel.chatDbEncrypted }.value == null || localUserCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated -> { onboarding == OnboardingStage.OnboardingComplete -> {
Box { Box {
showAdvertiseLAAlert = true showAdvertiseLAAlert = true
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(if (chatModel.desktopNoUserNoRemote()) AnimatedViewState.VISIBLE else AnimatedViewState.GONE)) }
KeyChangeEffect(chatModel.desktopNoUserNoRemote) {
if (chatModel.desktopNoUserNoRemote() && !ModalManager.start.hasModalsOpen()) {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
val scaffoldState = rememberScaffoldState() val scaffoldState = rememberScaffoldState()
val switchingUsersAndHosts = rememberSaveable { mutableStateOf(false) } val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) }
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsersAndHosts) }
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
AndroidScreen(settingsState) AndroidScreen(settingsState)
} else { } else {
@@ -137,12 +139,14 @@ fun MainScreen() {
} }
} }
onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
onboarding == OnboardingStage.LinkAMobile -> LinkAMobile()
onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null) onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null)
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
} }
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
ModalManager.fullscreen.showInView() ModalManager.fullscreen.showInView()
SwitchingUsersView()
} }
val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } } val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } }
@@ -158,11 +162,26 @@ fun MainScreen() {
AuthView() AuthView()
} else { } else {
SplashView() SplashView()
}
} else if (chatModel.showCallView.value) {
ActiveCallView()
}
ModalManager.fullscreen.showPasscodeInView() ModalManager.fullscreen.showPasscodeInView()
}
} else {
if (chatModel.showCallView.value) {
ActiveCallView()
} else {
// It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked
ModalManager.fullscreen.showPasscodeInView()
}
AlertManager.privacySensitive.showInView()
if (onboarding == OnboardingStage.OnboardingComplete) {
LaunchedEffect(chatModel.currentUser.value, chatModel.appOpenUrl.value) {
val (rhId, url) = chatModel.appOpenUrl.value ?: (null to null)
if (url != null) {
chatModel.appOpenUrl.value = null
connectIfOpenedViaUri(rhId, url, chatModel)
}
}
}
}
val invitation = chatModel.activeCallInvitation.value val invitation = chatModel.activeCallInvitation.value
if (invitation != null) IncomingCallAlertView(invitation, chatModel) if (invitation != null) IncomingCallAlertView(invitation, chatModel)
AlertManager.shared.showInView() AlertManager.shared.showInView()
@@ -262,7 +281,7 @@ fun CenterPartOfScreen() {
.background(MaterialTheme.colors.background), .background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(stringResource(MR.strings.no_selected_chat)) Text(stringResource(if (chatModel.desktopNoUserNoRemote) MR.strings.no_connected_mobile else MR.strings.no_selected_chat))
} }
} else { } else {
ModalManager.center.showInView() ModalManager.center.showInView()
@@ -286,6 +305,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
} }
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) { Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) {
ModalManager.start.showInView() ModalManager.start.showInView()
SwitchingUsersView()
} }
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) { Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) { Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
@@ -298,7 +318,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
EndPartOfScreen() EndPartOfScreen()
} }
} }
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState val (userPickerState, scaffoldState ) = settingsState
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen) { if (scaffoldState.drawerState.isOpen) {
Box( Box(
@@ -312,10 +332,12 @@ fun DesktopScreen(settingsState: SettingsViewState) {
) )
} }
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH)) VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH))
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE userPickerState.value = AnimatedViewState.GONE
} }
}
ModalManager.fullscreen.showInView() ModalManager.fullscreen.showInView()
} }
} }
@@ -335,3 +357,26 @@ fun InitializationView() {
} }
} }
} }
@Composable
private fun SwitchingUsersView() {
if (remember { chatModel.switchingUsersAndHosts }.value) {
Box(
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
ProgressIndicator()
}
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
)
}
@@ -2,7 +2,6 @@ package chat.simplex.common.model
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@@ -43,7 +42,7 @@ object ChatModel {
val setDeliveryReceipts = mutableStateOf(false) val setDeliveryReceipts = mutableStateOf(false)
val currentUser = mutableStateOf<User?>(null) val currentUser = mutableStateOf<User?>(null)
val users = mutableStateListOf<UserInfo>() val users = mutableStateListOf<UserInfo>()
val userCreated = mutableStateOf<Boolean?>(null) val localUserCreated = mutableStateOf<Boolean?>(null)
val chatRunning = mutableStateOf<Boolean?>(null) val chatRunning = mutableStateOf<Boolean?>(null)
val chatDbChanged = mutableStateOf<Boolean>(false) val chatDbChanged = mutableStateOf<Boolean>(false)
val chatDbEncrypted = mutableStateOf<Boolean?>(false) val chatDbEncrypted = mutableStateOf<Boolean?>(false)
@@ -51,6 +50,7 @@ object ChatModel {
val chats = mutableStateListOf<Chat>() val chats = mutableStateListOf<Chat>()
// map of connections network statuses, key is agent connection id // map of connections network statuses, key is agent connection id
val networkStatuses = mutableStateMapOf<String, NetworkStatus>() val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
val switchingUsersAndHosts = mutableStateOf(false)
// current chat // current chat
val chatId = mutableStateOf<String?>(null) val chatId = mutableStateOf<String?>(null)
@@ -58,7 +58,7 @@ object ChatModel {
val chatItemStatuses = mutableMapOf<Long, CIStatus>() val chatItemStatuses = mutableMapOf<Long, CIStatus>()
val groupMembers = mutableStateListOf<GroupMember>() val groupMembers = mutableStateListOf<GroupMember>()
val terminalItems = mutableStateListOf<TerminalItem>() val terminalItems = mutableStateOf<List<TerminalItem>>(listOf())
val userAddress = mutableStateOf<UserContactLinkRec?>(null) val userAddress = mutableStateOf<UserContactLinkRec?>(null)
// Allows to temporary save servers that are being edited on multiple screens // Allows to temporary save servers that are being edited on multiple screens
val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null) val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null)
@@ -67,8 +67,11 @@ object ChatModel {
// set when app opened from external intent // set when app opened from external intent
val clearOverlays = mutableStateOf<Boolean>(false) val clearOverlays = mutableStateOf<Boolean>(false)
// set when app is opened via contact or invitation URI // Only needed during onboarding when user skipped password setup (left as random password)
val appOpenUrl = mutableStateOf<URI?>(null) val desktopOnboardingRandomPassword = mutableStateOf(false)
// set when app is opened via contact or invitation URI (rhId, uri)
val appOpenUrl = mutableStateOf<Pair<Long?, URI>?>(null)
// preferences // preferences
val notificationPreviewMode by lazy { val notificationPreviewMode by lazy {
@@ -108,6 +111,9 @@ object ChatModel {
var updatingChatsMutex: Mutex = Mutex() var updatingChatsMutex: Mutex = Mutex()
val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
fun desktopNoUserNoRemote(): Boolean = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
// remote controller // remote controller
val remoteHosts = mutableStateListOf<RemoteHostInfo>() val remoteHosts = mutableStateListOf<RemoteHostInfo>()
val currentRemoteHost = mutableStateOf<RemoteHostInfo?>(null) val currentRemoteHost = mutableStateOf<RemoteHostInfo?>(null)
@@ -116,6 +122,9 @@ object ChatModel {
val remoteHostPairing = mutableStateOf<Pair<RemoteHostInfo?, RemoteHostSessionState>?>(null) val remoteHostPairing = mutableStateOf<Pair<RemoteHostInfo?, RemoteHostSessionState>?>(null)
val remoteCtrlSession = mutableStateOf<RemoteCtrlSession?>(null) val remoteCtrlSession = mutableStateOf<RemoteCtrlSession?>(null)
val processedCriticalError: ProcessedErrors<AgentErrorType.CRITICAL> = ProcessedErrors(60_000)
val processedInternalError: ProcessedErrors<AgentErrorType.INTERNAL> = ProcessedErrors(20_000)
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
currentUser.value currentUser.value
} else { } else {
@@ -222,8 +231,23 @@ object ChatModel {
val chat: Chat val chat: Chat
if (i >= 0) { if (i >= 0) {
chat = chats[i] chat = chats[i]
val newPreviewItem = when (cInfo) {
is ChatInfo.Group -> {
val currentPreviewItem = chat.chatItems.firstOrNull()
if (currentPreviewItem != null) {
if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) {
cItem
} else {
currentPreviewItem
}
} else {
cItem
}
}
else -> cItem
}
chats[i] = chat.copy( chats[i] = chat.copy(
chatItems = arrayListOf(cItem), chatItems = arrayListOf(newPreviewItem),
chatStats = chatStats =
if (cItem.meta.itemStatus is CIStatus.RcvNew) { if (cItem.meta.itemStatus is CIStatus.RcvNew) {
val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId
@@ -599,12 +623,13 @@ object ChatModel {
} }
fun addTerminalItem(item: TerminalItem) { fun addTerminalItem(item: TerminalItem) {
if (terminalItems.size >= 500) { if (terminalItems.value.size >= 500) {
terminalItems.removeAt(0) terminalItems.value = terminalItems.value.subList(1, terminalItems.value.size)
} }
terminalItems.add(item) terminalItems.value += item
} }
val connectedToRemote: Boolean @Composable get() = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
} }
@@ -2945,6 +2970,14 @@ sealed class RemoteCtrlSessionState {
@Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState() @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState()
} }
@Serializable
sealed class RemoteCtrlStopReason {
@Serializable @SerialName("discoveryFailed") class DiscoveryFailed(val chatError: ChatError): RemoteCtrlStopReason()
@Serializable @SerialName("connectionFailed") class ConnectionFailed(val chatError: ChatError): RemoteCtrlStopReason()
@Serializable @SerialName("setupFailed") class SetupFailed(val chatError: ChatError): RemoteCtrlStopReason()
@Serializable @SerialName("disconnected") object Disconnected: RemoteCtrlStopReason()
}
sealed class UIRemoteCtrlSessionState { sealed class UIRemoteCtrlSessionState {
object Starting: UIRemoteCtrlSessionState() object Starting: UIRemoteCtrlSessionState()
object Searching: UIRemoteCtrlSessionState() object Searching: UIRemoteCtrlSessionState()
@@ -21,10 +21,11 @@ sealed class WriteFileResult {
* */ * */
fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs {
val ctrl = ChatController.ctrl ?: throw Exception("Controller is not initialized")
val buffer = ByteBuffer.allocateDirect(data.size) val buffer = ByteBuffer.allocateDirect(data.size)
buffer.put(data) buffer.put(data)
buffer.rewind() buffer.rewind()
val str = chatWriteFile(path, buffer) val str = chatWriteFile(ctrl, path, buffer)
return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) { return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) {
is WriteFileResult.Result -> d.cryptoArgs is WriteFileResult.Result -> d.cryptoArgs
is WriteFileResult.Error -> throw Exception(d.writeError) is WriteFileResult.Error -> throw Exception(d.writeError)
@@ -43,7 +44,8 @@ fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray {
} }
fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs { fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs {
val str = chatEncryptFile(fromPath, toPath) val ctrl = ChatController.ctrl ?: throw Exception("Controller is not initialized")
val str = chatEncryptFile(ctrl, fromPath, toPath)
val d = json.decodeFromString(WriteFileResult.serializer(), str) val d = json.decodeFromString(WriteFileResult.serializer(), str)
return when (d) { return when (d) {
is WriteFileResult.Result -> d.cryptoArgs is WriteFileResult.Result -> d.cryptoArgs
@@ -108,6 +108,7 @@ class AppPreferences {
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false) val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
val showInternalErrors = mkBoolPreference(SHARED_PREFS_SHOW_INTERNAL_ERRORS, false)
val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false) val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false)
val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false) val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050") val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050")
@@ -173,6 +174,8 @@ class AppPreferences {
val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true) val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true)
val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true)
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
private fun mkIntPreference(prefName: String, default: Int) = private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference( SharedPreference(
get = fun() = settings.getInt(prefName, default), get = fun() = settings.getInt(prefName, default),
@@ -274,6 +277,7 @@ class AppPreferences {
private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage" private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage"
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
private const val SHARED_PREFS_SHOW_INTERNAL_ERRORS = "ShowInternalErrors"
private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible" private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible"
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy" private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort" private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort"
@@ -317,6 +321,7 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast" private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast"
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
} }
} }
@@ -359,7 +364,7 @@ object ChatController {
chatModel.users.addAll(users) chatModel.users.addAll(users)
if (justStarted) { if (justStarted) {
chatModel.currentUser.value = user chatModel.currentUser.value = user
chatModel.userCreated.value = true chatModel.localUserCreated.value = true
getUserChatData(null) getUserChatData(null)
appPrefs.chatLastStart.set(Clock.System.now()) appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true chatModel.chatRunning.value = true
@@ -379,6 +384,31 @@ object ChatController {
} }
} }
suspend fun startChatWithoutUser() {
Log.d(TAG, "user: null")
try {
if (chatModel.chatRunning.value == true) return
apiSetTempFolder(coreTmpDir.absolutePath)
apiSetFilesFolder(appFilesDir.absolutePath)
if (appPlatform.isDesktop) {
apiSetRemoteHostsFolder(remoteHostsDir.absolutePath)
}
apiSetXFTPConfig(getXFTPCfg())
apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get())
chatModel.users.clear()
chatModel.currentUser.value = null
chatModel.localUserCreated.value = false
appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
startReceiver()
setLocalDeviceName(appPrefs.deviceNameForRemoteAccess.get()!!)
Log.d(TAG, "startChat: started without user")
} catch (e: Error) {
Log.e(TAG, "failed starting chat without user $e")
throw e
}
}
suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) { suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) {
try { try {
changeActiveUser_(rhId, toUserId, viewPwd) changeActiveUser_(rhId, toUserId, viewPwd)
@@ -402,8 +432,9 @@ object ChatController {
} }
suspend fun getUserChatData(rhId: Long?) { suspend fun getUserChatData(rhId: Long?) {
chatModel.userAddress.value = apiGetUserAddress(rhId) val hasUser = chatModel.currentUser.value != null
chatModel.chatItemTTL.value = getChatItemTTL(rhId) chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null
chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None
updatingChatsMutex.withLock { updatingChatsMutex.withLock {
val chats = apiGetChats(rhId) val chats = apiGetChats(rhId)
chatModel.updateChats(chats) chatModel.updateChats(chats)
@@ -472,7 +503,9 @@ object ChatController {
val r = sendCmd(rh, CC.ShowActiveUser()) val r = sendCmd(rh, CC.ShowActiveUser())
if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh)
Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}")
chatModel.userCreated.value = false if (rh == null) {
chatModel.localUserCreated.value = false
}
return null return null
} }
@@ -552,7 +585,7 @@ object ChatController {
} }
suspend fun apiStartChat(): Boolean { suspend fun apiStartChat(): Boolean {
val r = sendCmd(null, CC.StartChat(expire = true)) val r = sendCmd(null, CC.StartChat(mainApp = true))
when (r) { when (r) {
is CR.ChatStarted -> return true is CR.ChatStarted -> return true
is CR.ChatRunning -> return false is CR.ChatRunning -> return false
@@ -891,20 +924,21 @@ object ChatController {
return null return null
} }
suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): Boolean { suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): PendingContactConnection? {
val userId = chatModel.currentUser.value?.userId ?: run { val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiConnect: no current user") Log.e(TAG, "apiConnect: no current user")
return false return null
} }
val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq)) val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq))
when { when {
r is CR.SentConfirmation || r is CR.SentInvitation -> return true r is CR.SentConfirmation -> return r.connection
r is CR.SentInvitation -> return r.connection
r is CR.ContactAlreadyExists -> { r is CR.ContactAlreadyExists -> {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.contact_already_exists), generalGetString(MR.strings.contact_already_exists),
String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.contact.displayName) String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.contact.displayName)
) )
return false return null
} }
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
&& r.chatError.errorType is ChatErrorType.InvalidConnReq -> { && r.chatError.errorType is ChatErrorType.InvalidConnReq -> {
@@ -912,7 +946,7 @@ object ChatController {
generalGetString(MR.strings.invalid_connection_link), generalGetString(MR.strings.invalid_connection_link),
generalGetString(MR.strings.please_check_correct_link_and_maybe_ask_for_a_new_one) generalGetString(MR.strings.please_check_correct_link_and_maybe_ask_for_a_new_one)
) )
return false return null
} }
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
&& r.chatError.agentError is AgentErrorType.SMP && r.chatError.agentError is AgentErrorType.SMP
@@ -921,13 +955,13 @@ object ChatController {
generalGetString(MR.strings.connection_error_auth), generalGetString(MR.strings.connection_error_auth),
generalGetString(MR.strings.connection_error_auth_desc) generalGetString(MR.strings.connection_error_auth_desc)
) )
return false return null
} }
else -> { else -> {
if (!(networkErrorAlert(r))) { if (!(networkErrorAlert(r))) {
apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r) apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r)
} }
return false return null
} }
} }
} }
@@ -1394,9 +1428,9 @@ object ChatController {
chatModel.remoteHosts.addAll(hosts) chatModel.remoteHosts.addAll(hosts)
} }
suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = true): Triple<RemoteHostInfo?, String, String>? { suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = true, address: RemoteCtrlAddress?, port: Int?): CR.RemoteHostStarted? {
val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast)) val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast, address, port))
if (r is CR.RemoteHostStarted) return Triple(r.remoteHost_, r.invitation, r.ctrlPort) if (r is CR.RemoteHostStarted) return r
apiErrorAlert("startRemoteHost", generalGetString(MR.strings.error_alert_title), r) apiErrorAlert("startRemoteHost", generalGetString(MR.strings.error_alert_title), r)
return null return null
} }
@@ -1526,16 +1560,6 @@ object ChatController {
fun active(user: UserLike): Boolean = activeUser(rhId, user) fun active(user: UserLike): Boolean = activeUser(rhId, user)
chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
when (r) { when (r) {
is CR.NewContactConnection -> {
if (active(r.user)) {
chatModel.updateContactConnection(rhId, r.connection)
}
}
is CR.ContactConnectionDeleted -> {
if (active(r.user)) {
chatModel.removeChat(rhId, r.connection.id)
}
}
is CR.ContactDeletedByContact -> { is CR.ContactDeletedByContact -> {
if (active(r.user) && r.contact.directOrUsed) { if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(rhId, r.contact) chatModel.updateContact(rhId, r.contact)
@@ -1898,6 +1922,14 @@ object ChatController {
} }
} }
} }
is CR.ChatCmdError -> when {
r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.CRITICAL -> {
chatModel.processedCriticalError.newError(r.chatError.agentError, r.chatError.agentError.offerRestart)
}
r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.INTERNAL && appPrefs.showInternalErrors.get() -> {
chatModel.processedInternalError.newError(r.chatError.agentError, false)
}
}
else -> else ->
Log.d(TAG , "unsupported event: ${r.responseType}") Log.d(TAG , "unsupported event: ${r.responseType}")
} }
@@ -1996,12 +2028,13 @@ object ChatController {
chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err))
} }
suspend fun switchUIRemoteHost(rhId: Long?) { suspend fun switchUIRemoteHost(rhId: Long?) = showProgressIfNeeded {
// TODO lock the switch so that two switches can't run concurrently? // TODO lock the switch so that two switches can't run concurrently?
chatModel.chatId.value = null chatModel.chatId.value = null
ModalManager.center.closeModals() ModalManager.center.closeModals()
ModalManager.end.closeModals() ModalManager.end.closeModals()
AlertManager.shared.alertViews.clear() AlertManager.shared.hideAllAlerts()
AlertManager.privacySensitive.hideAllAlerts()
chatModel.currentRemoteHost.value = switchRemoteHost(rhId) chatModel.currentRemoteHost.value = switchRemoteHost(rhId)
reloadRemoteHosts() reloadRemoteHosts()
val user = apiGetActiveUser(rhId) val user = apiGetActiveUser(rhId)
@@ -2009,7 +2042,10 @@ object ChatController {
chatModel.users.clear() chatModel.users.clear()
chatModel.users.addAll(users) chatModel.users.addAll(users)
chatModel.currentUser.value = user chatModel.currentUser.value = user
chatModel.userCreated.value = true if (user == null) {
chatModel.chatItems.clear()
chatModel.chats.clear()
}
val statuses = apiGetNetworkStatuses(rhId) val statuses = apiGetNetworkStatuses(rhId)
if (statuses != null) { if (statuses != null) {
chatModel.networkStatuses.clear() chatModel.networkStatuses.clear()
@@ -2019,6 +2055,23 @@ object ChatController {
getUserChatData(rhId) getUserChatData(rhId)
} }
suspend fun showProgressIfNeeded(block: suspend () -> Unit) {
val job = withBGApi {
try {
delay(500)
chatModel.switchingUsersAndHosts.value = true
} catch (e: Throwable) {
chatModel.switchingUsersAndHosts.value = false
}
}
try {
block()
} finally {
job.cancel()
chatModel.switchingUsersAndHosts.value = false
}
}
fun getXFTPCfg(): XFTPFileConfig { fun getXFTPCfg(): XFTPFileConfig {
return XFTPFileConfig(minFileSize = 0) return XFTPFileConfig(minFileSize = 0)
} }
@@ -2118,7 +2171,7 @@ sealed class CC {
class ApiMuteUser(val userId: Long): CC() class ApiMuteUser(val userId: Long): CC()
class ApiUnmuteUser(val userId: Long): CC() class ApiUnmuteUser(val userId: Long): CC()
class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean, val viewPwd: String?): CC() class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean, val viewPwd: String?): CC()
class StartChat(val expire: Boolean): CC() class StartChat(val mainApp: Boolean): CC()
class ApiStopChat: CC() class ApiStopChat: CC()
class SetTempFolder(val tempFolder: String): CC() class SetTempFolder(val tempFolder: String): CC()
class SetFilesFolder(val filesFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC()
@@ -2206,7 +2259,7 @@ sealed class CC {
// Remote control // Remote control
class SetLocalDeviceName(val displayName: String): CC() class SetLocalDeviceName(val displayName: String): CC()
class ListRemoteHosts(): CC() class ListRemoteHosts(): CC()
class StartRemoteHost(val remoteHostId: Long?, val multicast: Boolean): CC() class StartRemoteHost(val remoteHostId: Long?, val multicast: Boolean, val address: RemoteCtrlAddress?, val port: Int?): CC()
class SwitchRemoteHost (val remoteHostId: Long?): CC() class SwitchRemoteHost (val remoteHostId: Long?): CC()
class StopRemoteHost(val remoteHostKey: Long?): CC() class StopRemoteHost(val remoteHostKey: Long?): CC()
class DeleteRemoteHost(val remoteHostId: Long): CC() class DeleteRemoteHost(val remoteHostId: Long): CC()
@@ -2245,7 +2298,7 @@ sealed class CC {
is ApiMuteUser -> "/_mute user $userId" is ApiMuteUser -> "/_mute user $userId"
is ApiUnmuteUser -> "/_unmute user $userId" is ApiUnmuteUser -> "/_unmute user $userId"
is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}${maybePwd(viewPwd)}" is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}${maybePwd(viewPwd)}"
is StartChat -> "/_start subscribe=on expire=${onOff(expire)} xftp=on" is StartChat -> "/_start main=${onOff(mainApp)}"
is ApiStopChat -> "/_stop" is ApiStopChat -> "/_stop"
is SetTempFolder -> "/_temp_folder $tempFolder" is SetTempFolder -> "/_temp_folder $tempFolder"
is SetFilesFolder -> "/_files_folder $filesFolder" is SetFilesFolder -> "/_files_folder $filesFolder"
@@ -2342,7 +2395,7 @@ sealed class CC {
is CancelFile -> "/fcancel $fileId" is CancelFile -> "/fcancel $fileId"
is SetLocalDeviceName -> "/set device name $displayName" is SetLocalDeviceName -> "/set device name $displayName"
is ListRemoteHosts -> "/list remote hosts" is ListRemoteHosts -> "/list remote hosts"
is StartRemoteHost -> "/start remote host " + if (remoteHostId == null) "new" else "$remoteHostId multicast=${onOff(multicast)}" is StartRemoteHost -> "/start remote host " + (if (remoteHostId == null) "new" else "$remoteHostId multicast=${onOff(multicast)}") + (if (address != null) " addr=${address.address} iface=${address.`interface`}" else "") + (if (port != null) " port=$port" else "")
is SwitchRemoteHost -> "/switch remote host " + if (remoteHostId == null) "local" else "$remoteHostId" is SwitchRemoteHost -> "/switch remote host " + if (remoteHostId == null) "local" else "$remoteHostId"
is StopRemoteHost -> "/stop remote host " + if (remoteHostKey == null) "new" else "$remoteHostKey" is StopRemoteHost -> "/stop remote host " + if (remoteHostKey == null) "new" else "$remoteHostKey"
is DeleteRemoteHost -> "/delete remote host $remoteHostId" is DeleteRemoteHost -> "/delete remote host $remoteHostId"
@@ -2758,9 +2811,9 @@ data class NetCfg(
hostMode = HostMode.OnionViaSocks, hostMode = HostMode.OnionViaSocks,
requiredHostMode = false, requiredHostMode = false,
sessionMode = TransportSessionMode.User, sessionMode = TransportSessionMode.User,
tcpConnectTimeout = 15_000_000, tcpConnectTimeout = 20_000_000,
tcpTimeout = 10_000_000, tcpTimeout = 15_000_000,
tcpTimeoutPerKb = 30_000, tcpTimeoutPerKb = 45_000,
tcpKeepAlive = KeepAliveOpts.defaults, tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 1200_000_000, smpPingInterval = 1200_000_000,
smpPingCount = 3 smpPingCount = 3
@@ -3564,6 +3617,8 @@ data class RemoteHostInfo(
val remoteHostId: Long, val remoteHostId: Long,
val hostDeviceName: String, val hostDeviceName: String,
val storePath: String, val storePath: String,
val bindAddress_: RemoteCtrlAddress?,
val bindPort_: Int?,
val sessionState: RemoteHostSessionState? val sessionState: RemoteHostSessionState?
) { ) {
val activeHost: Boolean val activeHost: Boolean
@@ -3572,6 +3627,12 @@ data class RemoteHostInfo(
fun activeHost(): Boolean = chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId fun activeHost(): Boolean = chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId
} }
@Serializable
data class RemoteCtrlAddress(
val address: String,
val `interface`: String
)
@Serializable @Serializable
sealed class RemoteHostSessionState { sealed class RemoteHostSessionState {
@Serializable @SerialName("starting") object Starting: RemoteHostSessionState() @Serializable @SerialName("starting") object Starting: RemoteHostSessionState()
@@ -3581,6 +3642,13 @@ sealed class RemoteHostSessionState {
@Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteHostSessionState() @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteHostSessionState()
} }
@Serializable
sealed class RemoteHostStopReason {
@Serializable @SerialName("connectionFailed") data class ConnectionFailed(val chatError: ChatError): RemoteHostStopReason()
@Serializable @SerialName("crashed") data class Crashed(val chatError: ChatError): RemoteHostStopReason()
@Serializable @SerialName("disconnected") object Disconnected: RemoteHostStopReason()
}
val json = Json { val json = Json {
prettyPrint = true prettyPrint = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -3700,8 +3768,8 @@ sealed class CR {
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR() @Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR()
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR()
@@ -3795,16 +3863,15 @@ sealed class CR {
@Serializable @SerialName("callAnswer") class CallAnswer(val user: UserRef, val contact: Contact, val answer: WebRTCSession): CR() @Serializable @SerialName("callAnswer") class CallAnswer(val user: UserRef, val contact: Contact, val answer: WebRTCSession): CR()
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: UserRef, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: UserRef, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
@Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR()
// remote events (desktop) // remote events (desktop)
@Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List<RemoteHostInfo>): CR() @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List<RemoteHostInfo>): CR()
@Serializable @SerialName("currentRemoteHost") class CurrentRemoteHost(val remoteHost_: RemoteHostInfo?): CR() @Serializable @SerialName("currentRemoteHost") class CurrentRemoteHost(val remoteHost_: RemoteHostInfo?): CR()
@Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String, val ctrlPort: String): CR() @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String, val localAddrs: List<RemoteCtrlAddress>, val ctrlPort: String): CR()
@Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: String): CR() @Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: String): CR()
@Serializable @SerialName("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR()
@Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR()
@Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?): CR() @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?, val rhsState: RemoteHostSessionState, val rhStopReason: RemoteHostStopReason): CR()
@Serializable @SerialName("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR() @Serializable @SerialName("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR()
// remote events (mobile) // remote events (mobile)
@Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List<RemoteCtrlInfo>): CR() @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List<RemoteCtrlInfo>): CR()
@@ -3812,7 +3879,7 @@ sealed class CR {
@Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl_: RemoteCtrlInfo?, val ctrlAppInfo: CtrlAppInfo, val appVersion: String): CR() @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl_: RemoteCtrlInfo?, val ctrlAppInfo: CtrlAppInfo, val appVersion: String): CR()
@Serializable @SerialName("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR() @Serializable @SerialName("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR()
@Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR() @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR()
@Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR() @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(val rcsState: RemoteCtrlSessionState, val rcStopReason: RemoteCtrlStopReason): CR()
@Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List<UpMigration>, val agentMigrations: List<UpMigration>): CR() @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List<UpMigration>, val agentMigrations: List<UpMigration>): CR()
@Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR()
@@ -3944,7 +4011,6 @@ sealed class CR {
is CallAnswer -> "callAnswer" is CallAnswer -> "callAnswer"
is CallExtraInfo -> "callExtraInfo" is CallExtraInfo -> "callExtraInfo"
is CallEnded -> "callEnded" is CallEnded -> "callEnded"
is NewContactConnection -> "newContactConnection"
is ContactConnectionDeleted -> "contactConnectionDeleted" is ContactConnectionDeleted -> "contactConnectionDeleted"
is RemoteHostList -> "remoteHostList" is RemoteHostList -> "remoteHostList"
is CurrentRemoteHost -> "currentRemoteHost" is CurrentRemoteHost -> "currentRemoteHost"
@@ -3999,11 +4065,11 @@ sealed class CR {
is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode")
is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode")
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
is Invitation -> withUser(user, connReqInvitation) is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection")
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan))
is SentConfirmation -> withUser(user, noDetails()) is SentConfirmation -> withUser(user, json.encodeToString(connection))
is SentInvitation -> withUser(user, noDetails()) is SentInvitation -> withUser(user, json.encodeToString(connection))
is SentInvitationToContact -> withUser(user, json.encodeToString(contact)) is SentInvitationToContact -> withUser(user, json.encodeToString(contact))
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact)) is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact))
@@ -4091,7 +4157,6 @@ sealed class CR {
is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}") is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}")
is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}") is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}")
is CallEnded -> withUser(user, "contact: ${contact.id}") is CallEnded -> withUser(user, "contact: ${contact.id}")
is NewContactConnection -> withUser(user, json.encodeToString(connection))
is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection))
// remote events (mobile) // remote events (mobile)
is RemoteHostList -> json.encodeToString(remoteHosts) is RemoteHostList -> json.encodeToString(remoteHosts)
@@ -4655,6 +4720,7 @@ sealed class AgentErrorType {
is AGENT -> "AGENT ${agentErr.string}" is AGENT -> "AGENT ${agentErr.string}"
is INTERNAL -> "INTERNAL $internalErr" is INTERNAL -> "INTERNAL $internalErr"
is INACTIVE -> "INACTIVE" is INACTIVE -> "INACTIVE"
is CRITICAL -> "CRITICAL $offerRestart $criticalErr"
} }
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType() @Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType()
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType() @Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType()
@@ -4666,6 +4732,7 @@ sealed class AgentErrorType {
@Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
@Serializable @SerialName("INACTIVE") object INACTIVE: AgentErrorType() @Serializable @SerialName("INACTIVE") object INACTIVE: AgentErrorType()
@Serializable @SerialName("CRITICAL") data class CRITICAL(val offerRestart: Boolean, val criticalErr: String): AgentErrorType()
} }
@Serializable @Serializable
@@ -22,9 +22,9 @@ external fun chatParseMarkdown(str: String): String
external fun chatParseServer(str: String): String external fun chatParseServer(str: String): String
external fun chatPasswordHash(pwd: String, salt: String): String external fun chatPasswordHash(pwd: String, salt: String): String
external fun chatValidName(name: String): String external fun chatValidName(name: String): String
external fun chatWriteFile(path: String, buffer: ByteBuffer): String external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String
external fun chatReadFile(path: String, key: String, nonce: String): Array<Any> external fun chatReadFile(path: String, key: String, nonce: String): Array<Any>
external fun chatEncryptFile(fromPath: String, toPath: String): String external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String
external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String
val chatModel: ChatModel val chatModel: ChatModel
@@ -55,10 +55,22 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
val user = chatController.apiGetActiveUser(null) val user = chatController.apiGetActiveUser(null)
if (user == null) { if (user == null) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
chatModel.currentUser.value = null chatModel.currentUser.value = null
chatModel.users.clear() chatModel.users.clear()
if (appPlatform.isDesktop) {
/**
* Setting it here to null because otherwise the screen will flash in [MainScreen] after the first start
* because of default value of [OnboardingStage.OnboardingComplete]
* */
chatModel.localUserCreated.value = null
if (chatController.listRemoteHosts()?.isEmpty() == true) {
chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
}
chatController.startChatWithoutUser()
} else {
chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
}
} else { } else {
val savedOnboardingStage = appPreferences.onboardingStage.get() val savedOnboardingStage = appPreferences.onboardingStage.get()
appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
@@ -59,8 +59,10 @@ abstract class NtfManager {
awaitChatStartedIfNeeded(chatModel) awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications? // TODO include remote host ID in desktop notifications?
chatModel.controller.showProgressIfNeeded {
chatModel.controller.changeActiveUser(null, userId, null) chatModel.controller.changeActiveUser(null, userId, null)
} }
}
val cInfo = chatModel.getChat(chatId)?.chatInfo val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true chatModel.clearOverlays.value = true
if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo, chatModel) if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo, chatModel)
@@ -72,8 +74,10 @@ abstract class NtfManager {
awaitChatStartedIfNeeded(chatModel) awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications? // TODO include remote host ID in desktop notifications?
chatModel.controller.showProgressIfNeeded {
chatModel.controller.changeActiveUser(null, userId, null) chatModel.controller.changeActiveUser(null, userId, null)
} }
}
chatModel.chatId.value = null chatModel.chatId.value = null
chatModel.clearOverlays.value = true chatModel.clearOverlays.value = true
} }
@@ -89,12 +93,13 @@ abstract class NtfManager {
} }
} }
abstract fun notifyCallInvitation(invitation: RcvCallInvitation) abstract fun notifyCallInvitation(invitation: RcvCallInvitation): Boolean
abstract fun hasNotificationsForChat(chatId: String): Boolean abstract fun hasNotificationsForChat(chatId: String): Boolean
abstract fun cancelNotificationsForChat(chatId: String) abstract fun cancelNotificationsForChat(chatId: String)
abstract fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<Pair<NotificationAction, () -> Unit>> = emptyList()) abstract fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<Pair<NotificationAction, () -> Unit>> = emptyList())
abstract fun cancelCallNotification() abstract fun cancelCallNotification()
abstract fun cancelAllNotifications() abstract fun cancelAllNotifications()
abstract fun showMessage(title: String, text: String)
// Android only // Android only
abstract fun androidCreateNtfChannelsMaybeShowAlert() abstract fun androidCreateNtfChannelsMaybeShowAlert()
@@ -10,6 +10,7 @@ interface PlatformInterface {
fun androidChatStopped() {} fun androidChatStopped() {}
fun androidChatInitializedAndStarted() {} fun androidChatInitializedAndStarted() {}
fun androidIsBackgroundCallAllowed(): Boolean = true fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
} }
/** /**
@@ -4,13 +4,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.chat.ComposeState
import java.io.File
import java.net.URI import java.net.URI
@Composable @Composable
expect fun PlatformTextField( expect fun PlatformTextField(
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
sendMsgEnabled: Boolean, sendMsgEnabled: Boolean,
sendMsgButtonDisabled: Boolean,
textStyle: MutableState<TextStyle>, textStyle: MutableState<TextStyle>,
showDeleteTextButton: MutableState<Boolean>, showDeleteTextButton: MutableState<Boolean>,
userIsObserver: Boolean, userIsObserver: Boolean,
@@ -16,3 +16,11 @@ expect fun getKeyboardState(): State<KeyboardState>
expect fun hideKeyboard(view: Any?) expect fun hideKeyboard(view: Any?)
expect fun androidIsFinishingMainActivity(): Boolean expect fun androidIsFinishingMainActivity(): Boolean
fun registerGlobalErrorHandler() {
Thread.setDefaultUncaughtExceptionHandler(GlobalExceptionsHandler())
}
expect class GlobalExceptionsHandler(): Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, e: Throwable)
}
@@ -7,6 +7,7 @@ import androidx.compose.ui.text.font.FontFamily
import chat.simplex.res.MR import chat.simplex.res.MR
import chat.simplex.common.model.AppPreferences import chat.simplex.common.model.AppPreferences
import chat.simplex.common.model.ChatController import chat.simplex.common.model.ChatController
import chat.simplex.common.platform.platform
import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.helpers.generalGetString
// https://github.com/rsms/inter // https://github.com/rsms/inter
@@ -96,6 +97,7 @@ object ThemeManager {
fun applyTheme(theme: String, darkForSystemTheme: Boolean) { fun applyTheme(theme: String, darkForSystemTheme: Boolean) {
appPrefs.currentTheme.set(theme) appPrefs.currentTheme.set(theme)
CurrentColors.value = currentColors(darkForSystemTheme) CurrentColors.value = currentColors(darkForSystemTheme)
platform.androidSetNightModeIfSupported()
} }
fun changeDarkTheme(theme: String, darkForSystemTheme: Boolean) { fun changeDarkTheme(theme: String, darkForSystemTheme: Boolean) {
@@ -34,7 +34,6 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
close() close()
}) })
TerminalLayout( TerminalLayout(
remember { chatModel.terminalItems },
composeState, composeState,
sendCommand = { sendCommand(chatModel, composeState) }, sendCommand = { sendCommand(chatModel, composeState) },
close close
@@ -63,7 +62,6 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState<Compose
@Composable @Composable
fun TerminalLayout( fun TerminalLayout(
terminalItems: List<TerminalItem>,
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
sendCommand: () -> Unit, sendCommand: () -> Unit,
close: () -> Unit close: () -> Unit
@@ -111,7 +109,7 @@ fun TerminalLayout(
.fillMaxWidth(), .fillMaxWidth(),
color = MaterialTheme.colors.background color = MaterialTheme.colors.background
) { ) {
TerminalLog(terminalItems) TerminalLog()
} }
} }
} }
@@ -120,22 +118,13 @@ fun TerminalLayout(
private var lazyListState = 0 to 0 private var lazyListState = 0 to 0
@Composable @Composable
fun TerminalLog(terminalItems: List<TerminalItem>) { fun TerminalLog() {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second) val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
} }
val reversedTerminalItems by remember { val reversedTerminalItems by remember {
derivedStateOf { derivedStateOf { chatModel.terminalItems.value.asReversed() }
// Such logic prevents concurrent modification
val res = ArrayList<TerminalItem>()
var i = 0
while (i < terminalItems.size) {
res.add(terminalItems[i])
i++
}
res.asReversed()
}
} }
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
LazyColumn(state = listState, reverseLayout = true) { LazyColumn(state = listState, reverseLayout = true) {
@@ -152,7 +141,12 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
.clickable { .clickable {
ModalManager.start.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) { ModalManager.start.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) { SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING)) val details = item.details
.let {
if (it.length < 100_000) it
else it.substring(0, 100_000)
}
Text(details, modifier = Modifier.heightIn(max = 50_000.dp).padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
} }
} }
}.padding(horizontal = 8.dp, vertical = 4.dp) }.padding(horizontal = 8.dp, vertical = 4.dp)
@@ -170,7 +164,6 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
fun PreviewTerminalLayout() { fun PreviewTerminalLayout() {
SimpleXTheme { SimpleXTheme {
TerminalLayout( TerminalLayout(
terminalItems = TerminalItem.sampleData,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
sendCommand = {}, sendCommand = {},
close = {} close = {}
@@ -21,8 +21,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.* import androidx.compose.ui.text.style.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.*
import chat.simplex.common.model.Profile import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
@@ -76,7 +76,13 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
disabled = !canCreateProfile(displayName.value), disabled = !canCreateProfile(displayName.value),
textColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary,
click = { createProfileInProfiles(chatModel, displayName.value, close) }, click = {
if (chatModel.localUserCreated.value == true) {
createProfileInProfiles(chatModel, displayName.value, close)
} else {
createProfileInNoProfileSetup(displayName.value, close)
}
},
) )
SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device)) SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device))
SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts)) SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts))
@@ -168,6 +174,17 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) {
} }
} }
fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) {
withApi {
val user = controller.apiCreateActiveUser(null, Profile(displayName.trim(), "", null)) ?: return@withApi
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
chatModel.chatRunning.value = false
controller.startChat(user)
controller.switchUIRemoteHost(null)
close()
}
}
fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) { fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) {
withApi { withApi {
val rhId = chatModel.remoteHostId() val rhId = chatModel.remoteHostId()
@@ -190,12 +207,12 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: ()
fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) { fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) {
withApi { withApi {
chatModel.controller.apiCreateActiveUser( chatModel.currentUser.value = chatModel.controller.apiCreateActiveUser(
null, Profile(displayName.trim(), "", null) null, Profile(displayName.trim(), "", null)
) ?: return@withApi ) ?: return@withApi
val onboardingStage = chatModel.controller.appPrefs.onboardingStage val onboardingStage = chatModel.controller.appPrefs.onboardingStage
if (chatModel.users.isEmpty()) { if (chatModel.users.isEmpty()) {
onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get()) { onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) {
OnboardingStage.Step2_5_SetupDatabasePassphrase OnboardingStage.Step2_5_SetupDatabasePassphrase
} else { } else {
OnboardingStage.Step3_CreateSimpleXAddress OnboardingStage.Step3_CreateSimpleXAddress
@@ -13,8 +13,8 @@ class CallManager(val chatModel: ChatModel) {
callInvitations[invitation.contact.id] = invitation callInvitations[invitation.contact.id] = invitation
if (invitation.user.showNotifications) { if (invitation.user.showNotifications) {
if (Clock.System.now() - invitation.callTs <= 3.minutes) { if (Clock.System.now() - invitation.callTs <= 3.minutes) {
invitation.sentNotification = ntfManager.notifyCallInvitation(invitation)
activeCallInvitation.value = invitation activeCallInvitation.value = invitation
ntfManager.notifyCallInvitation(invitation)
} else { } else {
val contact = invitation.contact val contact = invitation.contact
ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText) ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
@@ -15,11 +15,10 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.ProfileImage import chat.simplex.common.views.helpers.ProfileImage
import chat.simplex.common.views.usersettings.ProfilePreview import chat.simplex.common.views.usersettings.ProfilePreview
import chat.simplex.common.platform.ntfManager
import chat.simplex.common.platform.SoundPlayer
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
@@ -27,7 +26,11 @@ import kotlinx.datetime.Clock
fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) { fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager val cm = chatModel.callManager
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(true) { SoundPlayer.start(scope, sound = !chatModel.showCallView.value) } LaunchedEffect(Unit) {
if (chatModel.activeCallInvitation.value?.sentNotification == false || appPlatform.isDesktop) {
SoundPlayer.start(scope, sound = !chatModel.showCallView.value)
}
}
DisposableEffect(true) { onDispose { SoundPlayer.stop() } } DisposableEffect(true) { onDispose { SoundPlayer.stop() } }
IncomingCallAlertLayout( IncomingCallAlertLayout(
invitation, invitation,
@@ -112,6 +112,9 @@ sealed class WCallResponse {
CallMediaType.Video -> MR.strings.incoming_video_call CallMediaType.Video -> MR.strings.incoming_video_call
CallMediaType.Audio -> MR.strings.incoming_audio_call CallMediaType.Audio -> MR.strings.incoming_audio_call
}) })
// Shows whether notification was shown or not to prevent playing sound twice in both notification and in-app
var sentNotification: Boolean = false
} }
@Serializable data class CallCapabilities(val encryption: Boolean) @Serializable data class CallCapabilities(val encryption: Boolean)
@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) { @Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
@@ -127,18 +130,10 @@ sealed class WCallResponse {
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}" "${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
} }
} }
val protocolText: String get() {
val local = localCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
val localRelay = localCandidate?.relayProtocol?.uppercase(Locale.ROOT) ?: "unknown"
val remote = remoteCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
val localText = if (localRelay == local || localCandidate?.relayProtocol == null) local else "$local ($localRelay)"
return if (local == remote) localText else "$localText / $remote"
}
} }
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?, val relayProtocol: String?) @Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?)
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
@Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null) @Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
@@ -220,8 +220,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
} }
} }
}, },
loadPrevMessages = { cInfo -> loadPrevMessages = {
val c = chatModel.getChat(cInfo.id) if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout
val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout)
val firstId = chatModel.chatItems.firstOrNull()?.id val firstId = chatModel.chatItems.firstOrNull()?.id
if (c != null && firstId != null) { if (c != null && firstId != null) {
withApi { withApi {
@@ -440,7 +441,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
changeNtfsState = { enabled, currentValue -> toggleNotifications(chat, enabled, chatModel, currentValue) }, changeNtfsState = { enabled, currentValue -> toggleNotifications(chat, enabled, chatModel, currentValue) },
onSearchValueChanged = { value -> onSearchValueChanged = { value ->
if (searchText.value == value) return@ChatLayout if (searchText.value == value) return@ChatLayout
val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout
val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) ?: return@ChatLayout
withApi { withApi {
apiFindMessages(c, chatModel, value) apiFindMessages(c, chatModel, value)
searchText.value = value searchText.value = value
@@ -467,7 +469,7 @@ fun ChatLayout(
back: () -> Unit, back: () -> Unit,
info: () -> Unit, info: () -> Unit,
showMemberInfo: (GroupInfo, GroupMember) -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit, loadPrevMessages: () -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit, deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long, Boolean) -> Unit, receiveFile: (Long, Boolean) -> Unit,
@@ -790,7 +792,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
useLinkPreviews: Boolean, useLinkPreviews: Boolean,
linkMode: SimplexLinkMode, linkMode: SimplexLinkMode,
showMemberInfo: (GroupInfo, GroupMember) -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit, loadPrevMessages: () -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit, deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long, Boolean) -> Unit, receiveFile: (Long, Boolean) -> Unit,
@@ -828,9 +830,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
} }
} }
PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, chat, chatItems) { c -> PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages)
loadPrevMessages(c.chatInfo)
}
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } } val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } }
@@ -900,8 +900,12 @@ fun BoxWithConstraintsScope.ChatItemsList(
@Composable @Composable
fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) { fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) {
tryOrShowError("${cItem.id}ChatItem", error = {
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart)
}) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
} }
}
@Composable @Composable
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) { fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) {
@@ -1146,24 +1150,32 @@ fun BoxWithConstraintsScope.FloatingButtons(
fun PreloadItems( fun PreloadItems(
listState: LazyListState, listState: LazyListState,
remaining: Int = 10, remaining: Int = 10,
chat: Chat, onLoadMore: () -> Unit,
items: List<*>,
onLoadMore: (chat: Chat) -> Unit,
) { ) {
LaunchedEffect(listState, chat, items) { // Prevent situation when initial load and load more happens one after another after selecting a chat with long scroll position from previous selection
snapshotFlow { listState.layoutInfo } val allowLoad = remember { mutableStateOf(false) }
.map { LaunchedEffect(Unit) {
val totalItemsNumber = it.totalItemsCount snapshotFlow { chatModel.chatId.value }
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 .filterNotNull()
if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT) .collect {
totalItemsNumber allowLoad.value = listState.layoutInfo.totalItemsCount == listState.layoutInfo.visibleItemsInfo.size
delay(500)
allowLoad.value = true
}
}
KeyChangeEffect(allowLoad.value) {
snapshotFlow {
val lInfo = listState.layoutInfo
val totalItemsNumber = lInfo.totalItemsCount
val lastVisibleItemIndex = (lInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
if (allowLoad.value && lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
totalItemsNumber + ChatPagination.PRELOAD_COUNT
else else
0 0
} }
.distinctUntilChanged()
.filter { it > 0 } .filter { it > 0 }
.collect { .collect {
onLoadMore(chat) onLoadMore()
} }
} }
} }
@@ -1435,7 +1447,7 @@ fun PreviewChatLayout() {
back = {}, back = {},
info = {}, info = {},
showMemberInfo = { _, _ -> }, showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> }, loadPrevMessages = {},
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
deleteMessages = { _ -> }, deleteMessages = { _ -> },
receiveFile = { _, _ -> }, receiveFile = { _, _ -> },
@@ -1508,7 +1520,7 @@ fun PreviewGroupChatLayout() {
back = {}, back = {},
info = {}, info = {},
showMemberInfo = { _, _ -> }, showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> }, loadPrevMessages = {},
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
deleteMessages = {}, deleteMessages = {},
receiveFile = { _, _ -> }, receiveFile = { _, _ -> },
@@ -201,7 +201,7 @@ suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text:
// Image // Image
val drawable = getDrawableFromUri(uri) val drawable = getDrawableFromUri(uri)
// Do not show alert in case it's already shown from the function above // Do not show alert in case it's already shown from the function above
bitmap = getBitmapFromUri(uri, withAlertOnException = AlertManager.shared.alertViews.isEmpty()) bitmap = getBitmapFromUri(uri, withAlertOnException = !AlertManager.shared.hasAlertsShown())
if (isAnimImage(uri, drawable)) { if (isAnimImage(uri, drawable)) {
// It's a gif or webp // It's a gif or webp
val fileSize = getFileSize(uri) val fileSize = getFileSize(uri)
@@ -29,7 +29,6 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File
import java.net.URI import java.net.URI
@Composable @Composable
@@ -82,7 +81,10 @@ fun SendMsgView(
val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) { val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() ||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
cs.endLiveDisabled
PlatformTextField(composeState, sendMsgEnabled, sendMsgButtonDisabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) {
if (!cs.inProgress) { if (!cs.inProgress) {
sendMessage(null) sendMessage(null)
} }
@@ -155,9 +157,6 @@ fun SendMsgView(
else -> { else -> {
val cs = composeState.value val cs = composeState.value
val icon = if (cs.editing || cs.liveMessage != null) painterResource(MR.images.ic_check_filled) else painterResource(MR.images.ic_arrow_upward) val icon = if (cs.editing || cs.liveMessage != null) painterResource(MR.images.ic_check_filled) else painterResource(MR.images.ic_arrow_upward)
val disabled = !sendMsgEnabled || !cs.sendEnabled() ||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
cs.endLiveDisabled
val showDropdown = rememberSaveable { mutableStateOf(false) } val showDropdown = rememberSaveable { mutableStateOf(false) }
@Composable @Composable
@@ -200,12 +199,12 @@ fun SendMsgView(
val menuItems = MenuItems() val menuItems = MenuItems()
if (menuItems.isNotEmpty()) { if (menuItems.isNotEmpty()) {
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage) { showDropdown.value = true } SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) { showDropdown.value = true }
DefaultDropdownMenu(showDropdown) { DefaultDropdownMenu(showDropdown) {
menuItems.forEach { composable -> composable() } menuItems.forEach { composable -> composable() }
} }
} else { } else {
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage) SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage)
} }
} }
} }
@@ -0,0 +1,18 @@
package chat.simplex.common.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 dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import chat.simplex.res.MR
@Composable
fun CIBrokenComposableView(alignment: Alignment) {
Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp), contentAlignment = alignment) {
Text(stringResource(MR.strings.error_showing_message), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
}
@@ -164,6 +164,12 @@ fun CIImageView(
} }
} }
} }
} else {
KeyChangeEffect(file) {
if (res.value == null) {
res.value = imageAndFilePath(file)
}
}
} }
val loaded = res.value val loaded = res.value
if (loaded != null) { if (loaded != null) {
@@ -195,7 +195,13 @@ fun ChatItemView(
} }
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests }
val copyAndShareAllowed = cItem.file == null || !chatModel.connectedToRemote() || getLoadedFilePath(cItem.file) != null || cachedRemoteReqs[cItem.file.fileSource] != false val copyAndShareAllowed = when {
cItem.content.text.isNotEmpty() -> true
cItem.file != null && chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded -> true
getLoadedFilePath(cItem.file) != null -> true
else -> false
}
if (copyAndShareAllowed) { if (copyAndShareAllowed) {
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
var fileSource = getLoadedFileSource(cItem.file) var fileSource = getLoadedFileSource(cItem.file)
@@ -221,7 +227,7 @@ fun ChatItemView(
showMenu.value = false showMenu.value = false
}) })
} }
if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file?.fileSource] != false))) { if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file?.fileSource] != false && cItem.file?.loaded == true))) {
SaveContentItemAction(cItem, saveFileLauncher, showMenu) SaveContentItemAction(cItem, saveFileLauncher, showMenu)
} }
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
@@ -14,6 +14,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -61,9 +62,17 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
is ChatInfo.Direct -> { is ChatInfo.Direct -> {
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
ChatListNavLinkLayout( ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, chatLinkPreview = {
tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) {
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false)
}
},
click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) }, click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) }, dropdownMenuItems = {
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead)
}
},
showMenu, showMenu,
stopped, stopped,
selectedChat selectedChat
@@ -71,25 +80,45 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
} }
is ChatInfo.Group -> is ChatInfo.Group ->
ChatListNavLinkLayout( ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) }, chatLinkPreview = {
tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) {
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout)
}
},
click = { if (!inProgress.value) groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) }, click = { if (!inProgress.value) groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) }, dropdownMenuItems = {
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead)
}
},
showMenu, showMenu,
stopped, stopped,
selectedChat selectedChat
) )
is ChatInfo.ContactRequest -> is ChatInfo.ContactRequest ->
ChatListNavLinkLayout( ChatListNavLinkLayout(
chatLinkPreview = { ContactRequestView(chat.chatInfo) }, chatLinkPreview = {
tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) {
ContactRequestView(chat.chatInfo)
}
},
click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) }, click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, dropdownMenuItems = {
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu)
}
},
showMenu, showMenu,
stopped, stopped,
selectedChat selectedChat
) )
is ChatInfo.ContactConnection -> is ChatInfo.ContactConnection ->
ChatListNavLinkLayout( ChatListNavLinkLayout(
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) }, chatLinkPreview = {
tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) {
ContactConnectionView(chat.chatInfo.contactConnection)
}
},
click = { click = {
ModalManager.center.closeModals() ModalManager.center.closeModals()
ModalManager.end.closeModals() ModalManager.end.closeModals()
@@ -97,7 +126,11 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
ContactConnectionInfoView(chatModel, chat.remoteHostId, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) ContactConnectionInfoView(chatModel, chat.remoteHostId, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close)
} }
}, },
dropdownMenuItems = { ContactConnectionMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, dropdownMenuItems = {
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
ContactConnectionMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu)
}
},
showMenu, showMenu,
stopped, stopped,
selectedChat selectedChat
@@ -105,7 +138,9 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
is ChatInfo.InvalidJSON -> is ChatInfo.InvalidJSON ->
ChatListNavLinkLayout( ChatListNavLinkLayout(
chatLinkPreview = { chatLinkPreview = {
tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) {
InvalidDataView() InvalidDataView()
}
}, },
click = { click = {
ModalManager.end.closeModals() ModalManager.end.closeModals()
@@ -119,6 +154,13 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
} }
} }
@Composable
private fun ErrorChatListItem() {
Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) {
Text(stringResource(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
}
fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) {
when { when {
contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true)
@@ -611,12 +653,12 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress(
close: (() -> Unit)?, close: (() -> Unit)?,
openChat: Boolean openChat: Boolean
) { ) {
AlertManager.shared.showAlertDialogButtonsColumn( AlertManager.privacySensitive.showAlertDialogButtonsColumn(
title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName), title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName),
buttons = { buttons = {
Column { Column {
SectionItemView({ SectionItemView({
AlertManager.shared.hideAlert() AlertManager.privacySensitive.hideAlert()
withApi { withApi {
close?.invoke() close?.invoke()
val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false) val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false)
@@ -628,7 +670,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress(
Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
} }
SectionItemView({ SectionItemView({
AlertManager.shared.hideAlert() AlertManager.privacySensitive.hideAlert()
withApi { withApi {
close?.invoke() close?.invoke()
val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true) val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true)
@@ -640,7 +682,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress(
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
} }
SectionItemView({ SectionItemView({
AlertManager.shared.hideAlert() AlertManager.privacySensitive.hideAlert()
}) { }) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
} }
@@ -654,7 +696,7 @@ suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactI
val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId) val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId)
if (contact != null) { if (contact != null) {
chatModel.updateContact(rhId, contact) chatModel.updateContact(rhId, contact)
AlertManager.shared.showAlertMsg( AlertManager.privacySensitive.showAlertMsg(
title = generalGetString(MR.strings.connection_request_sent), title = generalGetString(MR.strings.connection_request_sent),
text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted), text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted),
hostDevice = hostDevice(rhId), hostDevice = hostDevice(rhId),
@@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontStyle
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -49,13 +50,6 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
LaunchedEffect(chatModel.clearOverlays.value) { LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false) 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(chatModel.remoteHostId(), url, chatModel)
}
}
if (appPlatform.isDesktop) { if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) { KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) { if (chatModel.chatId.value != null) {
@@ -68,14 +62,18 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
var searchInList by rememberSaveable { mutableStateOf("") } var searchInList by rememberSaveable { mutableStateOf("") }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState val (userPickerState, scaffoldState ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } }, Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } },
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) }, drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
}
},
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid, drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = { floatingActionButton = {
if (searchInList.isEmpty()) { if (searchInList.isEmpty() && !chatModel.desktopNoUserNoRemote) {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
if (!stopped) { if (!stopped) {
@@ -104,7 +102,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
) { ) {
if (chatModel.chats.isNotEmpty()) { if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel, search = searchInList) ChatList(chatModel, search = searchInList)
} else if (!switchingUsersAndHosts.value) { } else if (!chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) { if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
OnboardingButtons(showNewChatSheet) OnboardingButtons(showNewChatSheet)
@@ -118,21 +116,17 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
if (searchInList.isEmpty()) { if (searchInList.isEmpty()) {
DesktopActiveCallOverlayLayout(newChatSheetState) DesktopActiveCallOverlayLayout(newChatSheetState)
// TODO disable this button and sheet for the duration of the switch // TODO disable this button and sheet for the duration of the switch
tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
} }
}
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE userPickerState.value = AnimatedViewState.GONE
} }
} }
if (switchingUsersAndHosts.value) {
Box(
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
ProgressIndicator()
}
} }
} }
@@ -209,7 +203,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
navigationButton = { navigationButton = {
if (showSearch) { if (showSearch) {
NavigationButtonBack(hideSearchOnBack) NavigationButtonBack(hideSearchOnBack)
} else if (chatModel.users.isEmpty()) { } else if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} else { } else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } } val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
@@ -304,24 +298,13 @@ private fun ToggleFilterButton() {
} }
} }
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
)
}
@Composable @Composable
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link") Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) { if (chatModel.currentUser.value == null) {
chatModel.appOpenUrl.value = uri chatModel.appOpenUrl.value = rhId to uri
} else { } else {
withApi { withApi {
planAndConnect(chatModel, rhId, uri, incognito = null, close = null) planAndConnect(chatModel, rhId, uri, incognito = null, close = null)
@@ -329,6 +312,13 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
} }
} }
@Composable
private fun ErrorSettingsView() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
}
private var lazyListState = 0 to 0 private var lazyListState = 0 to 0
@Composable @Composable
@@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Composable @Composable
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) { fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") } var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState, switchingUsersAndHosts) = settingsState val (userPickerState, scaffoldState) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
Scaffold( Scaffold(
Modifier.padding(end = endPadding), Modifier.padding(end = endPadding),
@@ -47,11 +47,13 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
} }
} }
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = { tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = {
chatModel.sharedContent.value = null chatModel.sharedContent.value = null
userPickerState.value = AnimatedViewState.GONE userPickerState.value = AnimatedViewState.GONE
}) })
} }
}
} }
@Composable @Composable
@@ -26,7 +26,9 @@ import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.remote.* import chat.simplex.common.views.remote.*
import chat.simplex.common.views.usersettings.doWithAuth
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -38,7 +40,6 @@ import kotlin.math.roundToInt
fun UserPicker( fun UserPicker(
chatModel: ChatModel, chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>, userPickerState: MutableStateFlow<AnimatedViewState>,
switchingUsersAndHosts: MutableState<Boolean>,
showSettings: Boolean = true, showSettings: Boolean = true,
showCancel: Boolean = false, showCancel: Boolean = false,
cancelClicked: () -> Unit = {}, cancelClicked: () -> Unit = {},
@@ -123,14 +124,10 @@ fun UserPicker(
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
if (!u.user.activeUser) { if (!u.user.activeUser) {
scope.launch { scope.launch {
val job = launch { controller.showProgressIfNeeded {
delay(500)
switchingUsersAndHosts.value = true
}
ModalManager.closeAllModalsEverywhere() ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null) chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
job.cancel() }
switchingUsersAndHosts.value = false
} }
} }
} }
@@ -162,13 +159,13 @@ fun UserPicker(
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
if (remoteHosts.isNotEmpty()) { if (remoteHosts.isNotEmpty()) {
if (currentRemoteHost == null) { if (currentRemoteHost == null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(true) { LocalDevicePickerItem(true) {
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice() switchToLocalDevice()
} }
Divider(Modifier.requiredHeight(1.dp)) Divider(Modifier.requiredHeight(1.dp))
} else { } else if (currentRemoteHost != null) {
val connecting = rememberSaveable { mutableStateOf(false) } val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(currentRemoteHost, RemoteHostPickerItem(currentRemoteHost,
actionButtonClick = { actionButtonClick = {
@@ -176,7 +173,7 @@ fun UserPicker(
stopRemoteHostAndReloadHosts(currentRemoteHost, true) stopRemoteHostAndReloadHosts(currentRemoteHost, true)
}) { }) {
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(currentRemoteHost, switchingUsersAndHosts, connecting) switchToRemoteHost(currentRemoteHost, connecting)
} }
Divider(Modifier.requiredHeight(1.dp)) Divider(Modifier.requiredHeight(1.dp))
} }
@@ -184,7 +181,7 @@ fun UserPicker(
UsersView() UsersView()
if (remoteHosts.isNotEmpty() && currentRemoteHost != null) { if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(false) { LocalDevicePickerItem(false) {
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice() switchToLocalDevice()
@@ -199,7 +196,7 @@ fun UserPicker(
stopRemoteHostAndReloadHosts(h, false) stopRemoteHostAndReloadHosts(h, false)
}) { }) {
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(h, switchingUsersAndHosts, connecting) switchToRemoteHost(h, connecting)
} }
Divider(Modifier.requiredHeight(1.dp)) Divider(Modifier.requiredHeight(1.dp))
} }
@@ -220,6 +217,18 @@ fun UserPicker(
userPickerState.value = AnimatedViewState.GONE userPickerState.value = AnimatedViewState.GONE
} }
Divider(Modifier.requiredHeight(1.dp)) Divider(Modifier.requiredHeight(1.dp))
} else if (chatModel.desktopNoUserNoRemote) {
CreateInitialProfile {
doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
userPickerState.value = AnimatedViewState.HIDING
}
CreateProfile(chat.simplex.common.platform.chatModel, close)
}
}
}
Divider(Modifier.requiredHeight(1.dp))
} }
if (showSettings) { if (showSettings) {
SettingsPickerItem(settingsClicked) SettingsPickerItem(settingsClicked)
@@ -401,6 +410,16 @@ private fun LinkAMobilePickerItem(onClick: () -> Unit) {
} }
} }
@Composable
private fun CreateInitialProfile(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.create_chat_profile)
Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black)
}
}
@Composable @Composable
private fun SettingsPickerItem(onClick: () -> Unit) { private fun SettingsPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
@@ -441,21 +460,15 @@ private fun switchToLocalDevice() {
} }
} }
private fun switchToRemoteHost(h: RemoteHostInfo, switchingUsersAndHosts: MutableState<Boolean>, connecting: MutableState<Boolean>) { private fun switchToRemoteHost(h: RemoteHostInfo, connecting: MutableState<Boolean>) {
if (!h.activeHost()) { if (!h.activeHost()) {
withBGApi { withBGApi {
val job = launch {
delay(500)
switchingUsersAndHosts.value = true
}
ModalManager.closeAllModalsEverywhere() ModalManager.closeAllModalsEverywhere()
if (h.sessionState != null) { if (h.sessionState != null) {
chatModel.controller.switchUIRemoteHost(h.remoteHostId) chatModel.controller.switchUIRemoteHost(h.remoteHostId)
} else { } else {
connectMobileDevice(h, connecting) connectMobileDevice(h, connecting)
} }
job.cancel()
switchingUsersAndHosts.value = false
} }
} else { } else {
connectMobileDevice(h, connecting) connectMobileDevice(h, connecting)
@@ -264,12 +264,13 @@ private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onCli
text, text,
generalGetString(MR.strings.enter_passphrase), generalGetString(MR.strings.enter_passphrase),
isValid = ::validKey, isValid = ::validKey,
keyboardActions = KeyboardActions(onDone = if (enabled) { // Don't enable this on desktop since it interfere with key event listener
keyboardActions = KeyboardActions(onDone = if (enabled && appPlatform.isAndroid) {
{ onClick?.invoke() } { onClick?.invoke() }
} else null } else null
), ),
modifier = Modifier.focusRequester(focusRequester).onPreviewKeyEvent { modifier = Modifier.focusRequester(focusRequester).onPreviewKeyEvent {
if (onClick != null && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { if (onClick != null && (it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) {
onClick() onClick()
true true
} else { } else {
@@ -20,6 +20,7 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
@@ -59,7 +60,9 @@ fun DatabaseView(
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) } val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) }
val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? ->
if (to != null) { if (to != null) {
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) {
startChat(m, chatLastStart, m.chatDbChanged)
}
} }
} }
val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) } val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) }
@@ -77,7 +80,6 @@ fun DatabaseView(
m.chatDbEncrypted.value, m.chatDbEncrypted.value,
m.controller.appPrefs.storeDBPassphrase.state.value, m.controller.appPrefs.storeDBPassphrase.state.value,
m.controller.appPrefs.initialRandomDBPassphrase, m.controller.appPrefs.initialRandomDBPassphrase,
m.controller.appPrefs.developerTools.state.value,
importArchiveLauncher, importArchiveLauncher,
chatArchiveName, chatArchiveName,
chatArchiveTime, chatArchiveTime,
@@ -100,7 +102,13 @@ fun DatabaseView(
setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize)
} }
}, },
showSettingsModal showSettingsModal,
disconnectAllHosts = {
val connected = chatModel.remoteHosts.filter { it.sessionState is RemoteHostSessionState.Connected }
connected.forEachIndexed { index, h ->
controller.stopRemoteHostAndReloadHosts(h, index == connected.lastIndex && chatModel.connectedToRemote())
}
}
) )
if (progressIndicator.value) { if (progressIndicator.value) {
Box( Box(
@@ -129,7 +137,6 @@ fun DatabaseLayout(
chatDbEncrypted: Boolean?, chatDbEncrypted: Boolean?,
passphraseSaved: Boolean, passphraseSaved: Boolean,
initialRandomDBPassphrase: SharedPreference<Boolean>, initialRandomDBPassphrase: SharedPreference<Boolean>,
developerTools: Boolean,
importArchiveLauncher: FileChooserLauncher, importArchiveLauncher: FileChooserLauncher,
chatArchiveName: MutableState<String?>, chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>, chatArchiveTime: MutableState<Instant?>,
@@ -144,16 +151,18 @@ fun DatabaseLayout(
deleteChatAlert: () -> Unit, deleteChatAlert: () -> Unit,
deleteAppFilesAndMedia: () -> Unit, deleteAppFilesAndMedia: () -> Unit,
onChatItemTTLSelected: (ChatItemTTL) -> Unit, onChatItemTTLSelected: (ChatItemTTL) -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
disconnectAllHosts: () -> Unit,
) { ) {
val stopped = !runChat val stopped = !runChat
val operationsDisabled = !stopped || progressIndicator val operationsDisabled = (!stopped || progressIndicator) && !chatModel.desktopNoUserNoRemote
Column( Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
) { ) {
AppBarTitle(stringResource(MR.strings.your_chat_database)) AppBarTitle(stringResource(MR.strings.your_chat_database))
if (!chatModel.desktopNoUserNoRemote) {
SectionView(stringResource(MR.strings.messages_section_title).uppercase()) { SectionView(stringResource(MR.strings.messages_section_title).uppercase()) {
TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected) TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected)
} }
@@ -168,12 +177,17 @@ fun DatabaseLayout(
} }
} }
) )
if (currentRemoteHost == null) {
SectionDividerSpaced(maxTopPadding = true) SectionDividerSpaced(maxTopPadding = true)
}
val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected }
if (chatModel.localUserCreated.value == true) {
SectionView(stringResource(MR.strings.run_chat_section)) { SectionView(stringResource(MR.strings.run_chat_section)) {
RunChatSetting(runChat, stopped, startChat, stopChatAlert) if (!toggleEnabled) {
SectionItemView(disconnectAllHosts) {
Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange)
}
}
RunChatSetting(runChat, stopped, toggleEnabled, startChat, stopChatAlert)
} }
SectionTextFooter( SectionTextFooter(
if (stopped) { if (stopped) {
@@ -183,8 +197,14 @@ fun DatabaseLayout(
} }
) )
SectionDividerSpaced() SectionDividerSpaced()
}
SectionView(stringResource(MR.strings.chat_database_section)) { SectionView(stringResource(MR.strings.chat_database_section)) {
if (chatModel.localUserCreated.value != true && !toggleEnabled) {
SectionItemView(disconnectAllHosts) {
Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange)
}
}
val unencrypted = chatDbEncrypted == false val unencrypted = chatDbEncrypted == false
SettingsActionItem( SettingsActionItem(
if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
@@ -194,7 +214,7 @@ fun DatabaseLayout(
iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary,
disabled = operationsDisabled disabled = operationsDisabled
) )
if (appPlatform.isDesktop && developerTools) { if (appPlatform.isDesktop) {
SettingsActionItem( SettingsActionItem(
painterResource(MR.images.ic_folder_open), painterResource(MR.images.ic_folder_open),
stringResource(MR.strings.open_database_folder), stringResource(MR.strings.open_database_folder),
@@ -267,8 +287,6 @@ fun DatabaseLayout(
String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size)) String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size))
} }
) )
}
SectionBottomSpacer() SectionBottomSpacer()
} }
} }
@@ -319,6 +337,7 @@ private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onS
fun RunChatSetting( fun RunChatSetting(
runChat: Boolean, runChat: Boolean,
stopped: Boolean, stopped: Boolean,
enabled: Boolean,
startChat: () -> Unit, startChat: () -> Unit,
stopChatAlert: () -> Unit stopChatAlert: () -> Unit
) { ) {
@@ -337,6 +356,7 @@ fun RunChatSetting(
stopChatAlert() stopChatAlert()
} }
}, },
enabled = enabled,
) )
} }
} }
@@ -346,7 +366,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
return stringResource(if (chatArchiveTime < chatLastStart) MR.strings.old_database_archive else MR.strings.new_database_archive) return stringResource(if (chatArchiveTime < chatLastStart) MR.strings.old_database_archive else MR.strings.new_database_archive)
} }
private fun startChat(m: ChatModel, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) { fun startChat(m: ChatModel, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
withApi { withApi {
try { try {
if (chatDbChanged.value) { if (chatDbChanged.value) {
@@ -386,6 +406,8 @@ private fun stopChatAlert(m: ChatModel) {
) )
} }
expect fun restartChatOrApp()
private fun exportProhibitedAlert() { private fun exportProhibitedAlert() {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.set_password_to_export), title = generalGetString(MR.strings.set_password_to_export),
@@ -393,7 +415,7 @@ private fun exportProhibitedAlert() {
) )
} }
private fun authStopChat(m: ChatModel) { fun authStopChat(m: ChatModel, onStop: (() -> Unit)? = null) {
if (m.controller.appPrefs.performLA.get()) { if (m.controller.appPrefs.performLA.get()) {
authenticate( authenticate(
generalGetString(MR.strings.auth_stop_chat), generalGetString(MR.strings.auth_stop_chat),
@@ -401,7 +423,7 @@ private fun authStopChat(m: ChatModel) {
completed = { laResult -> completed = { laResult ->
when (laResult) { when (laResult) {
LAResult.Success, is LAResult.Unavailable -> { LAResult.Success, is LAResult.Unavailable -> {
stopChat(m) stopChat(m, onStop)
} }
is LAResult.Error -> { is LAResult.Error -> {
m.chatRunning.value = true m.chatRunning.value = true
@@ -413,15 +435,16 @@ private fun authStopChat(m: ChatModel) {
} }
) )
} else { } else {
stopChat(m) stopChat(m, onStop)
} }
} }
private fun stopChat(m: ChatModel) { private fun stopChat(m: ChatModel, onStop: (() -> Unit)? = null) {
withApi { withApi {
try { try {
stopChatAsync(m) stopChatAsync(m)
platform.androidChatStopped() platform.androidChatStopped()
onStop?.invoke()
} catch (e: Error) { } catch (e: Error) {
m.chatRunning.value = true m.chatRunning.value = true
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_stopping_chat), e.toString()) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_stopping_chat), e.toString())
@@ -501,13 +524,14 @@ private fun importArchiveAlert(
m: ChatModel, m: ChatModel,
importedArchiveURI: URI, importedArchiveURI: URI,
appFilesCountAndSize: MutableState<Pair<Int, Long>>, appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean> progressIndicator: MutableState<Boolean>,
startChat: () -> Unit,
) { ) {
AlertManager.shared.showAlertDialog( AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.import_database_question), title = generalGetString(MR.strings.import_database_question),
text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one), text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
confirmText = generalGetString(MR.strings.import_database_confirmation), confirmText = generalGetString(MR.strings.import_database_confirmation),
onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator) }, onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator, startChat) },
destructive = true, destructive = true,
) )
} }
@@ -516,7 +540,8 @@ private fun importArchive(
m: ChatModel, m: ChatModel,
importedArchiveURI: URI, importedArchiveURI: URI,
appFilesCountAndSize: MutableState<Pair<Int, Long>>, appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean> progressIndicator: MutableState<Boolean>,
startChat: () -> Unit,
) { ) {
progressIndicator.value = true progressIndicator.value = true
val archivePath = saveArchiveFromURI(importedArchiveURI) val archivePath = saveArchiveFromURI(importedArchiveURI)
@@ -533,6 +558,10 @@ private fun importArchive(
operationEnded(m, progressIndicator) { operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database)) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database))
} }
if (chatModel.localUserCreated.value == false) {
chatModel.chatRunning.value = false
startChat()
}
} else { } else {
operationEnded(m, progressIndicator) { operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database) + "\n" + generalGetString(MR.strings.non_fatal_errors_occured_during_import)) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database) + "\n" + generalGetString(MR.strings.non_fatal_errors_occured_during_import))
@@ -681,7 +710,6 @@ fun PreviewDatabaseLayout() {
chatDbEncrypted = false, chatDbEncrypted = false,
passphraseSaved = false, passphraseSaved = false,
initialRandomDBPassphrase = SharedPreference({ true }, {}), initialRandomDBPassphrase = SharedPreference({ true }, {}),
developerTools = true,
importArchiveLauncher = rememberFileChooserLauncher(true) {}, importArchiveLauncher = rememberFileChooserLauncher(true) {},
chatArchiveName = remember { mutableStateOf("dummy_archive") }, chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
@@ -697,6 +725,7 @@ fun PreviewDatabaseLayout() {
deleteAppFilesAndMedia = {}, deleteAppFilesAndMedia = {},
showSettingsModal = { {} }, showSettingsModal = { {} },
onChatItemTTLSelected = {}, onChatItemTTLSelected = {},
disconnectAllHosts = {},
) )
} }
} }
@@ -1,8 +1,11 @@
package chat.simplex.common.views.helpers package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -21,7 +24,7 @@ import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
class AlertManager { class AlertManager {
var alertViews = mutableStateListOf<(@Composable () -> Unit)>() private var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
fun showAlert(alert: @Composable () -> Unit) { fun showAlert(alert: @Composable () -> Unit) {
Log.d(TAG, "AlertManager.showAlert") Log.d(TAG, "AlertManager.showAlert")
@@ -32,6 +35,12 @@ class AlertManager {
alertViews.removeLastOrNull() alertViews.removeLastOrNull()
} }
fun hideAllAlerts() {
alertViews.clear()
}
fun hasAlertsShown() = alertViews.isNotEmpty()
fun showAlertDialogButtons( fun showAlertDialogButtons(
title: String, title: String,
text: String? = null, text: String? = null,
@@ -217,6 +226,7 @@ class AlertManager {
companion object { companion object {
val shared = AlertManager() val shared = AlertManager()
val privacySensitive = AlertManager()
} }
} }
@@ -233,6 +243,7 @@ private fun alertTitle(title: String): (@Composable () -> Unit)? {
@Composable @Composable
private fun AlertContent(text: String?, hostDevice: Pair<Long?, String>?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { private fun AlertContent(text: String?, hostDevice: Pair<Long?, String>?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) {
BoxWithConstraints {
Column( Column(
Modifier Modifier
.padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF)
@@ -244,6 +255,10 @@ private fun AlertContent(text: String?, hostDevice: Pair<Long?, String>?, extraP
} }
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
if (text != null) { if (text != null) {
Column(Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f)
.verticalScroll(rememberScrollState())
) {
SelectionContainer {
Text( Text(
escapedHtmlToAnnotatedString(text, LocalDensity.current), escapedHtmlToAnnotatedString(text, LocalDensity.current),
Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f),
@@ -253,14 +268,19 @@ private fun AlertContent(text: String?, hostDevice: Pair<Long?, String>?, extraP
) )
} }
} }
}
}
content() content()
} }
}
} }
@Composable @Composable
private fun AlertContent(text: AnnotatedString?, hostDevice: Pair<Long?, String>?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { private fun AlertContent(text: AnnotatedString?, hostDevice: Pair<Long?, String>?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) {
BoxWithConstraints {
Column( Column(
Modifier Modifier
.verticalScroll(rememberScrollState())
.padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF)
) { ) {
if (appPlatform.isDesktop) { if (appPlatform.isDesktop) {
@@ -270,6 +290,11 @@ private fun AlertContent(text: AnnotatedString?, hostDevice: Pair<Long?, String>
} }
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
if (text != null) { if (text != null) {
Column(
Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f)
.verticalScroll(rememberScrollState())
) {
SelectionContainer {
Text( Text(
text, text,
Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f),
@@ -279,8 +304,11 @@ private fun AlertContent(text: AnnotatedString?, hostDevice: Pair<Long?, String>
) )
} }
} }
}
}
content() content()
} }
}
} }
fun hostDevice(rhId: Long?): Pair<Long?, String>? = if (rhId == null && chatModel.remoteHosts.isNotEmpty()) { fun hostDevice(rhId: Long?): Pair<Long?, String>? = if (rhId == null && chatModel.remoteHosts.isNotEmpty()) {
@@ -19,8 +19,8 @@ import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.* import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.* import androidx.compose.ui.text.input.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.*
import chat.simplex.common.views.database.PassphraseStrength import chat.simplex.common.views.database.PassphraseStrength
import chat.simplex.common.views.database.validKey import chat.simplex.common.views.database.validKey
import chat.simplex.res.MR import chat.simplex.res.MR
@@ -123,6 +123,7 @@ fun DefaultConfigurableTextField(
isValid: (String) -> Boolean, isValid: (String) -> Boolean,
keyboardActions: KeyboardActions = KeyboardActions(), keyboardActions: KeyboardActions = KeyboardActions(),
keyboardType: KeyboardType = KeyboardType.Text, keyboardType: KeyboardType = KeyboardType.Text,
fontSize: TextUnit = 16.sp,
dependsOn: State<Any?>? = null, dependsOn: State<Any?>? = null,
) { ) {
var valid by remember { mutableStateOf(isValid(state.value.text)) } var valid by remember { mutableStateOf(isValid(state.value.text)) }
@@ -152,7 +153,6 @@ fun DefaultConfigurableTextField(
BasicTextField( BasicTextField(
value = state.value, value = state.value,
modifier = modifier modifier = modifier
.fillMaxWidth()
.background(colors.backgroundColor(enabled).value, shape) .background(colors.backgroundColor(enabled).value, shape)
.indicatorLine(enabled, false, interactionSource, colors) .indicatorLine(enabled, false, interactionSource, colors)
.defaultMinSize( .defaultMinSize(
@@ -176,14 +176,14 @@ fun DefaultConfigurableTextField(
textStyle = TextStyle.Default.copy( textStyle = TextStyle.Default.copy(
color = color, color = color,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp fontSize = fontSize
), ),
interactionSource = interactionSource, interactionSource = interactionSource,
decorationBox = @Composable { innerTextField -> decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox( TextFieldDefaults.TextFieldDecorationBox(
value = state.value.text, value = state.value.text,
innerTextField = innerTextField, innerTextField = innerTextField,
placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary) }, placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary, fontSize = fontSize, maxLines = 1, overflow = TextOverflow.Ellipsis) },
singleLine = true, singleLine = true,
enabled = enabled, enabled = enabled,
isError = !valid, isError = !valid,
@@ -10,24 +10,23 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.*
import androidx.compose.ui.unit.sp
import chat.simplex.res.MR import chat.simplex.res.MR
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import chat.simplex.common.views.usersettings.SettingsActionItemWithContent
@Composable @Composable
fun <T> ExposedDropDownSettingRow( fun <T> ExposedDropDownSetting(
title: String,
values: List<Pair<T, String>>, values: List<Pair<T, String>>,
selection: State<T>, selection: State<T>,
textColor: Color = MaterialTheme.colors.secondary,
fontSize: TextUnit = 16.sp,
label: String? = null, label: String? = null,
icon: Painter? = null,
iconTint: Color = MaterialTheme.colors.secondary,
enabled: State<Boolean> = mutableStateOf(true), enabled: State<Boolean> = mutableStateOf(true),
minWidth: Dp = 200.dp,
maxWidth: Dp = with(LocalDensity.current) { 180.sp.toDp() },
onSelected: (T) -> Unit onSelected: (T) -> Unit
) { ) {
SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) {
val expanded = remember { mutableStateOf(false) } val expanded = remember { mutableStateOf(false) }
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded.value, expanded = expanded.value,
@@ -40,13 +39,13 @@ fun <T> ExposedDropDownSettingRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
val maxWidth = with(LocalDensity.current) { 180.sp.toDp() }
Text( Text(
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""), values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
Modifier.widthIn(max = maxWidth), Modifier.widthIn(max = maxWidth),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colors.secondary color = textColor,
fontSize = fontSize,
) )
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
Icon( Icon(
@@ -56,7 +55,7 @@ fun <T> ExposedDropDownSettingRow(
) )
} }
DefaultExposedDropdownMenu( DefaultExposedDropdownMenu(
modifier = Modifier.widthIn(min = 200.dp), modifier = Modifier.widthIn(min = minWidth),
expanded = expanded, expanded = expanded,
) { ) {
values.forEach { selectionOption -> values.forEach { selectionOption ->
@@ -72,10 +71,29 @@ fun <T> ExposedDropDownSettingRow(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
fontSize = fontSize,
) )
} }
} }
} }
} }
}
@Composable
fun <T> ExposedDropDownSettingRow(
title: String,
values: List<Pair<T, String>>,
selection: State<T>,
textColor: Color = MaterialTheme.colors.secondary,
label: String? = null,
icon: Painter? = null,
iconTint: Color = MaterialTheme.colors.secondary,
enabled: State<Boolean> = mutableStateOf(true),
minWidth: Dp = 200.dp,
maxWidth: Dp = with(LocalDensity.current) { 180.sp.toDp() },
onSelected: (T) -> Unit
) {
SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) {
ExposedDropDownSetting(values, selection ,textColor, label = label, enabled = enabled, minWidth = minWidth, maxWidth = maxWidth, onSelected = onSelected)
} }
} }

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