Compare commits

..

78 Commits

Author SHA1 Message Date
Evgeny Poberezkin 51bf2f413c 5.4.2: ios 186, android 166, desktop 20 2023-12-27 22:24:21 +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 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 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 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 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 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 fa1702a566 5.4.2.0: update .cabal 2023-12-23 14:13:38 +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 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 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
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 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 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
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 2f7632a70f 5.4.1: ios 185, android 164, desktop 19 2023-12-07 21:01:14 +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
185 changed files with 5723 additions and 2548 deletions
+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)
+96 -61
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() // 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()
} 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 {
.onAppear { initializationView()
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,48 +232,37 @@ 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) {
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback")
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") chatModel.chatId = nil
switch (laResult) {
case .success: authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
userAuthorized = true logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
canConnectCall = true switch (laResult) {
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime case .success:
case .failed: chatModel.contentViewAccessAuthenticated = true
if laMode == .passcode { canConnectViewCall = true
AlertManager.shared.showAlert(laFailedAlert()) lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
case .failed:
chatModel.contentViewAccessAuthenticated = false
if privacyLocalAuthModeDefault.get() == .passcode {
AlertManager.shared.showAlert(laFailedAlert())
}
case .unavailable:
prefPerformLA = false
canConnectViewCall = true
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
} }
case .unavailable:
userAuthorized = true
prefPerformLA = false
canConnectCall = true
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
} }
} }
} }
@@ -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:
+28 -6
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")
schedule() let shouldRun_ = lastRanLongAgo
if appStateGroupDefault.get().inactive { if allowBackgroundRefresh() && shouldRun_ {
schedule()
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()
+4 -4
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] = []
@@ -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 {
+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, dispatchQueue: DispatchQueue = DispatchQueue.main, 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.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.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)
}
}
}
}
+10 -2
View File
@@ -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))")
} }
@@ -1234,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()
} }
} }
@@ -1250,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)
} }
@@ -1736,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 {
m.remoteCtrlSession = nil await MainActor.run {
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()
+81 -23
View File
@@ -9,27 +9,28 @@
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
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, privacy: .public)")
} 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 +42,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 +63,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 +71,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 +79,108 @@ 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) {
m.v3DBMigration = v3DBMigrationDefault.get()
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 { do {
m.v3DBMigration = v3DBMigrationDefault.get() try initializeChat(start: m.v3DBMigration.startChat && start, refreshInvitations: refreshInvitations)
try initializeChat(start: m.v3DBMigration.startChat, 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 startChatAndActivate(dispatchQueue: DispatchQueue = DispatchQueue.main, _ completion: @escaping () -> Void) {
terminating = false
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 .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: 10, dispatchQueue: dispatchQueue) { 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
}
}
+27 -40
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.inactive && chatModel.chatRunning == true { if appState != .stopped {
updateChats() startChatAndActivate {
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { if appState.inactive && chatModel.chatRunning == true {
updateCallInvitations() updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
updateCallInvitations()
}
}
} }
} }
doAuthenticate = authenticationExpired()
canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently()
default: default:
break break
} }
@@ -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()
+22 -21
View File
@@ -155,31 +155,32 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
if (!ChatModel.shared.chatInitialized) { if (!ChatModel.shared.chatInitialized) {
initChatAndMigrate(refreshInvitations: false) initChatAndMigrate(refreshInvitations: false)
} }
startChatAndActivate() startChatAndActivate(dispatchQueue: DispatchQueue.global()) {
shouldSuspendChat = true 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 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)
}
// Tell PushKit that the notification is handled.
completion()
} }
// Tell PushKit that the notification is handled. } else {
completion() self.reportExpiredCall(update: update, completion)
} }
} else { } else {
reportExpiredCall(update: update, completion) self.reportExpiredCall(payload: payload, completion)
} }
} else {
reportExpiredCall(payload: payload, completion)
} }
} }
@@ -18,6 +18,7 @@ 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
@@ -308,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
+8 -3
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))
} }
menu.append(shareUIAction(ci)) let fileSource = getLoadedFileSource(ci.file)
menu.append(copyUIAction(ci)) let fileExists = if let fs = fileSource, FileManager.default.fileExists(atPath: getAppFilePath(fs.filePath).path) { true } else { false }
if let fileSource = getLoadedFileSource(ci.file) { let copyAndShareAllowed = !ci.content.text.isEmpty || (ci.content.msgContent?.isImage == true && fileExists)
if copyAndShareAllowed {
menu.append(shareUIAction(ci))
menu.append(copyUIAction(ci))
}
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
showMediaPicker = false await MainActor.run {
if itemsSelected { showMediaPicker = false
DispatchQueue.main.async { if itemsSelected {
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,17 +188,19 @@ struct GroupMemberInfoView: View {
// this condition prevents re-setting picker // this condition prevents re-setting picker
if !justOpened { return } if !justOpened { return }
} }
newRole = member.memberRole
do {
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
_ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats
connectionCode = code
} catch let error {
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
}
justOpened = false justOpened = false
DispatchQueue.main.async {
newRole = member.memberRole
do {
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
_ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats
connectionCode = code
} catch let error {
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
}
}
} }
.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 {
+100 -82
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 if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
for result in results { if let video = await loadVideo(p) {
logger.log("LibraryMediaListPicker result") await self.parent.addMedia(video)
let p = result.itemProvider logger.debug("LibraryMediaListPicker: added video")
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { }
p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in } else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
if let url = url { if let img = await loadImageData(p) {
let tempUrl = URL(fileURLWithPath: getTempFilesDirectory().path + "/" + generateNewFileName("video", url.pathExtension)) await self.parent.addMedia(img)
if ((try? FileManager.default.copyItem(at: url, to: tempUrl)) != nil) { logger.debug("LibraryMediaListPicker: added image")
ChatModel.shared.filesToDelete.insert(tempUrl) }
self.loadVideo(url: tempUrl, error: error) } else if p.canLoadObject(ofClass: UIImage.self) {
if let img = await loadImage(p) {
await self.parent.addMedia(.simpleImage(image: img))
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 {
cont.resume(returning: nil)
}
}
}
}
private func loadImage(_ p: NSItemProvider) async -> UIImage? {
await withCheckedContinuation { cont in
p.loadObject(ofClass: UIImage.self) { obj, err in
if let err = err {
logger.error("LibraryMediaListPicker result image error: \(err.localizedDescription)")
cont.resume(returning: nil)
} else {
cont.resume(returning: obj as? UIImage)
}
}
}
}
private func loadVideo(_ p: NSItemProvider) async -> UploadContent? {
await withCheckedContinuation { cont in
loadFileURL(p, type: UTType.movie) { url in
if let url = url {
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)
}
Task {
let success = await makeVideoQualityLower(tempUrl, outputUrl: convertedVideoUrl)
try? FileManager.default.removeItem(at: tempUrl)
if success {
_ = ChatModel.shared.filesToDelete.insert(convertedVideoUrl)
let video = UploadContent.loadVideoFromURL(url: convertedVideoUrl)
return cont.resume(returning: video)
} }
try? FileManager.default.removeItem(at: convertedVideoUrl)
cont.resume(returning: nil)
} }
} }
} else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) { }
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in }
self.loadImage(object: url, error: error) }
}
} else if p.canLoadObject(ofClass: UIImage.self) { private func loadFileURL(_ p: NSItemProvider, type: UTType, completion: @escaping (URL?) -> Void) {
p.loadObject(ofClass: UIImage.self) { image, error in p.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, err in
DispatchQueue.main.async { if let err = err {
self.loadImage(object: image, error: error) logger.error("LibraryMediaListPicker loadFileURL error: \(err.localizedDescription)")
} completion(nil)
}
} else { } else {
dispatchQueue.sync { self.mediaCount -= 1} completion(url)
}
}
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) {
if let error = error {
logger.error("LibraryMediaListPicker: couldn't load image with error: \(error.localizedDescription)")
} else if let image = object as? UIImage {
media.append(.simpleImage(image: image))
logger.log("LibraryMediaListPicker: added image")
} else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) {
media.append(image)
}
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) {
if let error = error {
logger.error("LibraryMediaListPicker: couldn't load video with error: \(error.localizedDescription)")
} else if let url = url as URL?, let video = UploadContent.loadVideoFromURL(url: url) {
media.append(video)
}
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 = []
} }
} }
} }
@@ -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) {
@@ -74,6 +74,7 @@ struct QRCode: View {
.onAppear { .onAppear {
image = image ?? generateImage(uri, tintColor: tintColor) image = image ?? generateImage(uri, tintColor: tintColor)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
@@ -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)
@@ -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) }
}
}
}
+421 -87
View File
@@ -14,91 +14,231 @@ 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> let nseSuspendDelay: TimeInterval = 2
let nseSuspendTimeout: Int = 5
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, privacy: .public)")
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, privacy: .public) \(ntfInfo.ntfMessages.count, privacy: .public)")
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, privacy: .public)")
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, privacy: .public)")
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, privacy: .public)")
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, privacy: .public)")
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 } 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 \(state.rawValue, privacy: .public)") logger.debug("NotificationService: app state is now \(state.rawValue, privacy: .public)")
if state.inactive { if state.inactive {
receiveNtfMessages(request, contentHandler) receiveNtfMessages(request, contentHandler)
} else { } else {
@@ -106,7 +246,6 @@ class NotificationService: UNNotificationServiceExtension {
} }
} }
default: default:
logger.debug("NotificationService: app state is \(appState.rawValue, privacy: .public)")
deliverBestAttemptNtf() deliverBestAttemptNtf()
} }
} }
@@ -121,27 +260,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), privacy: .public)")
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 {
Task { receiveEntityId = id
logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") NtfStreamSemaphores.shared.waitForStream(id)
await PendingNtfs.shared.createStream(id) if receiveEntityId != nil {
await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count, showNotifications: ntfMsgInfo.user.showNotifications) Task {
deliverBestAttemptNtf() logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)")
await PendingNtfs.shared.createStream(id)
await PendingNtfs.shared.readStream(id, for: self, ntfInfo: ntfInfo)
deliverBestAttemptNtf()
}
} }
return
} }
} }
return } else if let dbStatus = dbStatus {
} else {
setBestAttemptNtf(createErrorNtf(dbStatus)) setBestAttemptNtf(createErrorNtf(dbStatus))
} }
} }
@@ -159,14 +306,14 @@ 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
} }
@@ -174,9 +321,41 @@ class NotificationService: UNNotificationServiceExtension {
private func deliverBestAttemptNtf() { private func deliverBestAttemptNtf() {
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)
}
if let t = threadId {
threadId = nil
if NSEThreads.shared.endThread(t) {
logger.debug("NotificationService.deliverBestAttemptNtf: will suspend")
// suspension is delayed to allow chat core finalise any processing
// (e.g., send delivery receipts)
DispatchQueue.global().asyncAfter(deadline: .now() + nseSuspendDelay) {
if NSEThreads.shared.noThreads {
logger.debug("NotificationService.deliverBestAttemptNtf: suspending...")
suspendChat(nseSuspendTimeout)
}
}
}
}
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):
CXProvider.reportNewIncomingVoIPPushPayload([ CXProvider.reportNewIncomingVoIPPushPayload([
"displayName": invitation.contact.displayName, "displayName": invitation.contact.displayName,
@@ -184,66 +363,201 @@ class NotificationService: UNNotificationServiceExtension {
"media": invitation.callType.media.rawValue "media": invitation.callType.media.rawValue
]) { error in ]) { error in
if error == nil { if error == nil {
handler(UNMutableNotificationContent()) deliver(nil)
} else { } else {
logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") logger.debug("NotificationService reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)")
handler(createCallInvitationNtf(invitation)) deliver(createCallInvitationNtf(invitation))
} }
} }
case .empty: handler(UNMutableNotificationContent()) 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
} }
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(nseSuspendTimeout)
}
}
func appStateSubscriber(onState: @escaping (AppState) -> Void) -> AppSubscriber {
appMessageSubscriber { msg in
if case let .state(state) = msg {
logger.debug("NotificationService: appStateSubscriber \(state.rawValue, privacy: .public)")
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? {
logger.debug("NotificationService: startChat")
if case .active = NSEChatState.shared.value { return .ok }
startLock.wait()
defer { startLock.signal() }
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()
}
}
func doStartChat() -> DBMigrationResult? {
logger.debug("NotificationService: doStartChat")
hs_init(0, nil) hs_init(0, nil)
if chatStarted { return .ok } let (_, dbStatus) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation(), backgroundMode: true)
let (_, dbStatus) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation())
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())
let justStarted = try apiStartChat() // prevent suspension while starting chat
chatStarted = true suspendLock.wait()
if justStarted { defer { suspendLock.signal() }
chatLastStartGroupDefault.set(Date.now) if NSEChatState.shared.value == .starting {
Task { await receiveMessages() } updateNetCfg()
let justStarted = try apiStartChat()
NSEChatState.shared.set(.active)
if justStarted {
chatLastStartGroupDefault.set(Date.now)
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), privacy: .public)")
} }
} 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, privacy: .public)")
} else {
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 +571,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, privacy: .public)")
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 +588,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 +606,15 @@ 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
default: default:
logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)") logger.debug("NotificationService receivedMsgNtf ignored event: \(res.responseType)")
return nil return nil
} }
} }
@@ -334,6 +653,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,8 +698,8 @@ 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) 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 {
@@ -405,11 +739,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)
} }
} }
+48 -32
View File
@@ -43,11 +43,11 @@
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; };
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; };
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; };
5C4BB4B82B1E7D75007981AA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B32B1E7D75007981AA /* libgmp.a */; }; 5C4E80DA2B3CCD090080FAE2 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4E80D52B3CCD090080FAE2 /* libgmp.a */; };
5C4BB4B92B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */; }; 5C4E80DB2B3CCD090080FAE2 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4E80D62B3CCD090080FAE2 /* libffi.a */; };
5C4BB4BA2B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */; }; 5C4E80DC2B3CCD090080FAE2 /* libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4E80D72B3CCD090080FAE2 /* libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5.a */; };
5C4BB4BB2B1E7D75007981AA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B62B1E7D75007981AA /* libffi.a */; }; 5C4E80DD2B3CCD090080FAE2 /* libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4E80D82B3CCD090080FAE2 /* libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5-ghc9.6.3.a */; };
5C4BB4BC2B1E7D75007981AA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B72B1E7D75007981AA /* libgmpxx.a */; }; 5C4E80DE2B3CCD090080FAE2 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4E80D92B3CCD090080FAE2 /* libgmpxx.a */; };
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; };
5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; };
@@ -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 */; };
@@ -185,6 +188,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 */; };
@@ -290,11 +294,11 @@
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = "<group>"; }; 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = "<group>"; };
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = "<group>"; }; 5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = "<group>"; };
5C4BB4B32B1E7D75007981AA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; }; 5C4E80D52B3CCD090080FAE2 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a"; sourceTree = "<group>"; }; 5C4E80D62B3CCD090080FAE2 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a"; sourceTree = "<group>"; }; 5C4E80D72B3CCD090080FAE2 /* libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5.a"; sourceTree = "<group>"; };
5C4BB4B62B1E7D75007981AA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; }; 5C4E80D82B3CCD090080FAE2 /* libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5-ghc9.6.3.a"; sourceTree = "<group>"; };
5C4BB4B72B1E7D75007981AA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; }; 5C4E80D92B3CCD090080FAE2 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; };
5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = "<group>"; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = "<group>"; };
5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = "<group>"; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = "<group>"; };
@@ -434,6 +438,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; };
@@ -470,6 +477,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 +519,13 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5C4E80DD2B3CCD090080FAE2 /* libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5-ghc9.6.3.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C4BB4BB2B1E7D75007981AA /* libffi.a in Frameworks */, 5C4E80DA2B3CCD090080FAE2 /* libgmp.a in Frameworks */,
5C4BB4BA2B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a in Frameworks */, 5C4E80DC2B3CCD090080FAE2 /* libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5.a in Frameworks */,
5C4BB4B92B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a in Frameworks */,
5C4BB4B82B1E7D75007981AA /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5C4BB4BC2B1E7D75007981AA /* libgmpxx.a in Frameworks */, 5C4E80DB2B3CCD090080FAE2 /* libffi.a in Frameworks */,
5C4E80DE2B3CCD090080FAE2 /* libgmpxx.a in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -579,11 +587,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = { 5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5C4BB4B62B1E7D75007981AA /* libffi.a */, 5C4E80D62B3CCD090080FAE2 /* libffi.a */,
5C4BB4B32B1E7D75007981AA /* libgmp.a */, 5C4E80D52B3CCD090080FAE2 /* libgmp.a */,
5C4BB4B72B1E7D75007981AA /* libgmpxx.a */, 5C4E80D92B3CCD090080FAE2 /* libgmpxx.a */,
5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */, 5C4E80D82B3CCD090080FAE2 /* libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5-ghc9.6.3.a */,
5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */, 5C4E80D72B3CCD090080FAE2 /* libHSsimplex-chat-5.4.2.1-FP1oxJSttEYhorN1FRfI5.a */,
); );
path = Libraries; path = Libraries;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -608,6 +616,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 +644,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 +798,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 +819,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 +1192,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 +1215,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 +1273,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 +1281,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 +1518,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 = 186;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -1524,7 +1540,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1545,7 +1561,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 = 186;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -1567,7 +1583,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1626,7 +1642,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 = 186;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1639,7 +1655,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.2;
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 +1674,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 = 186;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1671,7 +1687,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.2;
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 +1706,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 = 186;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1714,7 +1730,7 @@
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.2;
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 +1752,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 = 186;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1760,7 +1776,7 @@
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
MARKETING_VERSION = 5.4; MARKETING_VERSION = 5.4.2;
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;
+9 -2
View File
@@ -17,7 +17,7 @@ public func getChatCtrl(_ useKey: String? = nil) -> chat_ctrl {
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 +41,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 +57,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
+14 -9
View File
@@ -27,7 +27,7 @@ public enum ChatCommand {
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(subscribe: Bool, expire: Bool, xftp: 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)
@@ -156,7 +156,7 @@ public enum ChatCommand {
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(subscribe, expire, xftp): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire)) xftp=\(onOff(xftp))"
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)"
@@ -604,7 +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 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])
@@ -751,6 +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 .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"
@@ -898,6 +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 .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)"
@@ -1204,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,
@@ -1495,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"
@@ -1502,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"
} }
} }
+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);
+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,9 +43,7 @@ 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()
@@ -206,6 +226,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()
@@ -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()
)
}
}
}
@@ -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;
@@ -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;
@@ -42,9 +42,11 @@ data class SettingsViewState(
@Composable @Composable
fun AppScreen() { fun AppScreen() {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { SimpleXTheme {
Surface(color = MaterialTheme.colors.background) { ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
MainScreen() Surface(color = MaterialTheme.colors.background) {
MainScreen()
}
} }
} }
} }
@@ -160,11 +162,26 @@ fun MainScreen() {
AuthView() AuthView()
} else { } else {
SplashView() SplashView()
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)
}
}
} }
} else if (chatModel.showCallView.value) {
ActiveCallView()
} }
ModalManager.fullscreen.showPasscodeInView()
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()
@@ -315,9 +332,11 @@ fun DesktopScreen(settingsState: SettingsViewState) {
) )
} }
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH)) VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH))
UserPicker(chatModel, userPickerState) { tryOrShowError("UserPicker", error = {}) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } UserPicker(chatModel, userPickerState) {
userPickerState.value = AnimatedViewState.GONE scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
} }
ModalManager.fullscreen.showInView() ModalManager.fullscreen.showInView()
} }
@@ -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.saveable.rememberSaveable
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
@@ -59,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)
@@ -68,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 {
@@ -618,10 +620,10 @@ 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 val connectedToRemote: Boolean @Composable get() = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
@@ -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
@@ -2023,7 +2023,8 @@ object ChatController {
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)
@@ -2800,9 +2801,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
@@ -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
@@ -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 = {}
@@ -207,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
@@ -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,7 +900,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
@Composable @Composable
fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) { fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) {
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) 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)
}
} }
@Composable @Composable
@@ -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
else delay(500)
0 allowLoad.value = true
} }
.distinctUntilChanged() }
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
0
}
.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)
}
}
@@ -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 = {
InvalidDataView() tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) {
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) {
@@ -71,7 +65,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
val (userPickerState, scaffoldState ) = 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 = {
@@ -118,12 +116,16 @@ 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
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
} }
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState) { tryOrShowError("UserPicker", error = {}) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } UserPicker(chatModel, userPickerState) {
userPickerState.value = AnimatedViewState.GONE scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
} }
} }
} }
@@ -302,7 +304,7 @@ expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<An
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)
@@ -310,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
@@ -47,10 +47,12 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
} }
} }
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = { tryOrShowError("UserPicker", error = {}) {
chatModel.sharedContent.value = null UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = {
userPickerState.value = AnimatedViewState.GONE chatModel.sharedContent.value = null
}) userPickerState.value = AnimatedViewState.GONE
})
}
} }
} }
@@ -270,7 +270,7 @@ private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onCli
} 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 {
@@ -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,53 +243,71 @@ 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)) {
Column( BoxWithConstraints {
Modifier Column(
.padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) Modifier
) { .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF)
if (appPlatform.isDesktop) { ) {
HostDeviceTitle(hostDevice, extraPadding = extraPadding) if (appPlatform.isDesktop) {
} else { HostDeviceTitle(hostDevice, extraPadding = extraPadding)
Spacer(Modifier.size(DEFAULT_PADDING_HALF)) } else {
} Spacer(Modifier.size(DEFAULT_PADDING_HALF))
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
if (text != null) {
Text(
escapedHtmlToAnnotatedString(text, LocalDensity.current),
Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f),
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.secondary
)
} }
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
if (text != null) {
Column(Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f)
.verticalScroll(rememberScrollState())
) {
SelectionContainer {
Text(
escapedHtmlToAnnotatedString(text, LocalDensity.current),
Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f),
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.secondary
)
}
}
}
}
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)) {
Column( BoxWithConstraints {
Modifier Column(
.padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) Modifier
) { .verticalScroll(rememberScrollState())
if (appPlatform.isDesktop) { .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF)
HostDeviceTitle(hostDevice, extraPadding = extraPadding) ) {
} else { if (appPlatform.isDesktop) {
Spacer(Modifier.size(DEFAULT_PADDING_HALF)) HostDeviceTitle(hostDevice, extraPadding = extraPadding)
} } else {
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { Spacer(Modifier.size(DEFAULT_PADDING_HALF))
if (text != null) {
Text(
text,
Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f),
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.secondary
)
} }
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
if (text != null) {
Column(
Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f)
.verticalScroll(rememberScrollState())
) {
SelectionContainer {
Text(
text,
Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f),
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.secondary
)
}
}
}
}
content()
} }
content()
} }
} }
@@ -390,6 +390,28 @@ fun IntSize.Companion.Saver(): Saver<IntSize, *> = Saver(
restore = { IntSize(it.first, it.second) } restore = { IntSize(it.first, it.second) }
) )
private var lastExecutedComposables = HashSet<Any>()
private val failedComposables = HashSet<Any>()
@Composable
fun tryOrShowError(key: Any = Exception().stackTraceToString().lines()[2], error: @Composable () -> Unit = {}, content: @Composable () -> Unit) {
if (!failedComposables.contains(key)) {
lastExecutedComposables.add(key)
content()
lastExecutedComposables.remove(key)
} else {
error()
}
}
fun includeMoreFailedComposables() {
lastExecutedComposables.forEach {
failedComposables.add(it)
Log.i(TAG, "Added composable key as failed: $it")
}
lastExecutedComposables.clear()
}
@Composable @Composable
fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) { fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) {
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -70,7 +70,8 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (
m.controller.startChat(createdUser) m.controller.startChat(createdUser)
} }
ModalManager.fullscreen.closeModals() ModalManager.fullscreen.closeModals()
AlertManager.shared.hideAlert() AlertManager.shared.hideAllAlerts()
AlertManager.privacySensitive.hideAllAlerts()
completed(LAResult.Success) completed(LAResult.Success)
} catch (e: Exception) { } catch (e: Exception) {
completed(LAResult.Error(generalGetString(MR.strings.incorrect_passcode))) completed(LAResult.Error(generalGetString(MR.strings.incorrect_passcode)))
@@ -67,7 +67,7 @@ fun QRCode(
scope.launch { scope.launch {
val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb())
.let { if (withLogo) it.addLogo() else it } .let { if (withLogo) it.addLogo() else it }
val file = saveTempImageUncompressed(image, false) val file = saveTempImageUncompressed(image, true)
if (file != null) { if (file != null) {
shareFile("", CryptoFile.plain(file.absolutePath)) shareFile("", CryptoFile.plain(file.absolutePath))
} }
@@ -20,7 +20,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.IncognitoView
import chat.simplex.res.MR import chat.simplex.res.MR
import java.net.URI import java.net.URI
@@ -58,7 +58,7 @@ suspend fun planAndConnect(
InvitationLinkPlan.OwnLink -> { InvitationLinkPlan.OwnLink -> {
Log.d(TAG, "planAndConnect, .InvitationLink, .OwnLink, incognito=$incognito") Log.d(TAG, "planAndConnect, .InvitationLink, .OwnLink, incognito=$incognito")
if (incognito != null) { if (incognito != null) {
AlertManager.shared.showAlertDialog( AlertManager.privacySensitive.showAlertDialog(
title = generalGetString(MR.strings.connect_plan_connect_to_yourself), title = generalGetString(MR.strings.connect_plan_connect_to_yourself),
text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link), text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link),
confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb),
@@ -80,13 +80,13 @@ suspend fun planAndConnect(
val contact = connectionPlan.invitationLinkPlan.contact_ val contact = connectionPlan.invitationLinkPlan.contact_
if (contact != null) { if (contact != null) {
openKnownContact(chatModel, rhId, close, contact) openKnownContact(chatModel, rhId, close, contact)
AlertManager.shared.showAlertMsg( AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.contact_already_exists), generalGetString(MR.strings.contact_already_exists),
String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName),
hostDevice = hostDevice(rhId), hostDevice = hostDevice(rhId),
) )
} else { } else {
AlertManager.shared.showAlertMsg( AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.connect_plan_already_connecting), generalGetString(MR.strings.connect_plan_already_connecting),
generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link), generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link),
hostDevice = hostDevice(rhId), hostDevice = hostDevice(rhId),
@@ -97,7 +97,7 @@ suspend fun planAndConnect(
Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito") Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito")
val contact = connectionPlan.invitationLinkPlan.contact val contact = connectionPlan.invitationLinkPlan.contact
openKnownContact(chatModel, rhId, close, contact) openKnownContact(chatModel, rhId, close, contact)
AlertManager.shared.showAlertMsg( AlertManager.privacySensitive.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), contact.displayName), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName),
hostDevice = hostDevice(rhId), hostDevice = hostDevice(rhId),
@@ -121,7 +121,7 @@ suspend fun planAndConnect(
ContactAddressPlan.OwnLink -> { ContactAddressPlan.OwnLink -> {
Log.d(TAG, "planAndConnect, .ContactAddress, .OwnLink, incognito=$incognito") Log.d(TAG, "planAndConnect, .ContactAddress, .OwnLink, incognito=$incognito")
if (incognito != null) { if (incognito != null) {
AlertManager.shared.showAlertDialog( AlertManager.privacySensitive.showAlertDialog(
title = generalGetString(MR.strings.connect_plan_connect_to_yourself), title = generalGetString(MR.strings.connect_plan_connect_to_yourself),
text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address), text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address),
confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb),
@@ -141,7 +141,7 @@ suspend fun planAndConnect(
ContactAddressPlan.ConnectingConfirmReconnect -> { ContactAddressPlan.ConnectingConfirmReconnect -> {
Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingConfirmReconnect, incognito=$incognito") Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingConfirmReconnect, incognito=$incognito")
if (incognito != null) { if (incognito != null) {
AlertManager.shared.showAlertDialog( AlertManager.privacySensitive.showAlertDialog(
title = generalGetString(MR.strings.connect_plan_repeat_connection_request), title = generalGetString(MR.strings.connect_plan_repeat_connection_request),
text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address), text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address),
confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb),
@@ -162,7 +162,7 @@ suspend fun planAndConnect(
Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito") Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito")
val contact = connectionPlan.contactAddressPlan.contact val contact = connectionPlan.contactAddressPlan.contact
openKnownContact(chatModel, rhId, close, contact) openKnownContact(chatModel, rhId, close, contact)
AlertManager.shared.showAlertMsg( AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.contact_already_exists), generalGetString(MR.strings.contact_already_exists),
String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName),
hostDevice = hostDevice(rhId), hostDevice = hostDevice(rhId),
@@ -172,7 +172,7 @@ suspend fun planAndConnect(
Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito") Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito")
val contact = connectionPlan.contactAddressPlan.contact val contact = connectionPlan.contactAddressPlan.contact
openKnownContact(chatModel, rhId, close, contact) openKnownContact(chatModel, rhId, close, contact)
AlertManager.shared.showAlertMsg( AlertManager.privacySensitive.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), contact.displayName), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName),
hostDevice = hostDevice(rhId), hostDevice = hostDevice(rhId),
@@ -193,7 +193,7 @@ suspend fun planAndConnect(
GroupLinkPlan.Ok -> { GroupLinkPlan.Ok -> {
Log.d(TAG, "planAndConnect, .GroupLink, .Ok, incognito=$incognito") Log.d(TAG, "planAndConnect, .GroupLink, .Ok, incognito=$incognito")
if (incognito != null) { if (incognito != null) {
AlertManager.shared.showAlertDialog( AlertManager.privacySensitive.showAlertDialog(
title = generalGetString(MR.strings.connect_via_group_link), title = generalGetString(MR.strings.connect_via_group_link),
text = generalGetString(MR.strings.you_will_join_group), text = generalGetString(MR.strings.you_will_join_group),
confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button),
@@ -217,7 +217,7 @@ suspend fun planAndConnect(
GroupLinkPlan.ConnectingConfirmReconnect -> { GroupLinkPlan.ConnectingConfirmReconnect -> {
Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito") Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito")
if (incognito != null) { if (incognito != null) {
AlertManager.shared.showAlertDialog( AlertManager.privacySensitive.showAlertDialog(
title = generalGetString(MR.strings.connect_plan_repeat_join_request), title = generalGetString(MR.strings.connect_plan_repeat_join_request),
text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link),
confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button),
@@ -238,12 +238,12 @@ suspend fun planAndConnect(
Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingProhibit, incognito=$incognito") Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingProhibit, incognito=$incognito")
val groupInfo = connectionPlan.groupLinkPlan.groupInfo_ val groupInfo = connectionPlan.groupLinkPlan.groupInfo_
if (groupInfo != null) { if (groupInfo != null) {
AlertManager.shared.showAlertMsg( AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.connect_plan_group_already_exists), generalGetString(MR.strings.connect_plan_group_already_exists),
String.format(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_vName), groupInfo.displayName) String.format(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_vName), groupInfo.displayName)
) )
} else { } else {
AlertManager.shared.showAlertMsg( AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.connect_plan_already_joining_the_group), generalGetString(MR.strings.connect_plan_already_joining_the_group),
generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link),
hostDevice = hostDevice(rhId), hostDevice = hostDevice(rhId),
@@ -254,7 +254,7 @@ suspend fun planAndConnect(
Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito")
val groupInfo = connectionPlan.groupLinkPlan.groupInfo val groupInfo = connectionPlan.groupLinkPlan.groupInfo
openKnownGroup(chatModel, rhId, close, groupInfo) openKnownGroup(chatModel, rhId, close, groupInfo)
AlertManager.shared.showAlertMsg( AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.connect_plan_group_already_exists), generalGetString(MR.strings.connect_plan_group_already_exists),
String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName), String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName),
hostDevice = hostDevice(rhId), hostDevice = hostDevice(rhId),
@@ -289,7 +289,7 @@ suspend fun connectViaUri(
if (pcc != null) { if (pcc != null) {
chatModel.updateContactConnection(rhId, pcc) chatModel.updateContactConnection(rhId, pcc)
close?.invoke() close?.invoke()
AlertManager.shared.showAlertMsg( AlertManager.privacySensitive.showAlertMsg(
title = generalGetString(MR.strings.connection_request_sent), title = generalGetString(MR.strings.connection_request_sent),
text = text =
when (connLinkType) { when (connLinkType) {
@@ -320,14 +320,14 @@ fun askCurrentOrIncognitoProfileAlert(
text: AnnotatedString? = null, text: AnnotatedString? = null,
connectDestructive: Boolean, connectDestructive: Boolean,
) { ) {
AlertManager.shared.showAlertDialogButtonsColumn( AlertManager.privacySensitive.showAlertDialogButtonsColumn(
title = title, title = title,
text = text, text = text,
buttons = { buttons = {
Column { Column {
val connectColor = if (connectDestructive) MaterialTheme.colors.error else MaterialTheme.colors.primary val connectColor = if (connectDestructive) MaterialTheme.colors.error else MaterialTheme.colors.primary
SectionItemView({ SectionItemView({
AlertManager.shared.hideAlert() AlertManager.privacySensitive.hideAlert()
withApi { withApi {
connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close)
} }
@@ -335,7 +335,7 @@ fun askCurrentOrIncognitoProfileAlert(
Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor)
} }
SectionItemView({ SectionItemView({
AlertManager.shared.hideAlert() AlertManager.privacySensitive.hideAlert()
withApi { withApi {
connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close)
} }
@@ -343,7 +343,7 @@ fun askCurrentOrIncognitoProfileAlert(
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor)
} }
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)
} }
@@ -372,14 +372,14 @@ fun ownGroupLinkConfirmConnect(
groupInfo: GroupInfo, groupInfo: GroupInfo,
close: (() -> Unit)?, close: (() -> Unit)?,
) { ) {
AlertManager.shared.showAlertDialogButtonsColumn( AlertManager.privacySensitive.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.connect_plan_join_your_group), title = generalGetString(MR.strings.connect_plan_join_your_group),
text = AnnotatedString(String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName)), text = AnnotatedString(String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName)),
buttons = { buttons = {
Column { Column {
// Open group // Open group
SectionItemView({ SectionItemView({
AlertManager.shared.hideAlert() AlertManager.privacySensitive.hideAlert()
openKnownGroup(chatModel, rhId, close, groupInfo) openKnownGroup(chatModel, rhId, close, groupInfo)
}) { }) {
Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
@@ -387,7 +387,7 @@ fun ownGroupLinkConfirmConnect(
if (incognito != null) { if (incognito != null) {
// Join incognito / Join with current profile // Join incognito / Join with current profile
SectionItemView({ SectionItemView({
AlertManager.shared.hideAlert() AlertManager.privacySensitive.hideAlert()
withApi { withApi {
connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close)
} }
@@ -400,7 +400,7 @@ fun ownGroupLinkConfirmConnect(
} else { } else {
// Use current profile // Use current profile
SectionItemView({ SectionItemView({
AlertManager.shared.hideAlert() AlertManager.privacySensitive.hideAlert()
withApi { withApi {
connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close)
} }
@@ -409,7 +409,7 @@ fun ownGroupLinkConfirmConnect(
} }
// Use new incognito profile // Use new incognito profile
SectionItemView({ SectionItemView({
AlertManager.shared.hideAlert() AlertManager.privacySensitive.hideAlert()
withApi { withApi {
connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close)
} }
@@ -419,7 +419,7 @@ fun ownGroupLinkConfirmConnect(
} }
// Cancel // Cancel
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)
} }
@@ -48,14 +48,6 @@ fun CreateSimpleXAddress(m: ChatModel, rhId: Long?) {
val connReqContact = m.controller.apiCreateUserAddress(rhId) val connReqContact = m.controller.apiCreateUserAddress(rhId)
if (connReqContact != null) { if (connReqContact != null) {
m.userAddress.value = UserContactLinkRec(connReqContact) m.userAddress.value = UserContactLinkRec(connReqContact)
try {
val u = m.controller.apiSetProfileAddress(rhId, true)
if (u != null) {
m.updateUser(u)
}
} catch (e: Exception) {
Log.e(TAG, "CreateSimpleXAddress apiSetProfileAddress: ${e.stackTraceToString()}")
}
progressIndicator = false progressIndicator = false
} }
} }
@@ -100,7 +92,7 @@ private fun CreateSimpleXAddressLayout(
ContinueButton(nextStep) ContinueButton(nextStep)
} else { } else {
CreateAddressButton(createAddress) CreateAddressButton(createAddress)
TextBelowButton(stringResource(MR.strings.your_contacts_will_see_it)) TextBelowButton(stringResource(MR.strings.you_can_make_address_visible_via_settings))
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
SkipButton(nextStep) SkipButton(nextStep)
} }
@@ -73,7 +73,8 @@ private fun LinkAMobileLayout(
} }
Box(Modifier.weight(0.7f)) { Box(Modifier.weight(0.7f)) {
AddingMobileDevice(false, staleQrCode, connecting) { AddingMobileDevice(false, staleQrCode, connecting) {
if (chatModel.remoteHosts.isEmpty()) { // currentRemoteHost will be set instantly but remoteHosts may be delayed
if (chatModel.remoteHosts.isEmpty() && chatModel.currentRemoteHost.value == null) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
} else { } else {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
@@ -1,10 +1,7 @@
package chat.simplex.common.views.onboarding package chat.simplex.common.views.onboarding
import SectionBottomSpacer import SectionBottomSpacer
import SectionItemView
import SectionItemViewSpaceBetween
import SectionTextFooter import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
@@ -15,14 +12,12 @@ 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.focus.* import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
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.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.dp
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
@@ -125,7 +120,7 @@ private fun SetupDatabasePassphraseLayout(
.padding(horizontal = DEFAULT_PADDING) .padding(horizontal = DEFAULT_PADDING)
.focusRequester(focusRequester) .focusRequester(focusRequester)
.onPreviewKeyEvent { .onPreviewKeyEvent {
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) {
focusManager.moveFocus(FocusDirection.Down) focusManager.moveFocus(FocusDirection.Down)
true true
} else { } else {
@@ -155,7 +150,7 @@ private fun SetupDatabasePassphraseLayout(
modifier = Modifier modifier = Modifier
.padding(horizontal = DEFAULT_PADDING) .padding(horizontal = DEFAULT_PADDING)
.onPreviewKeyEvent { .onPreviewKeyEvent {
if (!disabled && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { if (!disabled && (it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) {
onClickUpdate() onClickUpdate()
true true
} else { } else {
@@ -177,7 +172,10 @@ private fun SetupDatabasePassphraseLayout(
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
SkipButton(progressIndicator.value, nextStep) SkipButton(progressIndicator.value) {
chatModel.desktopOnboardingRandomPassword.value = true
nextStep()
}
SectionBottomSpacer() SectionBottomSpacer()
} }
@@ -154,20 +154,20 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
SectionItemView { SectionItemView {
TimeoutSettingRow( TimeoutSettingRow(
stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout, stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout,
listOf(5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000_000, 45_000_000), secondsLabel listOf(7_500000, 10_000000, 15_000000, 20_000000, 30_000_000, 45_000_000), secondsLabel
) )
} }
SectionItemView { SectionItemView {
TimeoutSettingRow( TimeoutSettingRow(
stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout, stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout,
listOf(3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel
) )
} }
SectionItemView { SectionItemView {
// can't be higher than 130ms to avoid overflow on 32bit systems // can't be higher than 130ms to avoid overflow on 32bit systems
TimeoutSettingRow( TimeoutSettingRow(
stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb,
listOf(15_000, 30_000, 60_000, 90_000, 120_000), secondsLabel listOf(15_000, 30_000, 45_000, 60_000, 90_000, 120_000), secondsLabel
) )
} }
SectionItemView { SectionItemView {
@@ -150,7 +150,6 @@
<string name="add_new_contact_to_create_one_time_QR_code"><![CDATA[<b> إضافة جهة اتصال جديدة </b>: لإنشاء رمز الاستجابة السريعة الخاص بك لمرة واحدة لجهة اتصالك.]]></string> <string name="add_new_contact_to_create_one_time_QR_code"><![CDATA[<b> إضافة جهة اتصال جديدة </b>: لإنشاء رمز الاستجابة السريعة الخاص بك لمرة واحدة لجهة اتصالك.]]></string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><![CDATA[<b> امسح رمز الاستجابة السريعة </b>: للاتصال بجهة الاتصال التي تعرض لك رمز الاستجابة السريعة.]]></string> <string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><![CDATA[<b> امسح رمز الاستجابة السريعة </b>: للاتصال بجهة الاتصال التي تعرض لك رمز الاستجابة السريعة.]]></string>
<string name="callstatus_in_progress">مكالمتك تحت الإجراء</string> <string name="callstatus_in_progress">مكالمتك تحت الإجراء</string>
<string name="callstatus_ended">انتهت المكالمة</string>
<string name="change_database_passphrase_question">تغيير عبارة مرور قاعدة البيانات؟</string> <string name="change_database_passphrase_question">تغيير عبارة مرور قاعدة البيانات؟</string>
<string name="cannot_access_keychain">لا يمكن الوصول إلى Keystore لحفظ كلمة مرور قاعدة البيانات</string> <string name="cannot_access_keychain">لا يمكن الوصول إلى Keystore لحفظ كلمة مرور قاعدة البيانات</string>
<string name="icon_descr_cancel_file_preview">إلغاء معاينة الملف</string> <string name="icon_descr_cancel_file_preview">إلغاء معاينة الملف</string>
@@ -1276,8 +1275,6 @@
<string name="gallery_video_button">فيديو</string> <string name="gallery_video_button">فيديو</string>
<string name="you_can_share_your_address">يمكنك مشاركة عنوانك كرابط أو رمز QR - يمكن لأي شخص الاتصال بك.</string> <string name="you_can_share_your_address">يمكنك مشاركة عنوانك كرابط أو رمز QR - يمكن لأي شخص الاتصال بك.</string>
<string name="you_can_create_it_later">يمكنك إنشاؤه لاحقًا</string> <string name="you_can_create_it_later">يمكنك إنشاؤه لاحقًا</string>
<string name="your_contacts_will_see_it">سوف تراها جهات اتصالك في whatsapp.
\nيمكنك تغييره في الإعدادات.</string>
<string name="invite_prohibited_description">أنت تحاول دعوة جهة اتصال قمت بمشاركة ملف تعريف متخفي معها إلى المجموعة التي تستخدم فيها ملفك الشخصي الرئيسي</string> <string name="invite_prohibited_description">أنت تحاول دعوة جهة اتصال قمت بمشاركة ملف تعريف متخفي معها إلى المجموعة التي تستخدم فيها ملفك الشخصي الرئيسي</string>
<string name="user_unmute">إلغاء الكتم</string> <string name="user_unmute">إلغاء الكتم</string>
<string name="unmute_chat">إلغاء الكتم</string> <string name="unmute_chat">إلغاء الكتم</string>
@@ -18,6 +18,7 @@
<string name="opening_database">Opening database…</string> <string name="opening_database">Opening database…</string>
<string name="non_content_uri_alert_title">Invalid file path</string> <string name="non_content_uri_alert_title">Invalid file path</string>
<string name="non_content_uri_alert_text">You shared an invalid file path. Report the issue to the app developers.</string> <string name="non_content_uri_alert_text">You shared an invalid file path. Report the issue to the app developers.</string>
<string name="app_was_crashed">View crashed</string>
<!-- Server info - ChatModel.kt --> <!-- Server info - ChatModel.kt -->
<string name="server_connected">connected</string> <string name="server_connected">connected</string>
@@ -44,6 +45,9 @@
<string name="moderated_description">moderated</string> <string name="moderated_description">moderated</string>
<string name="invalid_chat">invalid chat</string> <string name="invalid_chat">invalid chat</string>
<string name="invalid_data">invalid data</string> <string name="invalid_data">invalid data</string>
<string name="error_showing_message">error showing message</string>
<string name="error_showing_content">error showing content</string>
<string name="decryption_error">Decryption error</string> <string name="decryption_error">Decryption error</string>
<string name="encryption_renegotiation_error">Encryption re-negotiation error</string> <string name="encryption_renegotiation_error">Encryption re-negotiation error</string>
@@ -689,7 +693,7 @@
<string name="continue_to_next_step">Continue</string> <string name="continue_to_next_step">Continue</string>
<string name="dont_create_address">Don\'t create address</string> <string name="dont_create_address">Don\'t create address</string>
<string name="you_can_create_it_later">You can create it later</string> <string name="you_can_create_it_later">You can create it later</string>
<string name="your_contacts_will_see_it">Your contacts in SimpleX will see it.\nYou can change it in Settings.</string> <string name="you_can_make_address_visible_via_settings">You can make it visible to your SimpleX contacts via Settings.</string>
<!-- User profile details - UserProfileView.kt --> <!-- User profile details - UserProfileView.kt -->
<string name="display_name__field">Profile name:</string> <string name="display_name__field">Profile name:</string>
@@ -1236,8 +1236,6 @@
<string name="network_disable_socks">Използване на директна интернет връзка\?</string> <string name="network_disable_socks">Използване на директна интернет връзка\?</string>
<string name="network_use_onion_hosts">Използвай .onion хостове</string> <string name="network_use_onion_hosts">Използвай .onion хостове</string>
<string name="network_use_onion_hosts_prefer">Когато са налични</string> <string name="network_use_onion_hosts_prefer">Когато са налични</string>
<string name="your_contacts_will_see_it">Вашите контакти в SimpleX ще го видят.
\nМожете да го промените в Настройки.</string>
<string name="your_profile_is_stored_on_your_device">Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство.</string> <string name="your_profile_is_stored_on_your_device">Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство.</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Можете да използвате markdown за форматиране на съобщенията:</string> <string name="you_can_use_markdown_to_format_messages__prompt">Можете да използвате markdown за форматиране на съобщенията:</string>
<string name="you_control_servers_to_receive_your_contacts_to_send"><![CDATA[Вие контролирате през кой сървър(и) <b>да получавате</b> съобщенията, вашите контакти – сървърите, които използвате, за да им изпращате съобщения.]]></string> <string name="you_control_servers_to_receive_your_contacts_to_send"><![CDATA[Вие контролирате през кой сървър(и) <b>да получавате</b> съобщенията, вашите контакти – сървърите, които използвате, за да им изпращате съобщения.]]></string>
@@ -1247,8 +1247,6 @@
<string name="only_your_contact_can_add_message_reactions">Reakce na zprávy může přidávat pouze váš kontakt.</string> <string name="only_your_contact_can_add_message_reactions">Reakce na zprávy může přidávat pouze váš kontakt.</string>
<string name="your_contacts_will_remain_connected">Vaše kontakty zůstanou připojeny.</string> <string name="your_contacts_will_remain_connected">Vaše kontakty zůstanou připojeny.</string>
<string name="you_can_share_this_address_with_your_contacts">Tuto adresu můžete sdílet se svými kontakty, aby se mohli připojit k %s.</string> <string name="you_can_share_this_address_with_your_contacts">Tuto adresu můžete sdílet se svými kontakty, aby se mohli připojit k %s.</string>
<string name="your_contacts_will_see_it">Vaše kontakty v SimpleX ji uvidí.
\nMůžete ji změnit v Nastavení.</string>
<string name="you_can_share_your_address">Svou adresu můžete sdílet jako odkaz nebo QR kód - kdokoli se k vám může připojit.</string> <string name="you_can_share_your_address">Svou adresu můžete sdílet jako odkaz nebo QR kód - kdokoli se k vám může připojit.</string>
<string name="you_wont_lose_your_contacts_if_delete_address">Pokud později adresu odstraníte, o kontakty nepřijdete.</string> <string name="you_wont_lose_your_contacts_if_delete_address">Pokud později adresu odstraníte, o kontakty nepřijdete.</string>
<string name="app_passcode_replaced_with_self_destruct">Přístupový kód aplikace je nahrazen sebedestrukčním přístupovým heslem.</string> <string name="app_passcode_replaced_with_self_destruct">Přístupový kód aplikace je nahrazen sebedestrukčním přístupovým heslem.</string>
@@ -1256,8 +1256,6 @@
<string name="import_theme_error_desc">Stellen Sie sicher, dass die Datei die korrekte YAML-Syntax hat. Exportieren Sie das Design, um ein Beispiel für die Dateistruktur des Designs zu erhalten.</string> <string name="import_theme_error_desc">Stellen Sie sicher, dass die Datei die korrekte YAML-Syntax hat. Exportieren Sie das Design, um ein Beispiel für die Dateistruktur des Designs zu erhalten.</string>
<string name="auth_open_chat_profiles">Offene Chat-Profile</string> <string name="auth_open_chat_profiles">Offene Chat-Profile</string>
<string name="you_can_share_your_address">Sie können Ihre Adresse als Link oder QR-Code teilen jede Person kann sich mit Ihnen verbinden.</string> <string name="you_can_share_your_address">Sie können Ihre Adresse als Link oder QR-Code teilen jede Person kann sich mit Ihnen verbinden.</string>
<string name="your_contacts_will_see_it">Ihre Kontakte in SimpleX werden es sehen.
\nSie können es in den Einstellungen ändern.</string>
<string name="all_app_data_will_be_cleared">Werden die App-Daten komplett gelöscht.</string> <string name="all_app_data_will_be_cleared">Werden die App-Daten komplett gelöscht.</string>
<string name="empty_chat_profile_is_created">Es wurde ein leeres Chat-Profil mit dem eingegebenen Namen erstellt und die App öffnet wie gewohnt.</string> <string name="empty_chat_profile_is_created">Es wurde ein leeres Chat-Profil mit dem eingegebenen Namen erstellt und die App öffnet wie gewohnt.</string>
<string name="if_you_enter_self_destruct_code">Wenn Sie Ihren Selbstzerstörungs-Zugangscode während des Öffnens der App eingeben:</string> <string name="if_you_enter_self_destruct_code">Wenn Sie Ihren Selbstzerstörungs-Zugangscode während des Öffnens der App eingeben:</string>
@@ -41,7 +41,7 @@
<string name="accept">Αποδοχή</string> <string name="accept">Αποδοχή</string>
<string name="accept_connection_request__question">Αποδοχή αιτήματος σύνδεσης;</string> <string name="accept_connection_request__question">Αποδοχή αιτήματος σύνδεσης;</string>
<string name="callstatus_accepted">αποδεκτή κλήση</string> <string name="callstatus_accepted">αποδεκτή κλήση</string>
<string name="network_enable_socks_info">Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα 9050; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση.</string> <string name="network_enable_socks_info">Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα %d; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση.</string>
<string name="smp_servers_add">Προσθήκη διακομιστή…</string> <string name="smp_servers_add">Προσθήκη διακομιστή…</string>
<string name="network_settings">Προχωρημένες ρυθμίσεις δικτύου</string> <string name="network_settings">Προχωρημένες ρυθμίσεις δικτύου</string>
<string name="v4_3_improved_server_configuration_desc">Προσθήκη διακομιστών μέσω σάρωσης QR κωδικών.</string> <string name="v4_3_improved_server_configuration_desc">Προσθήκη διακομιστών μέσω σάρωσης QR κωδικών.</string>
@@ -298,7 +298,7 @@
<string name="icon_descr_cancel_live_message">Cancelar mensaje en directo</string> <string name="icon_descr_cancel_live_message">Cancelar mensaje en directo</string>
<string name="confirm_verb">Confirmar</string> <string name="confirm_verb">Confirmar</string>
<string name="clear_chat_menu_action">Vaciar</string> <string name="clear_chat_menu_action">Vaciar</string>
<string name="app_version_code">Build de la aplicación</string> <string name="app_version_code">Build de la aplicación: %s</string>
<string name="call_already_ended">¡La llamada ha terminado!</string> <string name="call_already_ended">¡La llamada ha terminado!</string>
<string name="rcv_conn_event_switch_queue_phase_completed">el servidor de envío ha cambiado para tí</string> <string name="rcv_conn_event_switch_queue_phase_completed">el servidor de envío ha cambiado para tí</string>
<string name="icon_descr_cancel_link_preview">cancelar vista previa del enlace</string> <string name="icon_descr_cancel_link_preview">cancelar vista previa del enlace</string>
@@ -1174,8 +1174,6 @@
<string name="share_with_contacts">Compartir con contactos</string> <string name="share_with_contacts">Compartir con contactos</string>
<string name="color_title">Título</string> <string name="color_title">Título</string>
<string name="you_can_share_this_address_with_your_contacts">Puedes compartir esta dirección con tus contactos para que puedan conectar con %s.</string> <string name="you_can_share_this_address_with_your_contacts">Puedes compartir esta dirección con tus contactos para que puedan conectar con %s.</string>
<string name="your_contacts_will_see_it">Tus contactos en SimpleX lo verán.
\nPuedes cambiarlo en Configuración.</string>
<string name="your_contacts_will_remain_connected">Tus contactos permanecerán conectados.</string> <string name="your_contacts_will_remain_connected">Tus contactos permanecerán conectados.</string>
<string name="you_wont_lose_your_contacts_if_delete_address">Si más tarde decides eliminar tu dirección los contactos no se perderán.</string> <string name="you_wont_lose_your_contacts_if_delete_address">Si más tarde decides eliminar tu dirección los contactos no se perderán.</string>
<string name="all_app_data_will_be_cleared">Todos los datos de la aplicación se eliminarán.</string> <string name="all_app_data_will_be_cleared">Todos los datos de la aplicación se eliminarán.</string>
@@ -171,7 +171,6 @@
<string name="chat_archive_header">Arkisto</string> <string name="chat_archive_header">Arkisto</string>
<string name="delete_chat_archive_question">Poista keskusteluarkisto\?</string> <string name="delete_chat_archive_question">Poista keskusteluarkisto\?</string>
<string name="archive_created_on_ts">Luotu %1$s</string> <string name="archive_created_on_ts">Luotu %1$s</string>
<string name="rcv_group_event_changed_your_role">%s:n rooli muutettu %s:ksi</string>
<string name="rcv_group_event_group_deleted">poistettu ryhmä</string> <string name="rcv_group_event_group_deleted">poistettu ryhmä</string>
<string name="group_member_status_connecting">yhdistää</string> <string name="group_member_status_connecting">yhdistää</string>
<string name="group_member_status_accepted">yhdistäminen (hyväksytty)</string> <string name="group_member_status_accepted">yhdistäminen (hyväksytty)</string>
@@ -1141,8 +1140,6 @@
<string name="theme_colors_section_title">TEEMAN VÄRIT</string> <string name="theme_colors_section_title">TEEMAN VÄRIT</string>
<string name="update_network_session_mode_question">Päivitä kuljetuksen eristystila\?</string> <string name="update_network_session_mode_question">Päivitä kuljetuksen eristystila\?</string>
<string name="you_can_create_it_later">Voit luoda sen myöhemmin</string> <string name="you_can_create_it_later">Voit luoda sen myöhemmin</string>
<string name="your_contacts_will_see_it">Kontaktisi SimpleX:ssä näkevät sen.
\nVoit muuttaa sitä Asetuksista.</string>
<string name="to_reveal_profile_enter_password">Voit paljastaa piilotetun profiilisi kirjoittamalla koko salasanan Keskusteluprofiilit-sivun hakukenttään.</string> <string name="to_reveal_profile_enter_password">Voit paljastaa piilotetun profiilisi kirjoittamalla koko salasanan Keskusteluprofiilit-sivun hakukenttään.</string>
<string name="we_do_not_store_contacts_or_messages_on_servers">Emme tallenna mitään kontaktejasi tai viestejäsi (kun ne on toimitettu) palvelimille.</string> <string name="we_do_not_store_contacts_or_messages_on_servers">Emme tallenna mitään kontaktejasi tai viestejäsi (kun ne on toimitettu) palvelimille.</string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Yksityisyyden suojaamiseksi kaikkien muiden alustojen käyttämien käyttäjätunnusten sijaan SimpleX käyttää viestijonojen tunnisteita, jotka ovat kaikille kontakteille erillisiä.</string> <string name="to_protect_privacy_simplex_has_ids_for_queues">Yksityisyyden suojaamiseksi kaikkien muiden alustojen käyttämien käyttäjätunnusten sijaan SimpleX käyttää viestijonojen tunnisteita, jotka ovat kaikille kontakteille erillisiä.</string>
@@ -1175,8 +1175,6 @@
<string name="read_more_in_user_guide_with_link"><![CDATA[Pour en savoir plus, consultez le <font color="#0088ff">Guide de l\'utilisateur</font>.]]></string> <string name="read_more_in_user_guide_with_link"><![CDATA[Pour en savoir plus, consultez le <font color="#0088ff">Guide de l\'utilisateur</font>.]]></string>
<string name="save_auto_accept_settings">Sauvegarder les paramètres d\'acceptation automatique</string> <string name="save_auto_accept_settings">Sauvegarder les paramètres d\'acceptation automatique</string>
<string name="scan_qr_to_connect_to_contact">Pour se connecter, votre contact peut scanner le code QR ou utiliser le lien dans l\'application.</string> <string name="scan_qr_to_connect_to_contact">Pour se connecter, votre contact peut scanner le code QR ou utiliser le lien dans l\'application.</string>
<string name="your_contacts_will_see_it">Vos contacts dans SimpleX la verront.
\nVous pouvez modifier ce choix dans les Paramètres.</string>
<string name="app_passcode_replaced_with_self_destruct">Le code d\'accès de l\'application est remplacé par un code d\'autodestruction.</string> <string name="app_passcode_replaced_with_self_destruct">Le code d\'accès de l\'application est remplacé par un code d\'autodestruction.</string>
<string name="enable_self_destruct">Activer l\'autodestruction</string> <string name="enable_self_destruct">Activer l\'autodestruction</string>
<string name="empty_chat_profile_is_created">Un profil de chat vierge portant le nom fourni est créé et l\'application s\'ouvre normalement.</string> <string name="empty_chat_profile_is_created">Un profil de chat vierge portant le nom fourni est créé et l\'application s\'ouvre normalement.</string>
@@ -1140,8 +1140,6 @@
<string name="invite_friends">Invita amici</string> <string name="invite_friends">Invita amici</string>
<string name="save_auto_accept_settings">Salva le impostazioni di accettazione automatica</string> <string name="save_auto_accept_settings">Salva le impostazioni di accettazione automatica</string>
<string name="you_can_create_it_later">Puoi crearlo più tardi</string> <string name="you_can_create_it_later">Puoi crearlo più tardi</string>
<string name="your_contacts_will_see_it">I tuoi contatti in SimpleX lo vedranno.
\nPuoi modificarlo nelle impostazioni.</string>
<string name="share_address">Condividi indirizzo</string> <string name="share_address">Condividi indirizzo</string>
<string name="enter_welcome_message">Inserisci il messaggio di benvenuto…</string> <string name="enter_welcome_message">Inserisci il messaggio di benvenuto…</string>
<string name="group_welcome_preview">Anteprima</string> <string name="group_welcome_preview">Anteprima</string>
@@ -1222,8 +1222,6 @@
\nלא ניתן לבטל פעולה זו – הפרופיל, אנשי הקשר, ההודעות והקבצים שלך ייאבדו באופן בלתי הפיך.</string> \nלא ניתן לבטל פעולה זו – הפרופיל, אנשי הקשר, ההודעות והקבצים שלך ייאבדו באופן בלתי הפיך.</string>
<string name="your_current_profile">הפרופיל הנוכחי שלך</string> <string name="your_current_profile">הפרופיל הנוכחי שלך</string>
<string name="your_contacts_will_remain_connected">אנשי הקשר שלך יישארו מחוברים.</string> <string name="your_contacts_will_remain_connected">אנשי הקשר שלך יישארו מחוברים.</string>
<string name="your_contacts_will_see_it">אנשי הקשר שלך ב־SimpleX ייראו זאת.
\nניתן לשנות זאת בהגדרות.</string>
<string name="your_ice_servers">שרתי ה־ICE שלך</string> <string name="your_ice_servers">שרתי ה־ICE שלך</string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">אתם תהיו מחוברים כאשר בקשת החיבור תאושר, אנא חכו או בידקו מאוחר יותר!</string> <string name="you_will_be_connected_when_your_connection_request_is_accepted">אתם תהיו מחוברים כאשר בקשת החיבור תאושר, אנא חכו או בידקו מאוחר יותר!</string>
<string name="profile_will_be_sent_to_contact_sending_link">הפרופיל שלך יישלח לאיש הקשר ממנו קיבלת קישור זה.</string> <string name="profile_will_be_sent_to_contact_sending_link">הפרופיל שלך יישלח לאיש הקשר ממנו קיבלת קישור זה.</string>
@@ -687,7 +687,6 @@
<string name="sender_cancelled_file_transfer">ファイル送信が中止されました。</string> <string name="sender_cancelled_file_transfer">ファイル送信が中止されました。</string>
<string name="sender_may_have_deleted_the_connection_request">送信元が繋がりリクエストを削除したかもしれません。</string> <string name="sender_may_have_deleted_the_connection_request">送信元が繋がりリクエストを削除したかもしれません。</string>
<string name="error_smp_test_server_auth">このサーバで待ち行列を作るには認証が必要です。パスワードをご確認ください。</string> <string name="error_smp_test_server_auth">このサーバで待ち行列を作るには認証が必要です。パスワードをご確認ください。</string>
<string name="periodic_notifications_desc">アプリが定期的に新しいメッセージを受信します。一日の電池使用量が約3%で、プッシュ通知に頼らずに、あなたの端末のデータをサーバに送ることはありません。</string>
<string name="la_notice_title_simplex_lock">SimpleXロック</string> <string name="la_notice_title_simplex_lock">SimpleXロック</string>
<string name="enter_passphrase_notification_desc">通知を受けるには、データベースの暗証フレーズを入力してください。</string> <string name="enter_passphrase_notification_desc">通知を受けるには、データベースの暗証フレーズを入力してください。</string>
<string name="simplex_service_notification_title">SimpleX Chat サービス</string> <string name="simplex_service_notification_title">SimpleX Chat サービス</string>
@@ -904,7 +903,6 @@
<string name="settings_section_title_support">SIMPLEX CHATを支援</string> <string name="settings_section_title_support">SIMPLEX CHATを支援</string>
<string name="smp_servers_test_servers">テストサーバ</string> <string name="smp_servers_test_servers">テストサーバ</string>
<string name="switch_receiving_address_desc">受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。</string> <string name="switch_receiving_address_desc">受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery"><![CDATA[あなたのプライバシーを守るために、このアプリはプッシュ通知の変わりに <b>SimpleX バックグラウンド・サービス</b> を使ってます。一日の電池使用量は約3%です。]]></string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。</string> <string name="to_protect_privacy_simplex_has_ids_for_queues">あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。</string>
<string name="group_main_profile_sent">あなたのチャットプロフィールが他のグループメンバーに送られます。</string> <string name="group_main_profile_sent">あなたのチャットプロフィールが他のグループメンバーに送られます。</string>
<string name="to_verify_compare">エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。</string> <string name="to_verify_compare">エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。</string>
@@ -1098,8 +1096,6 @@
\n何らかのバグが原因で、または接続に問題があった場合に発生する可能性があります。</string> \n何らかのバグが原因で、または接続に問題があった場合に発生する可能性があります。</string>
<string name="v5_0_polish_interface_descr">ユーザーに感謝します – Weblate 経由で貢献してください!</string> <string name="v5_0_polish_interface_descr">ユーザーに感謝します – Weblate 経由で貢献してください!</string>
<string name="you_can_accept_or_reject_connection">接続が要求されたら、それを受け入れるか拒否するかを選択できます。</string> <string name="you_can_accept_or_reject_connection">接続が要求されたら、それを受け入れるか拒否するかを選択できます。</string>
<string name="your_contacts_will_see_it">SimpleX の連絡先に表示されます。
\n設定で変更できます。</string>
<string name="icon_descr_video_snd_complete">ビデオが送信されました</string> <string name="icon_descr_video_snd_complete">ビデオが送信されました</string>
<string name="v4_6_group_moderation_descr">管理者は次のことができます。 <string name="v4_6_group_moderation_descr">管理者は次のことができます。
\n- メンバーのメッセージを削除します。 \n- メンバーのメッセージを削除します。
@@ -1173,8 +1173,6 @@
<string name="read_more_in_user_guide_with_link"><![CDATA[Lees meer in de <font color="#0088ff">Gebruikershandleiding</font>.]]></string> <string name="read_more_in_user_guide_with_link"><![CDATA[Lees meer in de <font color="#0088ff">Gebruikershandleiding</font>.]]></string>
<string name="theme_colors_section_title">THEMA KLEUREN</string> <string name="theme_colors_section_title">THEMA KLEUREN</string>
<string name="you_can_share_your_address">U kunt uw adres delen als een link of QR-code - iedereen kan verbinding met u maken.</string> <string name="you_can_share_your_address">U kunt uw adres delen als een link of QR-code - iedereen kan verbinding met u maken.</string>
<string name="your_contacts_will_see_it">Uw contacten in SimpleX kunnen het zien.
\nU kunt dit wijzigen in Instellingen.</string>
<string name="all_app_data_will_be_cleared">Alle app-gegevens worden verwijderd.</string> <string name="all_app_data_will_be_cleared">Alle app-gegevens worden verwijderd.</string>
<string name="empty_chat_profile_is_created">Er wordt een leeg chatprofiel met de opgegeven naam gemaakt en de app wordt zoals gewoonlijk geopend.</string> <string name="empty_chat_profile_is_created">Er wordt een leeg chatprofiel met de opgegeven naam gemaakt en de app wordt zoals gewoonlijk geopend.</string>
<string name="enabled_self_destruct_passcode">Zelfvernietigings wachtwoord inschakelen</string> <string name="enabled_self_destruct_passcode">Zelfvernietigings wachtwoord inschakelen</string>
@@ -1168,8 +1168,6 @@
<string name="save_auto_accept_settings">Zapisz ustawienia automatycznej akceptacji</string> <string name="save_auto_accept_settings">Zapisz ustawienia automatycznej akceptacji</string>
<string name="share_with_contacts">Udostępnij kontaktom</string> <string name="share_with_contacts">Udostępnij kontaktom</string>
<string name="you_can_create_it_later">Możesz go utworzyć później</string> <string name="you_can_create_it_later">Możesz go utworzyć później</string>
<string name="your_contacts_will_see_it">Twoje kontakty w SimpleX będą to widzieć.
\nMożesz to zmienić w Ustawieniach.</string>
<string name="address_section_title">Adres</string> <string name="address_section_title">Adres</string>
<string name="theme_simplex">SimpleX</string> <string name="theme_simplex">SimpleX</string>
<string name="color_secondary">Drugorzędny</string> <string name="color_secondary">Drugorzędny</string>
@@ -1170,8 +1170,6 @@
<string name="opening_database">Abrindo banco de dados…</string> <string name="opening_database">Abrindo banco de dados…</string>
<string name="auth_open_chat_profiles">Abrir perfis de bate-papo</string> <string name="auth_open_chat_profiles">Abrir perfis de bate-papo</string>
<string name="share_address_with_contacts_question">Compartilhar endereço com os contatos\?</string> <string name="share_address_with_contacts_question">Compartilhar endereço com os contatos\?</string>
<string name="your_contacts_will_see_it">Seus contatos no SimpleX o verão.
\nVocê pode alterá-la nas Configurações.</string>
<string name="your_contacts_will_remain_connected">Seus contatos continuarão conectados.</string> <string name="your_contacts_will_remain_connected">Seus contatos continuarão conectados.</string>
<string name="all_app_data_will_be_cleared">Todos os dados do aplicativo serão excluídos.</string> <string name="all_app_data_will_be_cleared">Todos os dados do aplicativo serão excluídos.</string>
<string name="app_passcode_replaced_with_self_destruct">A senha do aplicativo é substituída por uma senha de auto-destruição.</string> <string name="app_passcode_replaced_with_self_destruct">A senha do aplicativo é substituída por uma senha de auto-destruição.</string>
@@ -584,8 +584,6 @@
<string name="message_delivery_error_desc">Muito provavelmente este contato eliminou a conexão consigo.</string> <string name="message_delivery_error_desc">Muito provavelmente este contato eliminou a conexão consigo.</string>
<string name="this_text_is_available_in_settings">Este texto está disponível nas definições</string> <string name="this_text_is_available_in_settings">Este texto está disponível nas definições</string>
<string name="update_onion_hosts_settings_question">Atualizar definições de servidores .onion\?</string> <string name="update_onion_hosts_settings_question">Atualizar definições de servidores .onion\?</string>
<string name="your_contacts_will_see_it">Os seus contatos no SimpleX irão vê-lo.
\nVocê pode alterá-lo nas Definições.</string>
<string name="onboarding_notifications_mode_subtitle">Pode ser alterado mais tarde através das definições.</string> <string name="onboarding_notifications_mode_subtitle">Pode ser alterado mais tarde através das definições.</string>
<string name="settings_section_title_help">AJUDA</string> <string name="settings_section_title_help">AJUDA</string>
<string name="settings_section_title_support">SUPORTE SIMPLEX CHAT</string> <string name="settings_section_title_support">SUPORTE SIMPLEX CHAT</string>
@@ -1280,8 +1280,7 @@
<string name="continue_to_next_step">Продолжить</string> <string name="continue_to_next_step">Продолжить</string>
<string name="dont_create_address">Не создавать адрес</string> <string name="dont_create_address">Не создавать адрес</string>
<string name="you_can_create_it_later">Вы можете создать его позже</string> <string name="you_can_create_it_later">Вы можете создать его позже</string>
<string name="your_contacts_will_see_it">Ваши контакты в SimpleX получат этот адрес. <string name="you_can_make_address_visible_via_settings">Вы можете сделать его видимым для ваших контактов в SimpleX через Настройки.</string>
\nВы можете изменить это в Настройках.</string>
<string name="address_section_title">Адрес</string> <string name="address_section_title">Адрес</string>
<string name="enter_welcome_message">Введите приветственное сообщение…</string> <string name="enter_welcome_message">Введите приветственное сообщение…</string>
<string name="group_welcome_preview">Просмотр</string> <string name="group_welcome_preview">Просмотр</string>
@@ -1159,8 +1159,6 @@
<string name="theme_colors_section_title">สีของธีม</string> <string name="theme_colors_section_title">สีของธีม</string>
<string name="your_contacts_will_remain_connected">ผู้ติดต่อของคุณจะยังคงเชื่อมต่ออยู่</string> <string name="your_contacts_will_remain_connected">ผู้ติดต่อของคุณจะยังคงเชื่อมต่ออยู่</string>
<string name="you_can_create_it_later">คุณสามารถสร้างได้ในภายหลัง</string> <string name="you_can_create_it_later">คุณสามารถสร้างได้ในภายหลัง</string>
<string name="your_contacts_will_see_it">ผู้ติดต่อของคุณใน SimpleX จะเห็น
\nคุณสามารถเปลี่ยนได้ในการตั้งค่า</string>
<string name="you_control_your_chat">คุณเป็นผู้ควบคุมการแชทของคุณ!</string> <string name="you_control_your_chat">คุณเป็นผู้ควบคุมการแชทของคุณ!</string>
<string name="profile_is_only_shared_with_your_contacts">โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น</string> <string name="profile_is_only_shared_with_your_contacts">โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น</string>
<string name="we_do_not_store_contacts_or_messages_on_servers">เราไม่เก็บผู้ติดต่อหรือข้อความของคุณ (เมื่อส่งแล้ว) ไว้บนเซิร์ฟเวอร์</string> <string name="we_do_not_store_contacts_or_messages_on_servers">เราไม่เก็บผู้ติดต่อหรือข้อความของคุณ (เมื่อส่งแล้ว) ไว้บนเซิร์ฟเวอร์</string>

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